diff --git a/docs/SAMPLE_MEMORY_OPTIMIZATION.md b/docs/SAMPLE_MEMORY_OPTIMIZATION.md new file mode 100644 index 00000000..b5a23769 --- /dev/null +++ b/docs/SAMPLE_MEMORY_OPTIMIZATION.md @@ -0,0 +1,1493 @@ +# ProGPU sample memory optimization + +## Current outcome + +This document is updated after every measured optimization step. The current release +baseline is complete for both ProGPU's WinUI gallery and the ProGPU-backed Avalonia +ControlCatalog. No optimization result is accepted unless the same Release workload keeps +frame throughput, compositor timings, explicit GPU residency, and rendered output at least +equivalent within normal run-to-run noise. + +Current step: **core atlas, compositor reservations, target-specific pipeline residency, +shared primary/offscreen binding infrastructure, initial monochrome-glyph residency, and +glyph-outline buffer capacity reduced and applied to the Avalonia backend; WinUI +offscreen-effects, glyph, bidi, and rich-document traversal continuation complete**. + +| Stage | Workload | Physical footprint | Retained managed | Throughput | Status | +|---|---|---:|---:|---:|---| +| Release baseline | WinUI, average of 54 isolated pages | 450.62 MiB | 34.48 MiB | 414.17 FPS average | Complete | +| Release baseline | WinUI, worst page: Text & Documents | 573.08 MiB | 192.10 MiB | 67.61 FPS | Complete | +| Release baseline | Avalonia ControlCatalog: Buttons | 189.94 MiB | 15.17 MiB | Static after warm-up | Complete | +| Release baseline | Avalonia ControlCatalog: TextBlock | 407.34 MiB | 209.92 MiB | 3.4% sampled process CPU | Complete | +| Release baseline | Avalonia ControlCatalog: Custom Drawing | 1,027.28 MiB | 202.46 MiB | 19.0% sampled process CPU | Complete | +| Release baseline | Avalonia ControlCatalog: Composition | 200.20 MiB | 14.50 MiB | Static after warm-up | Complete | +| Step 1 | Avalonia ControlCatalog: TextBlock | 230.53 MiB (**-43.4%**) | 28.68 MiB (**-86.3%**) | 1.9% sampled process CPU | Complete | +| Step 1 | Avalonia ControlCatalog: Custom Drawing | 398.88 MiB (**-61.2%**) | 17.84 MiB (**-91.2%**) | Animated; frame instrumentation pending | Complete | +| Step 2 | Avalonia ControlCatalog: Custom Drawing | 379 MiB (**-63.1% cumulative**) | 17.87 MiB (**-91.2% cumulative**) | 59.85 FPS; 0.640 ms compositor | Complete | +| Step 3 | WinUI: Text & Documents | 525.17 MiB (**-8.4%**) | 129.47 MiB (**-32.6%**) | 73.11 FPS; 2.175 ms compile | Complete | +| Step 3 | WinUI: Text & Documents, forced live heap | — | 77.99 MiB (**-44.2%**) | Same workload and render counts | Complete | +| Step 3 | WinUI: Typography & Scripts | 446.10 MiB (**-11.7%**) | 30.39 MiB (**-65.1%**) | 433.29 FPS | Complete | +| Step 3 | WinUI: Text Shaping Lab | 385.35 MiB (**-20.9%**) | 43.73 MiB (**-45.1%**) | 434.22 FPS | Complete | +| Step 3 | WinUI, final average of all 54 isolated pages | 438.62 MiB (**-2.7%**) | 27.75 MiB (**-19.5%**) | 431.08 FPS average (**+4.1%**) | Complete | +| Step 12 | WinUI, final average of all 54 isolated pages | 446.14 MiB (driver-noise band) | 27.71 MiB | 430.94 FPS; 7.85 KiB/frame (**-31.3%** from Step 4) | Complete | +| Step 13 | WinUI: Compute FX / Image & Buttons | Driver-noise band | Unchanged after compacting GC | 1.23 / 1.39 KiB/frame median; redundant offscreen hit-test index removed | Complete | +| Step 15 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 36.41 KiB/frame median (**-8.3%**); geometric glyph-instance growth | Complete | +| Step 16 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 28.05 KiB/frame median (**-17.9%** from fresh baseline); ASCII UAX resolver stacks eliminated | Complete | +| Step 17 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 27.66 KiB/frame median (**-1.4%**, **-19.1%** cumulative); identity visual-order arrays eliminated | Complete | +| Step 18 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 27.30 KiB/frame median (**-0.3%** paired, **-20.1%** cumulative); bounded line-level workspace | Complete | +| Step 19 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 27.24 KiB/frame median (**-1.2%** paired, **-20.3%** cumulative); indexed recursive inline scans | Complete | +| Step 20 | WinUI: Font Glyph Browser | Driver-noise band | Unchanged after compacting GC | 26.80 KiB/frame median (**-1.0%** same-binary, **-21.6%** cumulative); indexed virtualized height scans | Complete | +| Step 3 | Avalonia ControlCatalog: Custom Drawing | 381 MiB (**-62.9% cumulative**) | 15.36 MiB (**-92.4% cumulative**) | 58.66–60.72 FPS under varying GPU contention | Complete | +| Step 4 | Avalonia, four-page allocation average | Driver-noise band | Unchanged after compacting GC | 4.47 KiB/frame (**-82.1%**) | Complete | +| Step 4 | Avalonia ControlCatalog: Custom Drawing | Driver-noise band | 21.79 MiB | 11.14 KiB/frame (**-81.9%**); 60.11 FPS | Complete | +| Step 12 | Avalonia, four-page allocation average | Driver-noise band | Unchanged after compacting GC | 3.05 KiB/frame (**-87.5%** from release baseline) | Complete | +| Step 12 | Avalonia ControlCatalog: Custom Drawing | 367.88 MiB | 21.79 MiB | 5.86 KiB/frame (**-47.4%** from Step 4); 58.41 FPS under desktop contention | Complete | +| Step 21 | Avalonia compositor persistent scene buffers | Exact configured GPU reservation | — | 252.2 KiB, **-7.19 MiB / -96.7%** from the former 7.44 MiB reservation; geometric one-time growth | Complete | +| Step 22 | Avalonia unused core effect resources | Same-binary Custom Drawing | 21.63 MiB retained managed | **12 shader modules + 12 compute pipelines + 4 layouts + 96 B parameters eliminated**; 6,022 B/frame and 56.40 FPS medians unchanged | Complete | +| Step 23 | Avalonia unused core chart resources | Same-binary Custom Drawing | 21.62 MiB retained managed | **2 shader modules + 4 render pipelines + 2 layouts eliminated**; 6,022 B/frame and 56.49 FPS medians unchanged | Complete | +| Step 24 | Core/Avalonia glyph residency | Exact reservation + same-binary Custom Drawing | 21.52 MiB retained managed | **336 KiB persistent GPU/managed reservation eliminated**; 5,997 B/frame and 56.73 FPS medians unchanged | Complete | +| Step 25 | Core/Avalonia path-atlas residency | Same-binary Custom Drawing | 21.53 MiB retained managed | **3 MiB / 75% path-texture residency eliminated**; 5,998 B/frame and 56.28 FPS median with no timing regression | Complete | +| Step 26 | Core/Avalonia target pipelines | Same-binary Custom Drawing | 21.54 MiB retained managed | **10 → 5 resident scene pipelines**; first rendered frame **-12.0 ms median**, steady timings unchanged | Complete | +| Step 27 | Core/Avalonia target bindings | Same-binary Custom Drawing | 21.52 MiB retained managed | **17 duplicate native binding objects eliminated**; allocation and compositor timings unchanged | Complete | +| Step 28 | Core/Avalonia monochrome glyph atlas | Same-binary Custom Drawing | 21.52 MiB retained managed | **262,144 → 67,600 B persistent R8 texture (-74.2%, -190 KiB)**; no growth or timing regression | Complete | +| Step 29 | Core/Avalonia glyph-outline capacity | Same-binary Custom Drawing | **12,800 B direct managed element reservation eliminated** | **51,200 → 44,544 B persistent GPU buffers (-13.0%)**; allocation unchanged and no capacity growth | Complete | + +The target is to approach a 50% reduction where the retained data is avoidable. It is a +directional target, not permission to lower raster quality, reduce benchmark work, skip +invalidation, disable animations, or trade memory for worse CPU/GPU frame performance. + +## Release baseline + +The baseline starts from commit +`cd768dbd53724544ae38adc959fa246414dbb898` (`v0.1.0-preview.27`). Both applications were +built once in Release and the exact binaries were reused for all measurements. + +### WinUI sample gallery + +`tools/profile-sample-memory.sh` launched every one of the 54 pages in a fresh process. Each +process ran 120 warm-up frames, performed a blocking compacting GC, measured 300 frames, +performed another compacting GC, wrote its metrics, and exited: + +```bash +PROGPU_MEMORY_SKIP_BUILD=1 \ +PROGPU_MEMORY_KEEP_TRACES=0 \ +PROGPU_MEMORY_WARMUP_FRAMES=120 \ +PROGPU_MEMORY_MEASURE_FRAMES=300 \ +tools/profile-sample-memory.sh artifacts/memory-optimization/baseline-winui +``` + +| Metric | 54-page average | Worst measured page | +|---|---:|---:| +| macOS physical footprint | 450.62 MiB | 573.08 MiB, Text & Documents | +| Retained managed memory | 34.48 MiB | 192.10 MiB, Text & Documents | +| Allocation per measured frame | 11.31 KiB | 197 KiB, Text & Documents | +| Wall throughput | 414.17 FPS | 67.61 FPS, Text & Documents | +| Compositor compilation | 0.415 ms | 3.811 ms page average, LOL/s | + +The highest retained pages are text- or tooling-heavy: Text & Documents (192.10 MiB), +Typography & Scripts (87.07 MiB), Text Shaping Lab (79.58 MiB), GPU Charting (76.07 MiB), +LOL/s (74.94 MiB), Inter Typeface (69.04 MiB), and Visual Designer (64.91 MiB). The ordinary +page floor is approximately 22–26 MiB retained managed memory. Text & Documents also retains +118.8 MiB on the large object heap and 27.7 MiB of fragmentation, so it remains the primary +WinUI follow-up after the Avalonia outlier is corrected. + +### Avalonia ControlCatalog + +Four representative pages were launched in fresh Release processes and left visible for +15 seconds. Physical footprint came from `/usr/bin/footprint`, process CPU and RSS from +`ps`, and retained managed objects from a forced-GC `dotnet-gcdump`. + +| Page | Physical footprint | Lifetime peak | Retained managed | RSS | Sampled CPU | +|---|---:|---:|---:|---:|---:| +| Buttons | 189.94 MiB | 369.75 MiB | 15.17 MiB | 113.20 MiB | 0.9% | +| TextBlock | 407.34 MiB | 594.24 MiB | 209.92 MiB | 119.84 MiB | 3.4% | +| Custom Drawing | 1,027.28 MiB | 1,027.34 MiB | 202.46 MiB | 298.28 MiB | 19.0% | +| Composition | 200.20 MiB | 400.78 MiB | 14.50 MiB | 108.02 MiB | 0.4% | + +The forced-GC dumps identify one `System.Byte[]` of 192,123,512 bytes on both outlier pages. +`/System/Library/Fonts/Apple Color Emoji.ttc` is 192,123,488 bytes; the 24-byte difference is +the managed array header. This proves that `FontManagerImpl` reads and permanently caches the +entire color-emoji collection when TextBlock renders emoji or Custom Drawing renders its +plus/minus symbols. Candidate fallback currently loads a complete font before checking its +compact `cmap`, so unrelated fallback candidates can also be read eagerly. + +Custom Drawing additionally invalidates itself continuously. That workload is intentional +and will remain enabled: after font residency is fixed, its remaining native/GPU growth must +be measured and corrected without reducing its animation rate or drawing content. + +## Performance and quality gates + +Every step must pass all applicable gates: + +- identical Release build, window size, page, warm-up, and measurement duration; +- retained managed bytes after a compacting full GC and macOS physical footprint; +- exact allocation per frame plus GC collection and pause counts; +- wall FPS, compile/upload/render timings, frame-budget misses, and sampled CPU; +- explicit glyph/path atlas textures, staging buffers, entries, generations, and evictions; +- deterministic screenshots or render-test output for affected text, emoji, paths, and + animated content; +- focused unit tests followed by the complete ProGPU and Avalonia renderer test suites. + +Small sub-millisecond timing changes are reported rather than hidden. A change that lowers +memory by dropping a glyph, changing fallback, reducing physical-DPI rasterization, altering +subpixel placement, disabling an animation, or avoiding required invalidation is a failure. + +## Cross-engine design record + +This is a clean-room implementation. No external source is copied, translated, ported, or +structurally reproduced. Primary sources are used only to choose observable behavior and +architecture; the implementation remains ProGPU's typed, reflection-free font, scene, +atlas-generation, invalidation, and GPU ownership design. + +| Primary source | Observed architecture | ProGPU decision | +|---|---|---| +| [Skia strike cache](https://github.com/google/skia/blob/main/src/core/SkStrikeCache.cpp), [SkParagraph font collection](https://github.com/google/skia/blob/main/modules/skparagraph/src/FontCollection.cpp) | Memory- and count-budgeted glyph strikes are made most-recently-used on lookup and purged; paragraph font fallback caches selected typefaces, with explicit cache clearing. | Adopt bounded residency and stable face identity. Do not retain an unbounded full-file array merely because one glyph was requested. | +| [DirectWrite custom font sets](https://learn.microsoft.com/en-us/windows/win32/directwrite/custom-font-sets-win10), [glyph-run analysis](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwriteglyphrunanalysis), [Direct2D performance guidance](https://learn.microsoft.com/en-us/windows/win32/direct2d/improving-direct2d-performance) | Lightweight face references are distinct from actual font data; file streams are opened when needed, character/glyph subsets can be requested, and layout/raster results are reusable. | Keep catalog and `cmap` metadata lightweight, instantiate a renderable face only after coverage matches, and make heavy bitmap-glyph payloads demand-resident. | +| [Win2D CanvasTextLayout](https://microsoft.github.io/Win2D/WinUI3/html/T_Microsoft_Graphics_Canvas_Text_CanvasTextLayout.htm), [Win2D QuickStart](https://microsoft.github.io/Win2D/WinUI2/html/QuickStart.htm) | A text layout is a cached, reusable drawable result; repeatedly drawn resources should be created once and reused. | Preserve Avalonia/HarfBuzz shaped-run reuse and the existing retained glyph commands. Reject per-frame face or layout construction as a memory workaround. | +| [WebRender overview](https://searchfox.org/firefox-main/source/gfx/docs/RenderingOverview.rst), [blob image design](https://searchfox.org/firefox-main/source/gfx/wr/webrender/doc/blob.md) | Visibility narrows a retained scene to a frame; raster work can be prepared off-thread while texture upload remains lazy and visibility-driven. | Retain font metadata and shaping state on the CPU, but make bitmap-glyph payload and GPU-atlas residency demand-driven. | +| [Vello glyph design](https://github.com/linebender/vello/issues/204), [Parley/Fontique architecture](https://github.com/linebender/parley) | Unicode/OpenType shaping remains a CPU concern; Fontique memory-maps font files so pages are loaded lazily and shared by the OS, while glyph rendering is independently cached. | Preserve HarfBuzz shaping, use the glyph-resident `sbix` path for huge bitmap collections, and use read-only OS/assembly-backed storage for ordinary raw SFNT payloads. Keep only selected compact `cmap` subtables managed for hot scalar lookup. | +| [HarfBuzz face table callbacks](https://harfbuzz.github.io/harfbuzz-hb-face.html), [HarfBuzz blob ownership](https://harfbuzz.github.io/harfbuzz-hb-blob.html), [shaping-plan caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | HarfBuzz can request individual tables, wraps caller-owned immutable data, and benefits from stable reusable faces/plans. | Supply a compact, immutable standalone shaping face containing complete layout tables while stripping unrequested heavy `sbix` records; keep the face alive and cached for reuse. | +| [Skia `SkCanvas`](https://api.skia.org/classSkCanvas.html), [Skia draw dispatch](https://skia.googlesource.com/skia/+/c6e63919c318/src/core/SkCanvas.cpp), [Skia `SkDraw`](https://skia.googlesource.com/skia/+/f1fab32641ade28345693284437b5295d0749f04/src/core/SkDraw.h) | Rectangles, rounded rectangles, and ovals remain explicit primitive operations rather than requiring a caller-created general path. | Keep Avalonia primitive commands typed through recording and create a clip path only for scene/image brushes that actually require geometry clipping. | +| [Direct2D `DrawRectangle`](https://learn.microsoft.com/en-us/windows/win32/direct2d/id2d1rendertarget-drawrectangle), [`FillRectangle`](https://learn.microsoft.com/en-us/windows/win32/direct2d/id2d1rendertarget-fillrectangle), [`FillRoundedRectangle`](https://learn.microsoft.com/en-us/windows/win32/api/d2d1/nf-d2d1-id2d1rendertarget-fillroundedrectangle%28constd2d1_rounded_rect_id2d1brush%29) | The render target accepts typed primitives and reusable brush resources directly. | Reuse bounded, value-keyed solid brush and pen conversions while preserving current color, opacity, thickness, cap, join, and miter values. | +| [WebRender renderer bindings](https://searchfox.org/mozilla-central/source/gfx/webrender_bindings/src/bindings.rs), [WebRender rendering overview](https://searchfox.org/mozilla-central/source/gfx/docs/RenderingOverview.rst) | Renderer resources and retained pictures are cached, while shader precompilation is an explicit policy rather than incidental per-draw work. | Reuse stable adapter resources and demand-create independent effect/chart and primary/offscreen vector/text/texture pipeline families. Keep `PrecompileBasePipelines` as an explicit opt-in policy for clients that intentionally trade residency for prewarming. | +| [Vello](https://github.com/linebender/vello) | The scene encoding retains typed fills and strokes for a compute-centric renderer. | Preserve typed ProGPU commands and bounded conversion reuse; do not flatten ordinary Avalonia primitives into transient path object graphs. | +| [Skia `drawLine`](https://skia.googlesource.com/skia/+/620de5ac9f6b/include/core/SkCanvas.h), [Direct2D `DrawLine`](https://learn.microsoft.com/en-us/windows/win32/api/d2d1/nf-d2d1-id2d1rendertarget-drawline) | A line remains a typed draw operation; Direct2D represents the ordinary solid stroke with no separate stroke-style object. | Record an ordinary solid Avalonia line directly and retain the general path/stroke expansion only for dashed lines. Keep the same caps, joins, transform, antialiasing, and device-space width policy. | +| [Skia Graphite graphics-pipeline key](https://skia.googlesource.com/skia/+/ca5481ebd0fb/src/gpu/graphite/GraphicsPipeline.h), [WebRender program cache](https://searchfox.org/firefox-main/source/gfx/webrender_bindings/src/program_cache.rs), [WebGPU render-pipeline contract](https://gpuweb.github.io/types/interfaces/GPURenderPipeline.html) | Expensive native pipelines have stable identities and are reused after selection; the frame path should select an existing pipeline rather than rebuild its textual description. | Front the existing owning pipeline cache with a value-keyed pointer selection cache. Ownership and release remain in the original cache, while steady-state selection avoids interpolated names and descriptor reconstruction. | +| [Skia Graphite resource ownership](https://skia.googlesource.com/skia/+/5fdfc51ef3e8/src/gpu/graphite/Resource.h), [Direct2D resource domains](https://learn.microsoft.com/en-us/windows/win32/direct2d/resources-and-resource-domains), [WebRender GPU cache](https://searchfox.org/firefox-main/source/gfx/wr/webrender/src/gpu_cache.rs), [Vello](https://github.com/linebender/vello), and the [WebGPU buffer/bind-group contract](https://gpuweb.github.io/gpuweb/) | GPU resources have explicit ownership and reusable lifetimes; retained renderers allocate/cache resources on demand; a bind group captures a concrete buffer binding and range rather than following a later buffer replacement. | Reserve scene buffers for the ordinary retained workload, grow them geometrically only after measured demand exceeds capacity, recreate the two vector scene-state bind groups exactly when brush/gradient storage changes, and keep the grown buffers stable across retained frames. Text shaping/layout remains at the existing Parley/HarfBuzz-style CPU boundary. | +| [WebGPU pipeline-layout creation](https://gpuweb.github.io/gpuweb/#pipeline-layout-creation), [WebGPU bind-group compatibility](https://gpuweb.github.io/gpuweb/#bind-group-compatibility), [Direct2D resource domains](https://learn.microsoft.com/en-us/windows/win32/direct2d/resources-and-resource-domains), [Win2D QuickStart](https://microsoft.github.io/Win2D/WinUI2/html/QuickStart.htm), and [Skia Graphite resource ownership](https://skia.googlesource.com/skia/+/5fdfc51ef3e8/src/gpu/graphite/Resource.h) | Pipelines created with the same explicit layout have a stable bind-group ABI; compatible resources are reusable across targets on one device, while render-target format and sample count remain pipeline state. Production engines keep device resources cache-owned and shared rather than cloning them per surface. | Give the primary and offscreen base pipelines one explicit resource-binding ABI. Share layouts and immutable bind groups across both targets, retain sample count in each render-pipeline key, atomically replace one shared bind group after buffer/atlas revision changes, and keep one dynamic mask bind group per texture. | +| [Skia Graphite resource ownership](https://skia.googlesource.com/skia/+/5fdfc51ef3e8/src/gpu/graphite/Resource.h), [WebRender texture cache](https://searchfox.org/firefox-main/source/gfx/wr/webrender/src/texture_cache.rs), and the [WebGPU texture descriptor](https://gpuweb.github.io/gpuweb/#dictdef-gputexturedescriptor) | Cached GPU images have explicit two-dimensional extents and residency; width and height are independent texture-allocation inputs, and normalized sampling addresses each axis against its own extent. | Grow the path atlas geometrically per axis from measured raster demand. Preserve existing texel coordinates during growth, normalize X and Y independently, and retain ProGPU's generation/retry contract rather than forcing a square allocation for a tall or wide live set. | +| [WebGPU object labels](https://gpuweb.github.io/gpuweb/#dom-gpuobjectbase-label) | Labels are diagnostic metadata and do not participate in rendering semantics. | Preserve readable encoder and command-buffer labels as static null-terminated UTF-8 data pinned for the synchronous call, avoiding per-frame managed/native string marshalling. | +| [Skia `SkSurface`](https://api.skia.org/classSkSurface.html) and [`SkPath::contains`](https://api.skia.org/classSkPath.html), [Direct2D offscreen interop](https://learn.microsoft.com/en-us/windows/win32/Direct2D/direct2d-and-direct3d-interoperation-overview), [Win2D offscreen drawing](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/offscreen-drawing), [`CanvasGeometry.FillContainsPoint`](https://microsoft.github.io/Win2D/WinUI3/html/M_Microsoft_Graphics_Canvas_Geometry_CanvasGeometry_FillContainsPoint_1.htm), and [`IDWriteTextLayout::HitTestPoint`](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwritetextlayout-hittestpoint) | Rendering to a texture/surface and geometry or text hit testing are separate operations. An offscreen target used as an effect input has no implicit input tree or hit-test query. | Disable ProGPU's optional GPU hit-test index only on the sample's private offscreen effects compositor. Preserve the screen compositor policy and every render, atlas, DPI, effect, and texture operation. | +| [WebRender renderer and `hit_test` modules](https://searchfox.org/firefox-main/source/gfx/wr/webrender/src), [Vello render-to-texture architecture](https://github.com/linebender/vello), [Parley text layout](https://github.com/linebender/parley), and [HarfBuzz shaping/rendering boundary](https://harfbuzz.github.io/shaping-and-shape-plans.html) | WebRender keeps hit testing as a distinct scene service; Vello renders a scene directly to a target texture; Parley and HarfBuzz produce reusable text layout/shaping data rather than coupling raster output to hit testing. | Keep text shaping/layout and visible scene compilation unchanged. Do not build or upload a second spatial index for an offscreen texture that is never queried. | +| [Unicode UAX #9 revision 51](https://www.unicode.org/reports/tr9/tr9-51.html), [DirectWrite text analysis](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetextanalyzer), [Skia Unicode interface](https://skia.googlesource.com/skia/+/a0fd12aac6b3/modules/skunicode/include/SkUnicode.h), [Skia bidi tests](https://skia.googlesource.com/skia/+/7a2127711a40/modules/skunicode/tests/SkUnicodeTest.cpp), [Firefox text-frame layout](https://searchfox.org/firefox-main/source/layout/generic/nsTextFrame.cpp), [Parley](https://github.com/linebender/parley), [Vello](https://github.com/linebender/vello), and [HarfBuzz buffer contract](https://harfbuzz.github.io/harfbuzz-hb-buffer.html) | UAX #9 permits an equivalent implementation that produces the specified result. DirectWrite and Skia resolve bidi before shaping/rendering; Firefox keeps embedding levels in text layout; Parley delegates Unicode analysis to ICU4X; HarfBuzz consumes direction-homogeneous runs and does not perform bidi; Vello begins at the already-laid-out scene boundary. | Recognize only the exhaustively verified level-zero ASCII case before invoking the full UAX resolver. Preserve fresh result ownership, explicit-RTL behavior, every non-ASCII path, run boundaries, shaping inputs, and the renderer boundary. | + +Rejected alternatives are disabling color emoji, substituting monochrome symbols, lowering +glyph resolution, recreating typefaces per frame, clearing all caches after every draw, or +moving Unicode/OpenType shaping to the GPU. They either change output, increase CPU work, +destroy retained-scene reuse, or are incomplete for complex scripts and emoji sequences. + +## Step log + +### Step 0 — release baseline + +Status: **complete**. + +No source was changed. The measurements above establish the comparison point and identify +two independent Avalonia costs: a deterministic 192.1 MB managed color-font retention issue +and sustained native/GPU growth under the animated Custom Drawing workload. + +### Step 1 — demand-resident Avalonia system fonts + +Status: **complete**. + +Implemented change: + +1. Test compact `cmap` coverage before loading any fallback face. +2. Reuse ProGPU's tested glyph-resident `sbix` construction for system font files larger + than 16 MiB. The compact face retains complete `cmap`, GSUB, GPOS, metrics, and other + shaping tables while replacing the heavy bitmap table with one resident glyph. +3. Attach a typed file-backed bitmap source to that face. Any glyph index emitted later by + shaping, including a different emoji or a sequence/ligature result, is read directly from + the original `sbix` table without loading the whole font. +4. Keep compressed demand-loaded glyph records in a 16 MiB per-face LRU cache. Oversized + single records remain transient; eviction never invalidates decoded pixels or GPU atlas + entries. +5. Keep the stable Avalonia typeface and HarfBuzz face cached. No per-frame typeface, + shaping-face, or layout construction was added. + +#### Step 1 comparison + +The same four fresh-process, 15-second Release measurements were repeated: + +| Page | Physical before | Physical after | Change | Managed before | Managed after | Change | +|---|---:|---:|---:|---:|---:|---:| +| Buttons | 189.94 MiB | 200.14 MiB | +5.4% | 15.17 MiB | 15.18 MiB | +0.1% | +| TextBlock | 407.34 MiB | 230.53 MiB | **-43.4%** | 209.92 MiB | 28.68 MiB | **-86.3%** | +| Custom Drawing | 1,027.28 MiB | 398.88 MiB | **-61.2%** | 202.46 MiB | 17.84 MiB | **-91.2%** | +| Composition | 200.20 MiB | 194.14 MiB | -3.0% | 14.50 MiB | 14.49 MiB | -0.1% | + +The unaffected Buttons/Composition retained heaps are unchanged. Their physical movement +(-3.0% to +5.4%) defines the run-to-run native/driver noise band for this short workload. +The affected pages no longer contain the 192,123,512-byte array. Their largest arrays are +the expected 1 MiB renderer buffers and decoded color-glyph pixels. + +Focused verification: + +- all seven `ProGpuFontManagerImplTests` pass, including a real macOS Apple Color Emoji + test and a synthetic 32 MiB non-matching fallback candidate; +- the low-level glyph-resident regression test loads a second, initially absent `sbix` + glyph on demand while the standalone shaping face remains below 1 MiB; +- the full ControlCatalog Release build succeeds; +- TextBlock renders its emoji sequences and Custom Drawing renders its plus/minus symbols + through the same color-bitmap and GPU-atlas path. + +The single 15-second process CPU samples moved from 3.4% to 1.9% for TextBlock, 0.9% to +0.6% for Buttons, and 0.4% to 0.5% for Composition. Custom Drawing moved from 19.0% to +26.8%, which is not accepted as a regression or gain from one sample: the page is an +unthrottled continuous animation, so step 2 adds frame-count and compositor timing +instrumentation and compares equal frame workloads. + +### Step 2 — animated Avalonia native/GPU residency + +Status: **complete**. + +The font fix exposed command recording as the remaining avoidable managed churn. Avalonia +creates a fresh `DrawingContextImpl` for each render and the old path first recorded into +one command array, then appended every command into a second `DrawingVisual` array. The +animated page therefore allocated about 1.18 MiB each frame and repeatedly promoted large +command arrays. + +Implemented change: + +1. Rent one cleared command-recording context from each offscreen target and return it after + rendering. Resource leases are released transactionally before reuse and concurrent + recording still receives an independent context. +2. Compile the recorded command stream directly through a typed + `IOwnedRenderCommandCache` visual instead of copying it with `Append`. +3. Preserve separate persistent contexts for cached layers. Reuse applies only to ordinary + frame recording and cannot alias a retained layer. +4. Report recorded command count and capacity alongside compositor, path/glyph atlas, and + GPU-residency telemetry. + +#### Step 2 comparison + +The matched 240-warm-up/1,200-frame Custom Drawing run produced: + +| Metric | Before direct reuse | After direct reuse | Change | +|---|---:|---:|---:| +| Allocation/frame | 1,179,083 B | 65,179 B | **-94.5%** | +| Gen0 / Gen1 / Gen2 collections | 66 / 44 / 42 | 9 / 0 / 0 | Gen1/Gen2 eliminated | +| Wall throughput | 59.62 FPS | 59.85 FPS | +0.4% | +| Compile / upload / render | — | 0.121 / 0.187 / 0.332 ms | Within 60 Hz budget | +| Compositor | 0.515 ms | 0.640 ms | noisy sub-millisecond movement | +| Recorded commands / capacity | — | 352 / 512 | exact telemetry | +| Draws / vector vertices / path entries | 113 / 528 / 26 | 113 / 528 / 26 | unchanged | + +A repeated run measured 0.640 ms compositor time with no wgpu validation errors. The +15-second forced-GC heap was 18.74 MiB and physical footprint was 379 MiB. Relative to the +release baseline, Custom Drawing is cumulatively down 63.1% physical and 91.2% retained +managed memory without changing its continuous animation. + +An experimental O(1) list-set swap inside `Compositor.RenderOffscreenCore` was rejected and +fully reverted. A forced-GC dump proved those snapshot arrays were dead LOH history rather +than live retention, the experiment produced no meaningful live-heap gain, and its timing +was not better. The direct recording-context reuse addresses the actual per-frame allocation. + +### Step 3 — zero-copy ordinary and bundled font payloads + +Status: **complete**. + +The WinUI Text & Documents forced-GC graph retained 146.58 MiB. Four complete system-font +payloads accounted for 48.46 MiB, while bundled Noto faces duplicated immutable bytes that +were already present in mapped assembly images. The rest was chiefly the intentional +20,000-paragraph benchmark document and its virtualized editor model; that workload was not +reduced. + +Implemented change: + +1. Raw SFNT/TTC file constructors retain a read-only memory-mapped view instead of a full + managed `File.ReadAllBytes` array. Mapping uses delete-sharing, has stable pointer + ownership, and remains alive with every face and variation instance. +2. Embedded raw fonts retain the runtime's `UnmanagedMemoryStream` over the assembly image. + Inter and Noto therefore no longer allocate a second copy of the resource. +3. WOFF/WOFF2 continues through the existing normalization path because decompression + necessarily creates a standalone SFNT payload. CFF fallback and variable-font parsing + copy only when their existing byte-array parser is actually demanded. +4. `SfntFontFace` and `TtfFont.FontData` use `ReadOnlyMemory`. Only the selected compact + `cmap` subtables are copied into managed memory to keep character lookup free of + `MemoryManager` dispatch. Scalar metrics use the stable read-only address. +5. Avalonia no longer keeps a second per-file byte-array cache. Its typeface stream is a + bounded read-only stream over the same font memory and is shared by both Avalonia 12 and + the source-linked Avalonia 11 build. + +The final large-face construction checks are: + +| Face | Payload | Managed construction allocation | Hot lookup | +|---|---:|---:|---:| +| Hiragino Sans GB TTC | 23.52 MiB | 0.84 MiB selected metadata/`cmap` | 15.65–16.16 ms | +| Same face, whole managed array | 23.52 MiB | 23.52 MiB payload plus metadata | 15.62–16.63 ms | +| Embedded Noto CJK | 16.47 MiB | 0.23 MiB selected metadata/`cmap` | exact pinned payload/hash | + +The lookup microcheck performs 1,000,000 repeated character-map and advance operations. +Mapped and array-backed storage are at parity after the compact-hot-table change. + +#### Step 3 WinUI comparison + +| Workload | Physical before | Physical after | Managed before | Managed after | Throughput before | Throughput after | +|---|---:|---:|---:|---:|---:|---:| +| Text & Documents | 573.08 MiB | 525.17 MiB (**-8.4%**) | 192.10 MiB | 129.47 MiB (**-32.6%**) | 67.61 FPS | 73.11 FPS | +| Typography & Scripts | 505.33 MiB | 446.10 MiB (**-11.7%**) | 87.07 MiB | 30.39 MiB (**-65.1%**) | 433.52 FPS | 433.29 FPS | +| Text Shaping Lab | 486.92 MiB | 385.35 MiB (**-20.9%**) | 79.58 MiB | 43.73 MiB (**-45.1%**) | 367.57 FPS | 434.22 FPS | + +Text & Documents compile time is 2.1745 ms versus 2.1828 ms at baseline, and allocation is +199,543 versus 199,751 bytes/frame. Its forced-GC live heap is 81.78 MiB versus 146.58 MiB +(**-44.2%**). The complete 1.2–23.5 MiB font arrays are absent; its largest live object is +the unchanged 3.76 MiB benchmark text string. The final trace reports exactly 63 draws, 676 +vector vertices, and 2,782 path entries, matching baseline. + +The final fresh-process sweep repeated all 54 pages with the baseline's 120 warm-up and 300 +measured frames. Average retained managed memory fell from 34.48 to 27.75 MiB (**-19.5%**) +and average physical footprint from 450.62 to 438.62 MiB (**-2.7%**). Average throughput +rose from 414.17 to 431.08 FPS (**+4.1%**) and average compile time fell from 0.415 to +0.292 ms (**-29.7%**). Allocation moved from 11,581 to 11,705 bytes/frame (+1.1%, 124 +bytes/frame), within cross-process sampling noise; Text & Documents itself fell slightly. + +#### Step 3 Avalonia comparison + +The same source is used by Avalonia. Custom Drawing's forced-GC heap fell from 18.74 MiB to +16.11 MiB (**-14.0%**) while physical footprint remained in the same driver-noise band +(379–381 MiB). Two low-contention 1,200-frame runs measured 60.67 and 60.72 FPS with +0.550 and 0.519 ms compositor time. Later runs during heavy desktop GPU contention measured +56.72–58.66 FPS and 0.897–1.221 ms; the recorded workload and explicit residency remained +identical in all runs: 352 commands/capacity 512, 113 draws, 528 vector vertices, 26 path +entries, and unchanged atlas texture sizes. + +### Step 4 — Avalonia platform steady-state allocations + +Status: **complete**. + +A backend-neutral ControlCatalog harness now runs the same pinned Avalonia source, page, +window size, warm-up, measured animation-frame count, compacting collections, and screenshot +capture against native `Avalonia.Skia` and `Avalonia.ProGpu`. It reports exact +`GC.GetTotalAllocatedBytes` deltas, retained managed/GC state, macOS physical footprint, CPU, +frame time, and ProGPU compositor/atlas residency where available. + +The profile found three adapter-owned costs: + +1. Solid rectangle, rounded-rectangle, and ellipse calls eagerly built a complete + `PathGeometry` even when their solid or gradient brush did not need a clip path. +2. Silk.NET event pumping copied the complete window list on every loop iteration. +3. Every draw converted the same Avalonia solid brush and pen into new ProGPU objects. + +The final implementation keeps primitive geometry lazy, publishes a copy-on-write immutable +window snapshot only when a window is registered or removed, and reuses solid styles through +a 256-entry value cache. The style key contains current packed color, opacity, thickness, +join, miter, and cap values, so mutating an Avalonia brush or pen selects a new converted +value. Cache overflow clears the bounded cache; retained commands keep their referenced +style alive independently. + +The matched baseline is the Step 3 tree before these platform changes. Final values are +medians of three fresh Release processes, each with 180 warm-up and 300 measured frames: + +| Page | Allocation/frame before | Allocation/frame after | Change | FPS before | FPS after | +|---|---:|---:|---:|---:|---:| +| Buttons | 6,961 B | 2,160 B | **-69.0%** | 62.47 | 62.68 | +| TextBlock | 18,137 B | 2,161 B | **-88.1%** | 62.56 | 62.69 | +| Custom Drawing | 63,010 B | 11,402 B | **-81.9%** | 58.09 | 60.11 | +| Composition | 11,626 B | 2,158 B | **-81.4%** | 62.69 | 62.66 | +| Four-page average | 24,934 B | 4,470 B | **-82.1%** | — | — | + +Native Skia measured 2,179–2,183 B/frame on the three ordinary pages and 7,261 B/frame on +Custom Drawing. ProGPU now matches the ordinary-page allocation floor and reduced the +animated-page gap from 8.7x to 1.6x. The remaining animated allocations are chiefly +Avalonia composition scheduling plus path/stroke caches for changing custom geometry; they +were not hidden by disabling invalidation or animation. + +Retained managed memory was unchanged after compacting GC, as expected for a churn +optimization. macOS physical-footprint medians moved in both directions inside the observed +driver/process noise band. Explicit ProGPU residency stayed fixed on Custom Drawing: 352 +recorded commands with capacity 512, 113 draws, 528 vector vertices, 226 text vertices, 26 +path entries, a 4 MiB path atlas, and 256 KiB each for glyph, color-glyph, and coverage +staging storage. Compile and render medians improved from 0.191/0.545 ms to 0.121/0.350 ms. + +An allocation trace independently measured 19,685 B/frame before solid-style reuse and +11,378 B/frame after it. The sampled `ProGPU.Vector.SolidColorBrush` hot spot disappeared. +The earlier full baseline trace was 65,764 B/frame; the final trace is **82.7% lower**. + +Quality and behavior checks: + +- Buttons, TextBlock, and Composition screenshots are byte-identical to their pre-change + ProGPU baselines. +- Custom Drawing is time-dependent; visual inspection confirms the same clipped scene, + strokes, ellipses, text, transforms, and antialiasing at a later animation phase. +- Solid-style mutation tests prove that color changes do not reuse stale converted values. +- Scene/image brush clipping still takes the general geometry path. +- No wgpu validation, uncaptured, or fatal errors occurred in the full measured matrix. +- The rejected macOS Metal-only instance mask and lazy chart-pipeline experiments were + fully reverted because repeated measurements did not establish a memory win. + +A fresh 54/54 WinUI gallery sweep after the Avalonia-only changes completed without a +wgpu error. Relative to the isolated Step 3 sweep, average managed retention was stable +within 0.06%, measured allocation fell 1.76%, and physical footprint moved 1.2% inside +the observed cross-process/driver noise band. Its throughput samples are not used as a +regression comparison because concurrent Roslyn, MSBuild/test, macOS media-analysis, and +WindowServer processes consumed several hundred percent CPU. A targeted four-page repeat +remained similarly contended. The valid isolated WinUI performance comparison is therefore +the Step 3 54-page result above; Step 4 changes only the Avalonia adapter, native ControlCatalog +benchmark, and Silk.NET event pump and do not enter the WinUI rendering path. + +### Steps 5–12 — retained geometry and bounded renderer hot paths + +Status: **complete**. + +The Step 4 trace still showed avoidable work on the continuously invalidated Custom Drawing +page. The continuation keeps the public drawing behavior and recorded workload unchanged +while making the following resources retained or stack-only: + +1. `GeometryImpl` owns one lazily built render-command geometry cache. Repeated draws are + average `O(1)` lookups; every `StreamGeometryImpl` mutation clears the cache before the + next `O(N)` rebuild for `N` segments. +2. An ordinary solid line remains a typed line command. Dashed lines retain the existing + general path/stroke cache because their dash expansion depends on path length and style. +3. Stroke-direction selection uses fixed one-, two-, and three-candidate overloads. It + preserves candidate order and numerical checks with fixed `O(1)` work and no temporary + `params` array. +4. Each UI thread pools at most four cleared drawing-context state sets. A state whose + opacity, clip, render-options, or text-options stack ever grows beyond 64 entries is not + retained, so an unusually deep frame cannot permanently inflate the thread-local pool. +5. Pipeline selection is keyed by draw type, typed vector specialization, blend mode, + target kind/format, alpha mode, and mask presence. The front cache stores only the + already-owned native pointer; the existing pipeline cache remains the sole owner and + releases every native pipeline exactly once. Selection is average `O(1)` and no longer + formats pipeline names after the first creation. +6. Unequal-radius rounded-rectangle fallback geometry uses a value-keyed cache capped at + 256 entries. Overflow clears this auxiliary lookup; commands and the path atlas retain + their own valid references independently. +7. Surface-to-context resolution first checks the thread's current context in `O(1)`, then + scans the locked active registry in `O(C)` for `C` contexts without allocating an array. + Disposal still removes the context and invalidates all adapter resources. +8. Per-frame encoder and command-buffer labels remain readable, null-terminated UTF-8 + labels, but are backed by static literal data pinned only for the synchronous WebGPU + call. No label string, unmanaged wrapper, or global tracking-node allocation is needed. + +All caches are either mutation-invalidated, compositor-owned, or explicitly bounded. No +mutable drawing data is accepted as reusable without its existing invalidation contract. +No foreign implementation source was copied or adapted. + +#### Allocation progression + +Values are medians of three fresh Release processes with 180 warm-up and 300 measured +frames. Each row records exactly 352 commands in capacity 512, 113 draws, 528 vector +vertices, 226 text vertices, and 26 resident paths. + +| Step | Change | Custom Drawing allocation/frame | Change from Step 4 | +|---|---|---:|---:| +| 4 | Typed primitives and style reuse | 11,402 B | — | +| 5 | Retained geometry-command cache and typed solid lines | 8,599 B | **-24.6%** | +| 6 | Fixed stroke-direction selection | 8,313 B | **-27.1%** | +| 7 | Bounded drawing-state pool | 7,854 B | **-31.1%** | +| 8 | Value-keyed general pipeline selection | 7,174 B | **-37.1%** | +| 9 | Bounded unequal-radius rounded-path reuse | 6,497 B | **-43.0%** | +| 10 | Solid primitive pipeline front cache | 6,284 B | **-44.9%** | +| 11 | Allocation-free surface-context lookup | 6,220 B | **-45.4%** | +| 12 | Static UTF-8 WebGPU command labels | 5,997 B | **-47.4%** | + +The final four-page result is: + +| Page | Release baseline | Step 4 | Step 12 | Final change | +|---|---:|---:|---:|---:| +| Buttons | 6,961 B/frame | 2,160 B/frame | 2,158 B/frame | **-69.0%** | +| TextBlock | 18,137 B/frame | 2,161 B/frame | 2,160 B/frame | **-88.1%** | +| Custom Drawing | 63,010 B/frame | 11,402 B/frame | 5,997 B/frame | **-90.5%** | +| Composition | 11,626 B/frame | 2,158 B/frame | 2,158 B/frame | **-81.4%** | +| Four-page average | 24,934 B/frame | 4,470 B/frame | 3,118 B/frame | **-87.5%** | + +A fresh native Avalonia/Skia run on the same pinned ControlCatalog source measured a +7,015 B/frame Custom Drawing median. ProGPU is 14.5% lower on managed allocation in this +workload. Native Skia and ProGPU use different presentation cadences on this machine, so +their FPS values are deliberately not compared. + +The final exact ProGPU median retains 21.79 MiB managed and a 367.88 MiB macOS physical +footprint. The latter remains in the established native-driver noise band. Step 5 was +measured before later desktop contention and improved compile/render/compositor medians +from 0.121/0.350/0.682 ms to 0.103/0.281/0.546 ms. Later steps performed less bounded CPU +work but ran while unrelated desktop processes consumed substantial CPU/GPU time; their +FPS and sub-millisecond timings are recorded in the artifacts but are not treated as an +isolated performance comparison. The final contended median is 58.41 FPS with +0.158/0.292/0.462 ms compile/upload/render and 0.912 ms total compositor time. + +The shared compositor changes were also measured across a fresh 54/54 WinUI page sweep. +Compared with the preceding final sweep, average allocation fell from 11,705 to +8,036 B/frame (**-31.3%**), compile time from 0.292 to 0.240 ms (**-17.8%**), +render time from 0.138 to 0.120 ms (**-13.3%**), and total compositor time from +0.475 to 0.401 ms (**-15.5%**). Average FPS is effectively unchanged at 430.94 versus +431.08 (-0.03%). Retained managed memory changed by -0.15%; physical footprint changed +by +1.71% inside the established process/driver noise band. Frame-budget misses fell from +43 to 12, compile-budget misses remained zero, and all pages completed without a wgpu +validation error. + +The randomized allocation trace is qualitative rather than a byte-total benchmark. In the +final trace, none of the sampled measurement stacks contain ProGPU or `SilkMarshal`; +remaining samples are Avalonia render-data nodes, dispatcher operations, composition +transport, and task scheduling. Exact allocation comes from +`GC.GetTotalAllocatedBytes(precise: true)` across all 300 measured frames. + +Quality and behavior checks: + +- all three Buttons, TextBlock, and Composition screenshots are byte-identical to their + retained Step 5 reference images; +- Custom Drawing remains time-dependent, and visual inspection shows the same clipping, + transforms, solid/dashed strokes, ellipses, text, antialiasing, and animation; +- command, draw, vertex, path, path-atlas, glyph-atlas, and staging-storage counts are + unchanged in every measured run; +- focused tests cover solid versus dashed line caching and geometry-cache reuse followed + by mutation invalidation; +- no wgpu validation, uncaptured, fatal, or other errors occur in any final benchmark log. + +### Step 13 — remove the unconsumed offscreen effects hit-test index + +Status: **complete**. + +The WinUI window compositor already disables the optional GPU hit-test index because +`InputSystem` owns CPU visual-tree hit testing. A fresh allocation trace found a second, +sample-private compositor used only to render the animated gear into an RGBA texture for +blur, shadow, and image-repeat effects. That offscreen target has no pointer/input surface +and no caller reads its `LastHitTestIndex`, but it still used the default option and rebuilt +and uploaded a complete spatial index on every animated frame. + +The sample now creates that compositor with `EnableGpuHitTesting = false`. This preserves +the exact visual tree, animation, transforms, clipping, DPI, MSAA, vector/text compilation, +atlas work, texture dimensions, effect dispatches, and final screen composition. It removes +only the unused side product: quadtree construction plus copies/uploads of `P` primitives, +`S` path segments, and `N` nodes. The eliminated CPU work is `O(P * D + S + N)` and the +eliminated transient/storage footprint is `O(P + S + N)`, with the existing maximum tree +depth `D = 8`. Render complexity and output storage are unchanged. + +The discovery trace and the first matched after trace use fresh Release processes with 120 +warm-up and 300 measured frames. Three additional fresh after processes establish the +post-change median: + +| Page | Before | Step 13 result | Change | FPS before / after | Compositor before / after | +|---|---:|---:|---:|---:|---:| +| Compute FX | 23,723 B/frame | 1,257 B/frame | **-94.7%** | 349.52 / 412.42 | 0.140 / 0.130 ms | +| Image & Buttons | 15,235 B/frame | 1,421 B/frame | **-90.7%** | 358.31 / 404.80 | 0.133 / 0.121 ms | +| Font Glyph Browser control | 40,760 B/frame | 40,980 B/frame | +0.5%, noise | 364.52 / 330.78 | Different animated workload timing; allocation control only | + +The affected before/after runs retain exactly 37/22 draws, 320/232 vector vertices, and +539/604 text vertices respectively. All after repetitions have zero frame-budget and +compile-budget misses. The Font Glyph Browser does not use the offscreen effects compositor +and its effectively unchanged allocation confirms the scope. + +The Compute FX headless test now asserts that neither a CPU nor device hit-test index is +created. Its rendered PNG remains byte-identical to the retained reference: +`9005419572d61c016f4b1b88f19be095956c788982213348c9fe83fb7b9058fb`. +No Step 13 benchmark log contains a WebGPU validation, uncaptured, or fatal error. + +Artifacts: + +- `artifacts/memory-optimization/step13-current-audit` +- `artifacts/memory-optimization/step13-offscreen-hit-test-disabled` +- `artifacts/memory-optimization/step13-repeat-1` +- `artifacts/memory-optimization/step13-repeat-2` +- `artifacts/memory-optimization/step13-repeat-3` + +### Step 14 — static UTF-8 glyph-rasterizer labels + +Status: **complete**. + +The compositor labels corrected in Step 12 did not cover `GlyphAtlas`. Every raster batch +still allocated and registered unmanaged strings for its encoder and command-buffer labels; +the immediate oversized-glyph fallback did the same. All four fixed labels now use +null-terminated UTF-8 literals pinned only for the synchronous WebGPU creation/finish call. +Label text and native object lifetime are unchanged, while the diagnostic-only managed, +unmanaged, and global memory-registration work is removed. + +The glyph browser exact allocation remained neutral at 40,980 versus 41,112 B/frame +(+0.3%, noise), while the after trace contains no sampled `SilkMarshal` stack. This small +cleanup is therefore accepted as allocation-safe, not claimed as a measurable whole-frame +gain. Focused batch wrap/checkpoint/coalescing tests preserve queue submission and atlas +visibility behavior. + +Artifact: `artifacts/memory-optimization/step14-glyph-labels`. + +### Step 15 — geometric glyph-instance buffer growth + +Status: **complete**. + +`CompileTextCommand` knows each shaped layout's glyph count and reserves the final +`GlyphInstance` count before appending. The old helper assigned `List.Capacity` to the exact +required count. A scrolling/virtualized page whose visible glyph total increased gradually +therefore copied a nearly full array repeatedly. The helper now uses the list's geometric +`EnsureCapacity` policy. It still guarantees capacity before the same `O(G)` glyph +compilation, but amortizes growth to `O(G)` total copies across increasing scene sizes and +retains at most the normal geometric spare capacity. The buffer stays compositor-owned and +is cleared, not discarded, between compilations. + +A tightly paired three-process Release comparison used 120 warm-up and 300 measured +Font Glyph Browser frames: + +| Metric | Exact-capacity median | Geometric-growth median | Change | +|---|---:|---:|---:| +| Allocation/frame | 40,651 B | 37,280 B | **-8.3%** | +| Sampled `GlyphInstance[]` bytes | 1,275,327 B in the discovery trace | 230,402 B | **-81.9%** | +| FPS | 354.49 | 334.26 | Ranges overlap under desktop contention | +| Compile time | 1.148 ms | 1.421 ms | Non-repeatable contention | +| Render time | 0.221 ms | 0.232 ms | Within run noise | +| Frame/compile budget misses | 0 / 0 | 0 / 0 | Unchanged | + +The timing ranges overlap substantially: exact-capacity runs span 295–373 FPS and optimized +runs span 256–398 FPS, with compile medians moving with the same external contention. The +change affects only rare list growth, not per-glyph compilation or GPU upload, so no +repeatable throughput regression is established. All runs retain 111 draws, 720 vector +vertices, 1,008 text vertices, five populated glyph-browser state samples, the same visible +range (432–1037), and zero atlas clears, evictions, or resets. + +Artifacts: + +- `artifacts/memory-optimization/step15-text-capacity-1` +- `artifacts/memory-optimization/step15-text-capacity-2` +- `artifacts/memory-optimization/step15-text-capacity-3` +- `artifacts/memory-optimization/step15-paired-baseline-1` through `-3` +- `artifacts/memory-optimization/step15-paired-optimized-1` through `-3` + +### Step 16 — level-zero ASCII bidi fast path + +Status: **complete**. + +The Font Glyph Browser allocation trace showed repeated `Uax9Resolver` construction for +ordinary ASCII labels. UAX #9 rules P2–P3, W1–W7, N0–N2, I1, and L1 resolve every ASCII +code point to level zero when the paragraph base level is zero. `BidiParagraph.Resolve` +now recognizes exactly that narrow case with one linear scan, then creates the same +level-zero UTF-16 projection and single logical run directly. Explicit RTL paragraphs and +every paragraph containing a non-ASCII code unit continue through the complete resolver. + +The scan is `O(N)` time and `O(1)` workspace. Its fresh `sbyte[N]` level array and one-run +array are the required retained outputs, so nonempty result ownership is unchanged. Empty +LTR and RTL paragraphs use shared immutable instances containing only `Array.Empty` +storage. Run construction for the full resolver path now uses a two-pass exact-size array +instead of a temporary `List` followed by `ToArray`. + +Correctness is checked against the complete resolver for all 16,384 two-character ASCII +combinations under both explicit LTR and detected base direction: 32,768 paragraph-level, +UTF-16-level, and run comparisons. Focused cases also prove fresh nonempty array ownership, +shared empty results, and that explicit RTL ASCII still takes the full UAX path and retains +nonzero embedding levels. The five affected headless text pages render successfully. + +Three fresh Release processes on each side used the same Font Glyph Browser workload: +180 warm-up frames, 600 measured frames, retained traces, visible range 576–1901, 111 +draws, 720 vector vertices, 1,009 text vertices, 301 glyph-atlas generation changes and +evictions, and no atlas clear or path reset. + +| Metric | Fresh baseline median | Step 16 median | Change | +|---|---:|---:|---:| +| Allocation/frame | 34,998 B | 28,728 B | **-17.9%** | +| Sampled measurement allocation | 20,574,564 B | 17,193,569 B | **-16.4%** | +| Sampled `Uax9Resolver` top-stack bytes | 2,971,016 B | 0 B | **eliminated** | +| Gen0 collections | 2 | 1 | One fewer collection | +| Wall throughput | 358.94 FPS | 407.74 FPS | No regression observed | +| Compile time | 0.710 ms | 0.823 ms | Sub-millisecond run variance | +| Frame / compile budget misses | 0 / 0 | 0 / 0 | Unchanged | + +The bidi change does not enter compositor compilation, and the faster wall result does not +justify attributing a throughput gain under varying desktop contention. The small compile +median movement is reported rather than hidden; all six processes retained identical work +and stayed below one millisecond without a budget miss. No benchmark log contains a WebGPU +validation, uncaptured, or fatal error. + +Artifacts: + +- `artifacts/memory-optimization/step16-bidi-baseline-1` through `-3` +- `artifacts/memory-optimization/step16-bidi-optimized-1` through `-3` + +### Step 17 — allocation-free identity visual order + +Status: **complete**. + +UAX #9 rule L2 is an identity mapping when a broken line contains no odd embedding level. +The compatibility helper still returns a complete `int[N]` map, but WinUI's layout hot path +now requests a map only when reordering is necessary. A null internal map means logical +index equals visual index. Mixed and RTL lines create and retain the same exact map as +before, including its use during justification; LTR lines iterate their existing character +list directly. Work remains `O(N)` and output is unchanged, while identity-map storage +falls from `O(N)` to `O(1)`. + +The Step 16 optimized runs are the exact pre-change baseline. Three fresh Step 17 processes +used the same 180 warm-up and 600 measured frames: + +| Metric | Step 16 median | Step 17 median | Change | +|---|---:|---:|---:| +| Allocation/frame | 28,728 B | 28,324 B | **-1.4%** | +| Sampled measurement allocation | 17,193,569 B | 16,272,137 B | **-5.4%** | +| Sampled visual-order stacks | 307,280 B | 0 B | **eliminated** | +| Wall throughput | 407.74 FPS | 415.43 FPS | No regression observed | +| Compile time | 0.823 ms | 0.751 ms | No regression observed | +| Frame / compile budget misses | 0 / 0 | 0 / 0 | Unchanged | + +The focused bidi suite includes direct identity and mixed-order coverage. All three runs +retain the same visible range, draws, vector vertices, atlas generations/evictions, and +absence of atlas clears and path resets. One of six runs reports 1,008 rather than 1,009 +text vertices because the benchmark scroll endpoint crosses one glyph boundary; this is +the same existing run-to-run endpoint variation, not dropped content. No WebGPU error is +present. + +Artifacts: + +- `artifacts/memory-optimization/step17-identity-order-optimized-1` through `-3` + +### Step 18 — bounded per-line bidi-level workspace + +Status: **complete**. + +Rich-text layout projected retained paragraph levels into a new `sbyte[N]` array for every +broken line, used it synchronously for cluster shaping and visual ordering, then discarded +it. Lines of at most 256 positioned characters now use a bounded stack span; longer lines +retain the existing heap allocation. The helper verifies exact line/storage length, fills +every element, performs the same UAX #9 L1 trailing-whitespace reset, and cannot escape the +layout call. Time remains `O(N)`, temporary heap storage becomes `O(1)` for ordinary short +lines, and stack use is capped at 256 bytes per active layout call. + +A temporary process-level switch selected the previous heap path without changing the +binary. Six heap and six bounded-stack processes were run in alternating order, then in +reverse alternating order, each with 180 warm-up and 600 measured frames. The switch was +removed from the retained source after measurement. + +| Metric | Same-binary heap median | Same-binary stack median | Change | +|---|---:|---:|---:| +| Allocation/frame | 28,029 B | 27,953 B | **-0.3%** | +| Pairwise allocation delta | — | -64 B/frame median | Four of six pairs lower | +| Sampled measurement allocation | 16,846,563 B | 15,703,711 B | **-6.8%** | +| Wall throughput | 375.12 FPS | 396.04 FPS | No regression observed | +| Compile time | 0.952 ms | 1.009 ms | +0.057 ms, run noise | +| Total compositor time | 1.328 ms | 1.406 ms | +0.078 ms, run noise | +| Frame / compile budget misses | 2 / 0 | 0 / 0 | No stack-mode misses | + +The exact gain is deliberately reported as small. Aggregate wall throughput is favorable, +while the sub-tenth-millisecond compile/compositor medians move in the opposite direction +under external desktop load; neither timing movement is claimed as causal. All twelve runs +retain the same visible range, 111 draws, 720 vector vertices, 1,009 text vertices, 301 +glyph-atlas generation changes and evictions, and no atlas clear or path reset. Earlier +retained traces show the sampled `GetLineBidiLevels` heap stack falling from 307,256 B to +zero. No WebGPU validation, uncaptured, or fatal error occurs. + +The current 27,953 B/frame median is **20.1%** below the fresh 34,998 B/frame pre-Step-16 +baseline. + +Artifacts: + +- `artifacts/memory-optimization/step18-bidi-line-stack-optimized-1` through `-3` +- `artifacts/memory-optimization/step18-same-binary-heap-1` through `-3` +- `artifacts/memory-optimization/step18-same-binary-stack-1` through `-3` +- `artifacts/memory-optimization/step18-same-binary-reverse-heap-1` through `-3` +- `artifacts/memory-optimization/step18-same-binary-reverse-stack-1` through `-3` +- `artifacts/memory-optimization/step18-final-retained` + +### Step 19 — indexed rich-inline traversal + +Status: **complete**. + +The Step 17 trace showed boxed `RichElementCollection` enumerators in recursive inline +length and line-break scans. The retained implementation changes only those recursive +internal methods from interface-based `foreach` traversal to indexed traversal over the +existing `RichElementCollection`. Logical tree order, recursion, estimated-height +policy, invalidation, shaping, layout output, and rendering are unchanged. Work remains +`O(N)` for `N` retained inline nodes, but traversal workspace is `O(1)` with no boxed +enumerator per nested collection. + +An earlier broader experiment also changed estimated-height loops. It showed the allocation +signal but was fully reverted because unrelated compiler/security load prevented a paired +timing comparison. The accepted narrower change used a temporary process-level switch for +only recursive length/line-break scans. Six enumerable and six indexed processes ran from +the same binary in alternating and reverse-alternating order, each with 180 warm-up and 600 +measured frames. The switch and enumerable baseline code were then removed. + +| Metric | Same-binary enumerable median | Same-binary indexed median | Change | +|---|---:|---:|---:| +| Allocation/frame | 28,237 B | 27,890 B | **-1.2%** | +| Pairwise allocation delta | — | -447 B/frame median | All six pairs lower | +| Sampled measurement allocation | 17,512,759 B | 17,356,544 B | **-0.9%** | +| Wall throughput | 329.38 FPS | 345.79 FPS | **+5.0%** | +| Compile time | 1.503 ms | 1.215 ms | **-19.2%** | +| Layout time | 0.1060 ms | 0.1068 ms | Stable | +| Total compositor time | 2.016 ms | 1.705 ms | **-15.4%** | +| Frame / compile budget misses | 6 / 0 | 3 / 0 | Improved / unchanged | + +Every run retains the same visible range, 111 draws, 720 vector vertices, 1,009 text +vertices, 301 glyph-atlas generation changes and evictions, and no atlas clear or path +reset. No WebGPU validation, uncaptured, or fatal error occurs. The current 27,890 B/frame +median is **20.3%** below the fresh 34,998 B/frame pre-Step-16 baseline. + +Artifacts: + +- Reverted exploratory run: + `artifacts/memory-optimization/step19-indexed-rich-inline-1` through `-3` +- `artifacts/memory-optimization/step19-same-binary-enumerable-1` through `-3` +- `artifacts/memory-optimization/step19-same-binary-indexed-1` through `-3` +- `artifacts/memory-optimization/step19-same-binary-reverse-enumerable-1` through `-3` +- `artifacts/memory-optimization/step19-same-binary-reverse-indexed-1` through `-3` +- `artifacts/memory-optimization/step19-final-retained` + +### Step 20 — indexed virtualized height estimation + +Status: **complete**. + +The retained trace still attributed allocation to boxed `RichElementCollection` +enumerators inside `EstimateBlockHeight`. This method supplies provisional heights for +offscreen rich-document paragraphs, lists, and tables so virtualization can assign offsets +without laying out every block. The retained implementation now traverses the existing +inline, list-item, row, and cell collections by index. It preserves the previous +one-level maximum-font scan, line-break and embedded-content policies, formulas, block +order, cache invalidation, and visible layout. Work remains `O(N)` for `N` visited retained +nodes with `O(1)` traversal workspace. + +A temporary process-level switch selected the former enumerable implementation from the +same Release binary. Three enumerable-then-indexed pairs and three reverse-order pairs each +ran 180 warm-up and 600 measured frames. The switch and former implementation were removed +after the result was accepted. + +| Metric | Same-binary enumerable median | Same-binary indexed median | Change | +|---|---:|---:|---:| +| Allocation/frame | 27,721 B | 27,441 B | **-1.0%** | +| Pairwise allocation delta | — | -294 B/frame median | Five pairs lower; one +12 B tie | +| Sampled measurement allocation | 16,779,435 B | 16,032,211 B | **-4.5%** | +| Wall throughput | 403.21 FPS | 401.58 FPS | -0.4%, noise band | +| Compile time | 0.914 ms | 0.937 ms | +0.023 ms, noise band | +| Layout time | 0.0753 ms | 0.0664 ms | -0.0089 ms | +| Total compositor time | 1.242 ms | 1.277 ms | +0.035 ms, noise band | +| Frame / compile budget misses | 0 / 0 | 1 / 0 | No compile misses | + +Every run retains the same visible range, 111 draws, 720 vector vertices, 1,009 text +vertices, 301 glyph-atlas generation changes and evictions, and no atlas clear or path +reset. The retained final trace reports 27,543 B/frame. Its lower wall throughput is +explained by 3.773 ms average surface acquisition under external load; compile, layout, +rendering work, and all workload counters remain in family. No WebGPU validation, +uncaptured, or fatal error occurs. The current same-binary median is **21.6%** below the +fresh 34,998 B/frame pre-Step-16 baseline. + +Artifacts: + +- `artifacts/memory-optimization/step20-same-binary-enumerable-1` through `-3` +- `artifacts/memory-optimization/step20-same-binary-indexed-1` through `-3` +- `artifacts/memory-optimization/step20-same-binary-reverse-enumerable-1` through `-3` +- `artifacts/memory-optimization/step20-same-binary-reverse-indexed-1` through `-3` +- `artifacts/memory-optimization/step20-final-retained` + +### Step 21 — demand-sized core scene buffers for Avalonia + +Status: **complete**. + +Every compositor previously reserved the maximum brush and gradient-stop storage at +construction: 8,192 `GpuBrush` records at 256 bytes and 65,536 `GpuGradientStop` records at +32 bytes, or exactly 4 MiB before a scene contained a brush. The default core reservation +is now 64 brushes and 512 stops, 16 KiB each. Both limits remain unchanged. When a retained +scene exceeds either initial capacity, the owning buffer grows geometrically to at least +the observed demand and both onscreen/offscreen vector bind groups are recreated against +the new concrete WebGPU buffer ranges. A stable second render retains those buffers and +bind groups; it does not resize again. + +The Avalonia adapter also selects 1,024 initial vertices and 1,536 initial indices instead +of the general-purpose 16,384/24,576 profile. Avalonia performs input hit testing through +its own visual tree and no Avalonia backend code consumes ProGPU's optional compiled GPU +hit-test index, so that duplicate index is disabled for Avalonia compositors. Compiled +scene reuse, visible rendering, CPU geometry hit testing, paths, glyphs, DPI, and atlas +policies are unchanged. + +Exact per-compositor persistent scene-buffer reservations are: + +| Storage | Former reservation | Current Avalonia reservation | Change | +|---|---:|---:|---:| +| Brush + gradient storage | 4,194,304 B | 32,768 B | **-4,161,536 B (-99.2%)** | +| Vector/text/texture mesh buffers | 3,604,480 B | 225,280 B | **-3,379,200 B (-93.7%)** | +| Uniform buffer | 208 B | 208 B | unchanged | +| **Total** | **7,798,992 B (7.44 MiB)** | **258,256 B (252.2 KiB)** | **-7,540,736 B (-96.7%, -7.19 MiB)** | + +These are exact requested/allocated WebGPU buffer sizes, not an inference from process +RSS. The existing measured Custom Drawing workload records 352 commands, 113 draws, 528 +vector vertices, 226 text vertices, and 26 paths, so its mesh demand remains within the +compact profile. The ControlCatalog benchmark now emits total scene-buffer bytes, separate +brush and gradient buffer bytes, active brush/stop counts, and the GPU-hit-testing flag so +future live captures show any workload-specific growth directly. + +The growth regression begins with four brushes, four stops, 16 vertices, and 24 indices, +then renders 300 distinct two-stop gradients. It verifies correct pixels through the +normal headless render path, sufficient grown vector/style capacities, exact active counts, +disabled hit-index compilation, and unchanged buffer sizes on the second retained render. +Core option tests cover exact initial reservations. Avalonia unit coverage pins the compact +profile and confirms that GPU hit testing remains disabled. + +The initial restricted pass could not connect GUI processes to WindowServer or open the +diagnostics Unix socket used by `dotnet-trace`, so this step first accepted only exact GPU +reservation and headless retained-render evidence. Step 22 records the subsequent +unrestricted same-binary ControlCatalog comparison using these counters. + +### Step 22 — lazy core effect pipelines + +Status: **complete**. + +`Compositor` owns a `ComputeAccelerator` even when a backend never requests a blur, shadow, +morphology, color filter, blend, displacement, convolution, or lighting pass. Construction +previously compiled and retained twelve WGSL shader modules and twelve compute pipelines, +acquired four blur bind-group layouts, and allocated three persistent parameter buffers +totalling 96 bytes. The Avalonia renderer does not route any operation through these core +effect methods: Avalonia acrylic uses its typed backdrop-material extension, box shadows +remain typed draw commands, and `IDrawingContextImplWithEffects` currently maps only blend +state. The eager resources were therefore dead weight on every Avalonia compositor. + +Each effect family is now created on its first actual call: + +- Gaussian blur creates only its horizontal/vertical pair, two layouts, and two 16-byte + parameter buffers. +- sharp shadow creates one pipeline; blurred shadow creates only its two-pass pair, two + layouts, and one 64-byte retained parameter buffer. +- morphology, image blend, color table, arithmetic composite, displacement, convolution, + lighting, nonlinear filtering, and magnification each create only their requested + pipeline. +- the existing equal-radius combined blur/shadow specialization remains independently + lazy. + +The cache retains every created family, so the first-use compile is paid once and stable +frames select the existing pipeline. A GPU regression executes all sixteen possible +effect pipelines, checks the pipeline count after every family, verifies blur output, and +proves a second call does not grow the cache or parameter storage. The existing Compute FX +and Framework Effects headless pages render successfully. + +A temporary process-level switch recreated the former eager effect set and full scene +buffers from the same Release binary. It was removed after three legacy and three optimized +fresh-process Custom Drawing runs, each with 240 warm-up and 600 measured frames: + +| Metric | Legacy median | Optimized median | Change | +|---|---:|---:|---:| +| Persistent scene buffers | 7,798,992 B | 258,256 B | **-7,540,736 B (-96.7%)** | +| Unused effect shaders / pipelines | 12 / 12 | 0 / 0 | **eliminated** | +| Unused retained effect layouts / parameters | 4 / 96 B | 0 / 0 B | **eliminated** | +| Allocation/frame | 6,022 B | 6,022 B | unchanged | +| Retained managed memory | 21.81 MiB | 21.63 MiB | **-0.18 MiB (-0.8%)** | +| Working set | 236.39 MiB | 235.09 MiB | **-1.30 MiB (-0.5%)** | +| Wall throughput | 56.39 FPS | 56.40 FPS | unchanged | +| Compile time | 0.233 ms | 0.226 ms | no regression | +| Total compositor time | 1.284 ms | 1.260 ms | no regression | + +The six physical-footprint samples span 359.91–374.94 MiB optimized and +366.78–368.72 MiB legacy, an incoherent Metal/WindowServer residency band, so no physical +footprint gain is claimed from three pairs. Every run retains exactly 352 commands, +113 draws, 528 vector vertices, 226 text vertices, 26 paths, 11 active brushes, no +gradients, no effect pipeline, and no GPU hit-test index. No wgpu validation, uncaptured, +or fatal error occurs. + +Artifacts: + +- `artifacts/memory-optimization/step22-lazy-effects/legacy-1.json` through `legacy-3.json` +- `artifacts/memory-optimization/step22-lazy-effects/optimized-1.json` through `optimized-3.json` +- `artifacts/memory-optimization/step22-lazy-effects/custom-drawing.json` +- `artifacts/memory-optimization/step22-lazy-effects/custom-drawing.png` + +### Step 23 — lazy core chart pipelines + +Status: **complete**. + +The core compositor also compiled the line-chart and scatter-chart shaders and both +onscreen/offscreen pipeline variants during construction. Avalonia ControlCatalog does not +record either chart command, yet every Avalonia compositor retained those two shader +modules, four render pipelines, and two pipeline-derived bind-group layouts. + +Both chart families are now independently demand-created after a valid chart draw reaches +the render pass. Line charts create their shader, two pipelines, and layout on first line +use; scatter charts do the same only on first scatter use. The owning pipeline cache keeps +them for subsequent frames. A dedicated GPU regression starts with the ordinary +three-shader/eight-pipeline core, renders real line and scatter commands, verifies visible +output and the resulting five-shader/twelve-pipeline cache, then proves a second frame +retains exactly the same counts. + +A temporary same-binary switch restored eager chart creation and was removed after three +eager and three lazy Custom Drawing processes, each with 240 warm-up and 600 measured +frames: + +| Metric | Eager median | Lazy median | Change | +|---|---:|---:|---:| +| Scene shader modules | 5 | 3 | **2 unused modules eliminated** | +| Scene render pipelines | 14 | 10 | **4 unused pipelines eliminated** | +| Chart bind-group layouts | 2 | 0 | **2 unused layouts eliminated** | +| Allocation/frame | 6,028 B | 6,022 B | effectively unchanged | +| Retained managed memory | 21.632 MiB | 21.622 MiB | -0.010 MiB | +| Working set | 235.28 MiB | 234.14 MiB | -1.14 MiB | +| Wall throughput | 56.45 FPS | 56.49 FPS | unchanged | +| Compile time | 0.2248 ms | 0.2256 ms | +0.0008 ms, noise | +| Total compositor time | 1.282 ms | 1.256 ms | no regression | + +Physical-footprint medians are 372.70 MiB eager and 359.42 MiB lazy, but paired deltas +range from +0.11 MiB to -13.28 MiB and overlap the established Metal/WindowServer noise +band, so no physical-memory gain is attributed from three pairs. All six processes retain +the identical 352 commands, 113 draws, 528 vector vertices, 226 text vertices, 26 paths, +11 brushes, zero gradients, zero effect pipelines, and disabled GPU hit testing. No wgpu +validation, uncaptured, or fatal error occurs. + +Artifacts: + +- `artifacts/memory-optimization/step23-lazy-chart-pipelines/eager-1.json` through `eager-3.json` +- `artifacts/memory-optimization/step23-lazy-chart-pipelines/lazy-1.json` through `lazy-3.json` + +### Step 24 — compact core color-glyph residency and Avalonia glyph staging + +Status: **complete**. + +Every core glyph atlas previously created a 256×256 RGBA bitmap-color texture at +construction, even when the process rendered no `sbix` or CBDT bitmap glyph. The initial +texture is now 64×64 while the existing 512×512 maximum is unchanged. A bitmap larger than +the resident texture grows it geometrically to the required size, copies prior texels, +refreshes normalized coordinates, advances the texture revision, and recreates the +compositor's concrete atlas bind groups through the existing revision contract. A new +96×96 `sbix` regression starts from 16×16, grows once to 128×128, verifies nonzero decoded +pixels, and proves a repeated lookup preserves coordinates and does not grow again. + +The core compositor options now also expose the bounded glyph uniform-ring reservation. +Avalonia selects 16 KiB instead of 64 KiB. This still holds 64 independently aligned glyph +uniform records. The representative 13-glyph first-use regression retains one uniform +write, one raster submission, one bind-group creation, and one compute pass with the +compact uniform ring. + +A proposed 64 KiB Avalonia coverage ring was rejected: the same first-use workload split +its pending outline uploads into four writes instead of the accepted one-or-two range. +Avalonia therefore retains the 256 KiB coverage ring. Raster quality, physical-DPI sizing, +four-way subpixel policy, atlas maximums, eviction, and steady rendering are unchanged. + +Exact persistent reservation changes per Avalonia compositor are: + +| Storage | Former | Current | Change | +|---|---:|---:|---:| +| Bitmap-color glyph texture | 262,144 B | 16,384 B | **-245,760 B** | +| Glyph uniform GPU ring | 65,536 B | 16,384 B | **-49,152 B** | +| Managed uniform upload mirror | 65,536 B | 16,384 B | **-49,152 B** | +| Glyph coverage GPU ring | 262,144 B | 262,144 B | unchanged | +| **Total listed residency** | **655,360 B** | **311,296 B** | **-344,064 B (-52.5%, -336 KiB)** | + +The color-texture reduction is the core default; the smaller uniform ring is the Avalonia +backend profile. Metrics now report both uniform reservations, coverage staging, and glyph +raster submissions/passes. + +A temporary process-level switch restored the former color texture and uniform ring in the +same Release binary. It was removed after three legacy and three compact Custom Drawing +processes, each with 180 warm-up and 300 measured frames: + +| Metric | Legacy median | Compact median | Change | +|---|---:|---:|---:| +| Allocation/frame | 5,996.85 B | 5,996.85 B | unchanged | +| Retained managed memory | 21.62 MiB | 21.52 MiB | -0.09 MiB | +| Working set | 233.59 MiB | 233.86 MiB | +0.27 MiB, noise | +| Physical footprint | 358.77 MiB | 358.64 MiB | -0.13 MiB, noise | +| Wall throughput | 56.60 FPS | 56.73 FPS | no regression | +| Compile time | 0.2249 ms | 0.2135 ms | no regression | +| Total compositor time | 1.2412 ms | 1.2391 ms | unchanged | + +The Custom Drawing workload does not rasterize a new glyph after attachment, so all six +runs correctly report zero measurement-time glyph raster submissions. They retain exactly +352 commands, 113 draws, 528 vector vertices, and 226 text vertices. Physical-footprint +ranges overlap (legacy 358.72–358.89 MiB, compact 358.44–359.02 MiB), so the exact GPU/CPU +reservation delta—not process footprint noise—is the memory claim. No wgpu validation, +uncaptured, fatal, or other error occurs. + +Artifacts: + +- `artifacts/memory-optimization/step24-compact-glyph-staging/legacy-1.json` through + `legacy-3.json` +- `artifacts/memory-optimization/step24-compact-glyph-staging/compact-1.json` through + `compact-3.json` + +Step 25 addresses the measured path-atlas shape mismatch separately, including its +coordinate-stability, recovery-packing, one-shot retry, image-quality, and performance +contracts. + +### Step 25 — independent-axis core path-atlas growth + +Status: **complete**. + +The Custom Drawing workload's tallest raster is 49×1,608 pixels. The former square growth +policy therefore expanded the R8 atlas from 512×512 through 1,024×1,024 to 2,048×2,048, +reserving 4 MiB even though no observed path required comparable width. `PathAtlas` now +tracks width and height independently and grows only the required axis geometrically. If +the current shelf is vertically exhausted while an entry already fits both dimensions, +height grows first; after height reaches its configured maximum, width may grow to expose +the right-hand strip. The per-axis maximum remains 2,048, so this does not expand the +renderer's previous memory bound. + +Growth copies the old rectangular texel region without moving it, so existing integer +atlas coordinates and cached coverage remain stable. Both the texture revision and atlas +generation advance: the revision refreshes concrete GPU bindings, while the generation +invalidates vertices whose normalized UVs were compiled against the previous dimensions. +The compositor then uses its bounded reset/recompile path in the same frame, preventing +partially stale path output when a later entry grows the other axis. Normalized +coordinates use the width reciprocal for X and the height reciprocal for Y. The +deterministic MaxRects recovery path likewise uses independent extents, area, coordinate +candidates, free rectangles, compatibility bounds, and placement signatures. A capacity +miss or UV-generation change still aborts compilation, resets once, and retries the same +frame. A live set that cannot fit after that retry still fails explicitly rather than +looping or dropping paths. + +The new regressions cover: + +- 512×512 to 512×1,024 height-only growth with unchanged texel coordinates and coverage + for already resident paths, plus an advanced generation that forces same-frame UV + recompilation; +- separate X/Y UV normalization after rectangular growth; +- rectangular reset/recovery with non-overlapping bounds and visible raster output; and +- a 900-pixel-tall rounded path rendered visibly on the first 64×1,024 frame, followed by + a stable retained second frame. + +The ControlCatalog diagnostics now emit the actual atlas width and height plus peak raster +width and height. A temporary same-binary switch restored square growth and was removed +after three square and three rectangular fresh-process runs, each with 180 warm-up and 300 +measured frames: + +| Metric | Square median | Rectangular median | Change | +|---|---:|---:|---:| +| Path-atlas extent | 2,048×2,048 | 512×2,048 | width reduced 4× | +| Persistent R8 texture | 4,194,304 B | 1,048,576 B | **-3,145,728 B (-75%, -3 MiB)** | +| Allocation/frame | 5,996.69 B | 5,997.81 B | +1.12 B, noise | +| Retained managed memory | 21.53 MiB | 21.53 MiB | unchanged | +| Working set | 234.02 MiB | 233.75 MiB | -0.27 MiB | +| Physical footprint | 373.69 MiB | 370.17 MiB | **-3.52 MiB median** | +| Wall throughput | 56.21 FPS | 56.28 FPS | unchanged | +| Compile time | 0.2341 ms | 0.2384 ms | +0.0042 ms, noise | +| Upload time | 0.4022 ms | 0.3919 ms | no regression | +| Render time | 0.6786 ms | 0.6607 ms | no regression | +| Total compositor time | 1.3115 ms | 1.2911 ms | no regression | + +All six runs retain the identical 26 paths, 352 commands, 113 draws, 528 vector vertices, +and 226 text vertices. Each of the three paired rectangular runs used 2.85–5.13 MiB less +physical footprint than its square counterpart, consistent with the exact 3 MiB texture +reduction. No WebGPU validation, uncaptured, fatal, or other error occurs. + +The final rebuilt Avalonia 12 binary was also exercised independently for 60 warm-up and +120 measured frames. It reports a 512×2,048 atlas, 1,048,576 texture bytes, the same +49×1,608 peak raster and workload counts, 57.55 FPS, 0.2219 ms compilation, and 1.1487 ms +total compositor time. Its screenshot preserves the expected Custom Drawing output. + +Artifacts: + +- `artifacts/memory-optimization/step25-rectangular-path-atlas/square-1.json` through + `square-3.json` +- `artifacts/memory-optimization/step25-rectangular-path-atlas/rectangular-1.json` through + `rectangular-3.json` +- `artifacts/memory-optimization/step25-rectangular-path-atlas/final.json` +- `artifacts/memory-optimization/step25-rectangular-path-atlas/final.png` + +### Step 26 — demand-created primary/offscreen core pipelines + +Status: **complete**. + +Every compositor previously compiled all eight base pipelines at construction: vector, +solid rectangle, text, and texture variants for both the multisampled primary target and +the single-sample offscreen target. The three corresponding shader modules were also +created before any scene established which command or target family it needed. + +Base shaders and pipelines are now selected through the existing owning +`RenderPipelineCache` on first use. Primary and offscreen identities remain distinct +because their sample counts and pipeline layouts differ, but rendering through one target +does not retain the unused family for the other. The cache keeps every selected pipeline, +so subsequent frames perform the same value-key lookup and reuse the same native object. +An empty compositor now owns zero scene shader modules and zero render pipelines instead +of three and eight. A solid-rectangle primary render creates one vector shader and one +primary pipeline; a later offscreen render creates only its second target pipeline and +reuses the shader. + +Clients that require explicit prewarming can set +`CompositorOptions.PrecompileBasePipelines = true`. That policy recreates the former three +base shaders and eight pipelines during construction. It is opt-in rather than an +incidental cost on every compositor. + +This particularly benefits Avalonia: its framebuffer and render-to-texture paths call +`RenderOffscreen`, while the previous constructor still retained the unrelated primary +family. Custom Drawing now retains exactly the five pipelines its offscreen workload +selects instead of ten total eager/dynamic pipelines. Its three used shaders are unchanged. + +Regressions prove: + +- default construction owns no base shader or pipeline; +- the first primary solid-rectangle frame is visible and creates exactly one pipeline; +- a stable second primary frame retains the same object count; +- the first offscreen frame is visible and adds only the corresponding target pipeline; +- a stable second offscreen frame retains the same object count; and +- explicit precompilation still creates the former three-shader/eight-pipeline base set. + +A temporary same-binary environment switch selected the eager policy for measurement and +was removed afterward. Six eager and six lazy fresh processes were run in alternating +forward and reverse order, each with 180 warm-up and 300 measured frames: + +| Metric | Eager median | Demand-created median | Change | +|---|---:|---:|---:| +| Scene shader modules | 3 | 3 | all three used by this workload | +| Scene render pipelines | 10 | 5 | **5 unused pipelines eliminated (-50%)** | +| First rendered frame | 1,071.13 ms | 1,059.14 ms | **-11.99 ms**; paired median -13.18 ms | +| Allocation/frame | 5,996.85 B | 6,010.00 B | +13.15 B, noise | +| Retained managed memory | 21.535 MiB | 21.535 MiB | unchanged | +| Working set | 233.438 MiB | 233.461 MiB | +0.023 MiB, noise | +| Physical footprint | 369.650 MiB | 370.103 MiB | overlapping native/WindowServer noise | +| Wall throughput | 57.99 FPS | 57.89 FPS | -0.18%, noise | +| Compile time | 0.1996 ms | 0.1992 ms | unchanged | +| Upload time | 0.3189 ms | 0.3234 ms | +0.0045 ms, noise | +| Render time | 0.5285 ms | 0.5404 ms | +0.0119 ms, noise | +| Total compositor time | 1.0561 ms | 1.0707 ms | +0.0147 ms, noise | + +All twelve processes retain the identical 26 paths, 352 commands, 113 draws, 528 vector +vertices, and 226 text vertices. Physical-footprint pair deltas range from -14.97 MiB to ++14.06 MiB, so no byte-level process-footprint saving is claimed. The exact memory result +is the five fewer live native pipeline objects. No WebGPU validation, uncaptured, fatal, +or other error occurs. + +The final rebuilt Avalonia 12 binary reports five scene pipelines, three shaders, the +unchanged 512×2,048 path atlas and workload counts, 58.15 FPS, 0.1932 ms compilation, and +1.0276 ms total compositor time. Its screenshot preserves the expected output. + +Artifacts: + +- `artifacts/memory-optimization/step26-lazy-target-pipelines/eager-1.json` through + `eager-3.json` +- `artifacts/memory-optimization/step26-lazy-target-pipelines/lazy-1.json` through + `lazy-3.json` +- `artifacts/memory-optimization/step26-lazy-target-pipelines/reverse-eager-1.json` + through `reverse-eager-3.json` +- `artifacts/memory-optimization/step26-lazy-target-pipelines/reverse-lazy-1.json` + through `reverse-lazy-3.json` +- `artifacts/memory-optimization/step26-lazy-target-pipelines/final.json` +- `artifacts/memory-optimization/step26-lazy-target-pipelines/final.png` + +### Step 27 — shared primary/offscreen core binding infrastructure + +Status: **complete**. + +The primary and offscreen base pipelines previously cloned the same resource-binding ABI +inside every compositor. Both target families use the same WebGPU device, uniform and +storage buffers, samplers, atlas textures, texture views, and mask textures. Their material +pipeline difference is sample count: the primary family uses the configured multisample +count and the offscreen family uses one sample. Sample count is render-pipeline state and +does not require a second bind-group or pipeline-layout identity. + +The compositor now creates one explicit set of vector, text, texture, atlas, path-atlas, +mask, and retained-glyph layouts and aliases it into both target families. The immutable +uniform, atlas, path-atlas, and dummy-mask bind groups are likewise shared. Brush or +gradient-buffer growth replaces one vector scene-state bind group and publishes that same +pointer to both targets. Glyph- or path-atlas texture revision replaces one corresponding +bind group. Dynamic geometry-mask bindings use one texture-keyed cache rather than +separate primary/offscreen caches. + +This removes exactly 17 native WebGPU objects from every compositor: + +| Native object family | Former | Shared | Change | +|---|---:|---:|---:| +| Base bind-group layouts | 15 | 8 | **-7** | +| Base pipeline layouts | 8 | 4 | **-4** | +| Immutable base bind groups | 12 | 6 | **-6** | +| **Total** | **35** | **18** | **-17 (-48.6%)** | + +The dynamic mask cache additionally retains at most one bind group per live mask texture +instead of one per target when the same mask crosses both target paths. The benchmark page +does not create geometry masks during measurement, so no dynamic-mask saving is included +in the exact 17-object result. Native implementations do not expose portable byte sizes +for these opaque handles; no byte-level GPU or driver-memory claim is made. + +Regressions prove: + +- all seven bind-group-layout, four pipeline-layout, and six immutable-bind-group target + pairs have identical native identity; +- brush-storage growth replaces the shared vector bind group once and preserves identity; +- glyph- and path-atlas revision refreshes replace one shared bind group each; +- primary and offscreen frames remain visibly rendered; +- primary and offscreen requests for one dynamic mask return one cached bind group, and + texture disposal removes it; and +- compositor disposal does not release any aliased object twice. + +A temporary same-binary switch restored the cloned target infrastructure and was removed +after six cloned and six shared fresh processes in alternating forward and reverse order. +Each process used 180 warm-up and 300 measured Custom Drawing frames: + +| Metric | Cloned median | Shared median | Change | +|---|---:|---:|---:| +| Base bind-group layouts | 15 | 8 | **-7** | +| Base pipeline layouts | 8 | 4 | **-4** | +| Immutable base bind groups | 12 | 6 | **-6** | +| Allocation/frame | 5,996.77 B | 5,996.85 B | +0.08 B, unchanged | +| Retained managed memory | 21.52 MiB | 21.52 MiB | unchanged | +| Working set | 233.43 MiB | 232.87 MiB | -0.56 MiB | +| Physical footprint | 362.47 MiB | 354.88 MiB | overlapping native/WindowServer noise | +| Wall throughput | 56.52 FPS | 56.39 FPS | -0.23%, noise | +| Compile time | 0.2376 ms | 0.2373 ms | unchanged | +| Upload time | 0.3957 ms | 0.3924 ms | unchanged | +| Render time | 0.6733 ms | 0.6673 ms | unchanged | +| Total compositor time | 1.3112 ms | 1.2918 ms | unchanged | + +The physical-footprint ranges overlap: cloned 354.06–370.33 MiB and shared +352.00–370.52 MiB. Pair deltas alternate between approximately -15 and +15 MiB, confirming +that the process measurement cannot resolve these small opaque objects. First-rendered +frame medians moved from 1,062.24 to 1,050.81 ms, but startup variation is also larger than +the work removed, so this is not claimed as a latency gain. + +All twelve processes retain the identical 26 paths, 352 commands, 113 draws, 528 vector +vertices, 226 text vertices, three scene shaders, five scene pipelines, and two persistent +texture bind groups. No WebGPU validation, uncaptured, fatal, or other error occurs. + +The final rebuilt Avalonia 12 binary reports 8/4/6 binding objects, the unchanged +512×2,048 path atlas and workload counts, 57.62 FPS, 0.2050 ms compilation, and 1.0787 ms +total compositor time. Its inspected screenshot preserves the expected Custom Drawing +output. + +Artifacts: + +- `artifacts/memory-optimization/step27-shared-target-bindings/legacy-1.json` through + `legacy-3.json` +- `artifacts/memory-optimization/step27-shared-target-bindings/shared-1.json` through + `shared-3.json` +- `artifacts/memory-optimization/step27-shared-target-bindings/reverse-legacy-1.json` + through `reverse-legacy-3.json` +- `artifacts/memory-optimization/step27-shared-target-bindings/reverse-shared-1.json` + through `reverse-shared-3.json` +- `artifacts/memory-optimization/step27-shared-target-bindings/final.json` +- `artifacts/memory-optimization/step27-shared-target-bindings/final.png` + +### Step 28 — compact demand-grown monochrome glyph atlas + +Status: **complete**. + +The core monochrome glyph atlas previously reserved a 512×512 R8 texture at compositor +construction. Its shelf packer requires a two-texel perimeter, so a 260×260 texture +provides a complete 256×256 usable starting area. A direct 256×256 experiment was rejected: +the representative Avalonia navigation and Custom Drawing text exhausted it and triggered +an immediate growth to 512×512. The 260×260 configuration retains the full workload +without growth. + +`GlyphAtlas.DefaultInitialAtlasSize` is now 260 and +`CompositorOptions.InitialGlyphAtlasSize` exposes the reservation independently from the +unchanged maximum. Direct `GlyphAtlas` callers can likewise specify the initial and maximum +sizes separately. If demand exceeds a non-power-of-two start, geometric growth rounds to +the next power of two: 260 grows to 512 rather than 520. Growth still flushes an active +batch transactionally, copies every resident texel without moving integer coordinates, +refreshes normalized UVs, advances only the texture revision, and lets the compositor +replace its one shared atlas bind group. Generation, eviction, DPI, subpixel phases, +raster quality, and the 2,048×2,048 maximum are unchanged. + +The persistent R8 reservation per compositor changes exactly: + +| Storage | Former | Current | Change | +|---|---:|---:|---:| +| Monochrome glyph coverage texture | 262,144 B | 67,600 B | **-194,544 B (-74.2%, -190 KiB)** | +| Bitmap-color glyph texture | 16,384 B | 16,384 B | unchanged | +| Combined resident glyph textures | 278,528 B | 83,984 B | **-69.8%** | + +Regressions prove that a configured 260×260 atlas starts at the requested extent, grows +to 512×512 rather than 520×520 under additional glyph phases, preserves visible resident +coverage and integer texel coordinates, advances the texture revision without advancing +generation, and keeps the original glyph cache entry reusable. Compositor-option coverage +also verifies independent initial/max sizes, and the Avalonia backend profile inherits the +new core default. + +A temporary same-binary initial-size override restored 512×512 and was removed after six +legacy and six compact fresh processes in alternating forward and reverse order. Each +process used 180 warm-up and 300 measured Custom Drawing frames: + +| Metric | 512×512 median | 260×260 median | Change | +|---|---:|---:|---:| +| Monochrome glyph texture | 262,144 B | 67,600 B | **-194,544 B (-74.2%)** | +| Allocation/frame | 5,997.33 B | 5,996.85 B | -0.48 B, unchanged | +| Retained managed memory | 21.52 MiB | 21.52 MiB | unchanged | +| Working set | 232.71 MiB | 232.73 MiB | unchanged | +| Physical footprint | 355.42 MiB | 361.34 MiB | overlapping native/WindowServer noise | +| Wall throughput | 56.58 FPS | 56.40 FPS | -0.32%, noise | +| Compile time | 0.2299 ms | 0.2366 ms | +0.0067 ms, noise | +| Upload time | 0.3851 ms | 0.3986 ms | +0.0136 ms, noise | +| Render time | 0.6560 ms | 0.6730 ms | +0.0170 ms, noise | +| Total compositor time | 1.2695 ms | 1.3107 ms | +0.0412 ms, noise | + +The physical-footprint ranges overlap: 512×512 353.92–370.13 MiB and 260×260 +352.53–370.41 MiB. Pair deltas alternate between approximately -17.6 and +15.9 MiB, so the +exact texture descriptor is the memory claim rather than process noise. First-rendered +frame medians moved from 1,063.49 to 1,051.50 ms, but startup variation is much larger and +no latency gain is claimed. + +All twelve processes keep the compact atlas at 260×260—none invokes its growth path—and +retain the identical 26 paths, 352 commands, 113 draws, 528 vector vertices, 226 text +vertices, three scene shaders, and five scene pipelines. No WebGPU validation, uncaptured, +fatal, or other error occurs. + +The final rebuilt Avalonia 12 binary reports a 67,600-byte monochrome glyph texture, the +unchanged 512×2,048 path atlas and workload counts, 57.72 FPS, 0.2084 ms compilation, and +1.1087 ms total compositor time. Its inspected screenshot preserves the expected glyph and +Custom Drawing output. + +Artifacts: + +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/legacy-1.json` + through `legacy-3.json` +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/compact-1.json` + through `compact-3.json` +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/reverse-legacy-1.json` + through `reverse-legacy-3.json` +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/reverse-compact-1.json` + through `reverse-compact-3.json` +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/final.json` +- `artifacts/memory-optimization/step28-compact-initial-glyph-atlas/final.png` + +### Step 29 — measured compact glyph-outline buffer capacity + +Status: **complete**. + +The glyph atlas formerly reserved 64 GPU glyph records and 1,024 GPU outline segments, +plus managed scratch and pending-upload lists at the same capacities. The representative +Avalonia navigation and Custom Drawing workload consistently compiles 40 records and 813 +segments. The initial capacities are now 48 records and 896 segments, preserving 20% and +10.2% headroom respectively. Existing geometric growth remains unchanged when a larger +font workload crosses either capacity. + +The direct per-atlas reservation changes exactly: + +| Storage | Former | Current | Change | +|---|---:|---:|---:| +| GPU record and segment buffers | 51,200 B | 44,544 B | **-6,656 B (-13.0%)** | +| Managed segment scratch elements | 49,152 B | 43,008 B | **-6,144 B** | +| Managed pending-segment elements | 49,152 B | 43,008 B | **-6,144 B** | +| Managed pending-record elements | 2,048 B | 1,536 B | **-512 B** | +| Combined direct GPU and managed payload | 151,552 B | 132,096 B | **-19,456 B (-12.8%)** | + +Managed figures describe reserved element payload and exclude the unchanged list and array +object headers. GPU allocation is reported directly from the two WebGPU buffer +descriptors. + +A temporary same-binary capacity override restored 64/1,024 and was removed after six +legacy and six compact fresh processes. Each process used 180 warm-up and 300 measured +Custom Drawing frames in forward and reverse launch order. Every compact process remained +at exactly 40/48 records and 813/896 segments; no buffer growth, extra glyph-raster +submission, workload change, or WebGPU error occurred. + +Across all twelve runs, allocation remains exactly 5,996.85 B/frame at the median. The +unadjusted medians were 57.28 FPS / 1.1804 ms compositor for legacy and 56.60 FPS / +1.2974 ms for compact. Those aggregate values are launch-order biased: when legacy ran +first, the later compact process ranged from -1.04% to -3.10% FPS; reversing the order +reduced compact-versus-legacy to -0.25% through +0.67%, with compositor deltas from +-0.0972 to +0.0080 ms. Since only buffer capacity changes, all compute dispatch counts +and shader-visible record/segment counts are identical, and the reverse-order results +straddle zero, no throughput or compositor regression is attributed to the compact +reservation. + +The final rebuilt Avalonia 12 binary reports 44,544 bytes of glyph-outline GPU buffers, +40/48 records, 813/896 segments, the unchanged 352 commands and 113 draws, 56.27 FPS, +0.2662 ms compilation, and 1.3848 ms total compositor time. Its inspected screenshot +preserves the expected glyph, path, and Custom Drawing output. + +Regression coverage verifies both the exact compact initial capacity and geometric growth +beyond it while preserving outline compilation, upload, and coverage. The runtime +override used for measurement is absent from the final source. + +Artifacts: + +- `artifacts/memory-optimization/step29-glyph-outline-capacity/legacy-1.json` through + `legacy-3.json` +- `artifacts/memory-optimization/step29-glyph-outline-capacity/compact-1.json` through + `compact-3.json` +- `artifacts/memory-optimization/step29-glyph-outline-capacity/reverse-legacy-1.json` + through `reverse-legacy-3.json` +- `artifacts/memory-optimization/step29-glyph-outline-capacity/reverse-compact-1.json` + through `reverse-compact-3.json` +- `artifacts/memory-optimization/step29-glyph-outline-capacity/final.json` +- `artifacts/memory-optimization/step29-glyph-outline-capacity/final.png` + +### Final validation + +- `ProGPU.Tests`: **2,398 passed**, 0 failed. +- `ProGPU.Tests.Headless`: **198 passed**, 0 failed. +- Focused bidi coverage: **140 passed**, including 32,768 exhaustive ASCII pair + comparisons; all **5/5** affected headless text pages pass. +- `ProGPU.Xaml.Tests`: **249 passed**, 0 failed. +- Avalonia ProGPU unit tests: **66 passed**, 0 failed. +- Avalonia ProGPU render tests: **1 passed**, 0 failed. +- Avalonia SkiaShim render tests: **2 passed**, 0 failed. +- Avalonia Silk.NET integration tests: **34 passed**, 0 failed. +- Focused font/storage tests: **77 passed**, including exact Inter/Noto hashes, CFF/WOFF, + variable fonts, TTC faces, mapped raw SFNT, and demand-resident `sbix`. +- Avalonia 11 and Avalonia 12 Rendering/SilkNet Release builds, native-Skia and ProGPU + ControlCatalog builds, RenderDemo, ProGpuSandbox, SampleControls, and the WinUI desktop + sample Release build all succeed. +- The unchanged `1_Counter`, `3_FlightBooker`, `4_Timer`, `5_CRUD`, and `6_CircleDrawer` + examples still fail their pre-existing routed-event/Reactive API conversions; none of + the optimization changes enter those projects. `2_TempConverter` and `7_Cells` build. +- All **54/54** isolated WinUI sample pages completed the final Release memory/performance + profile. +- No wgpu validation errors occurred in any measured ControlCatalog or WinUI run. +- Atlas maximums, command/draw/path counts, DPI/raster policy, animation workload, and + color-font payloads are unchanged. Path-atlas residency now follows measured independent + X/Y demand. Headless pixel/layout coverage is green. diff --git a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj index 88117512..ce9e815f 100644 --- a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj +++ b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj @@ -6,6 +6,7 @@ true enable $(DefineConstants);AVALONIA_SKIA_SHIM + $(DefineConstants);PROGPU_AVALONIA_BACKEND en app.manifest diff --git a/samples/ControlCatalog.Desktop/ControlCatalogBenchmark.cs b/samples/ControlCatalog.Desktop/ControlCatalogBenchmark.cs new file mode 100644 index 00000000..b99ed97d --- /dev/null +++ b/samples/ControlCatalog.Desktop/ControlCatalogBenchmark.cs @@ -0,0 +1,607 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +#if PROGPU_AVALONIA_BACKEND +using Avalonia.ProGpu; +using ProGPU.Scene; +#endif + +namespace ControlCatalog.Desktop +{ + internal sealed class ControlCatalogBenchmark : IDisposable + { + private const string OutputVariable = "PROGPU_AVALONIA_BENCHMARK_OUTPUT"; + private const string ScreenshotVariable = "PROGPU_AVALONIA_BENCHMARK_SCREENSHOT"; + private const string WarmupVariable = "PROGPU_AVALONIA_BENCHMARK_WARMUP_FRAMES"; + private const string MeasureVariable = "PROGPU_AVALONIA_BENCHMARK_MEASURE_FRAMES"; + private const string WholeFontsVariable = "PROGPU_AVALONIA_BENCHMARK_WHOLE_FONTS"; + private const string WholeFontsSwitch = "ProGPU.Avalonia.Diagnostics.UseWholeSystemFontFiles"; + + private readonly string _backend; + private readonly string _page; + private readonly string _outputPath; + private readonly string? _screenshotPath; + private readonly int _warmupFrames; + private readonly int _measureFrames; + private readonly Process _process; + private readonly long _startupTimestamp = Stopwatch.GetTimestamp(); + private readonly Stopwatch _measurementClock = new(); + private readonly Action _animationFrameCallback; + private Window? _window; + private int _totalFrames; + private int _measuredFrames; + private int _completed; + private long _allocatedBytesStart; + private TimeSpan _cpuStart; + private int _gen0Start; + private int _gen1Start; + private int _gen2Start; + private double _frameMs; + private double _maxFrameMs; + private long _lastAnimationTimestamp; +#if PROGPU_AVALONIA_BACKEND + private int _metricSamples; + private double _compileMs; + private double _uploadMs; + private double _renderMs; + private double _compositorMs; + private double _maxCompileMs; + private int _sceneCacheHits; + private double _firstRenderedFrameMs; + private CompositorMetrics _lastMetrics; +#endif + + private ControlCatalogBenchmark( + string backend, + string page, + string outputPath, + string? screenshotPath, + int warmupFrames, + int measureFrames) + { + _backend = backend; + _page = page; + _outputPath = outputPath; + _screenshotPath = screenshotPath; + _warmupFrames = warmupFrames; + _measureFrames = measureFrames; + _process = Process.GetCurrentProcess(); + _animationFrameCallback = OnAnimationFrame; +#if PROGPU_AVALONIA_BACKEND + ProGpuRenderingDiagnostics.FrameRendered += OnProGpuFrameRendered; +#endif + AvaloniaBenchmarkEventSource.Log.WorkloadStarted(page); + } + + public static ControlCatalogBenchmark? TryStart(string backend, string? page) + { + var outputPath = Environment.GetEnvironmentVariable(OutputVariable); + if (string.IsNullOrWhiteSpace(outputPath)) + { + return null; + } + + if (ReadBoolean(WholeFontsVariable)) + { + AppContext.SetSwitch(WholeFontsSwitch, true); + } + + var screenshotPath = Environment.GetEnvironmentVariable(ScreenshotVariable); + return new ControlCatalogBenchmark( + backend, + string.IsNullOrWhiteSpace(page) ? "Home" : page, + Path.GetFullPath(outputPath), + string.IsNullOrWhiteSpace(screenshotPath) ? null : Path.GetFullPath(screenshotPath), + ReadPositiveInt(WarmupVariable, 120), + ReadPositiveInt(MeasureVariable, 300)); + } + + public void Attach() + { + Dispatcher.UIThread.Post(TryAttach, DispatcherPriority.Loaded); + } + + private void TryAttach() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime lifetime || + lifetime.MainWindow is not { } window) + { + Dispatcher.UIThread.Post(TryAttach, DispatcherPriority.Background); + return; + } + + _window = window; + window.RequestAnimationFrame(_animationFrameCallback); + } + + private void OnAnimationFrame(TimeSpan timestamp) + { + if (Volatile.Read(ref _completed) != 0 || _window is not { } window) + { + return; + } + + window.InvalidateVisual(); + _totalFrames++; + + long timestampTicks = timestamp.Ticks; + if (_lastAnimationTimestamp != 0 && _totalFrames > _warmupFrames) + { + double elapsedMilliseconds = + (timestampTicks - _lastAnimationTimestamp) * 1000d / TimeSpan.TicksPerSecond; + _frameMs += elapsedMilliseconds; + _maxFrameMs = Math.Max(_maxFrameMs, elapsedMilliseconds); + } + _lastAnimationTimestamp = timestampTicks; + + if (_totalFrames == _warmupFrames) + { + CollectRetainedMemory(); + _allocatedBytesStart = GC.GetTotalAllocatedBytes(precise: true); + _cpuStart = _process.TotalProcessorTime; + _gen0Start = GC.CollectionCount(0); + _gen1Start = GC.CollectionCount(1); + _gen2Start = GC.CollectionCount(2); + _measurementClock.Restart(); + AvaloniaBenchmarkEventSource.Log.MeasurementStarted(_page); + } + else if (_totalFrames > _warmupFrames) + { + _measuredFrames++; + if (_measuredFrames == _measureFrames && + Interlocked.Exchange(ref _completed, 1) == 0) + { + Complete(); + return; + } + } + + window.RequestAnimationFrame(_animationFrameCallback); + } + +#if PROGPU_AVALONIA_BACKEND + private void OnProGpuFrameRendered(CompositorMetrics metrics) + { + if (_firstRenderedFrameMs == 0d) + { + _firstRenderedFrameMs = Stopwatch + .GetElapsedTime(_startupTimestamp) + .TotalMilliseconds; + } + + if (Volatile.Read(ref _completed) != 0 || _totalFrames <= _warmupFrames) + { + return; + } + + _metricSamples++; + _compileMs += metrics.VisualTreeCompileTimeMs; + _uploadMs += metrics.GpuUploadTimeMs; + _renderMs += metrics.RenderPassTimeMs; + _compositorMs += metrics.FrameTimeMs; + _maxCompileMs = Math.Max(_maxCompileMs, metrics.VisualTreeCompileTimeMs); + if (metrics.SceneCacheHit) + { + _sceneCacheHits++; + } + _lastMetrics = metrics; + } +#endif + + private void Complete() + { + _measurementClock.Stop(); + AvaloniaBenchmarkEventSource.Log.MeasurementStopped(_page); + var allocatedBytes = GC.GetTotalAllocatedBytes(precise: true) - _allocatedBytesStart; + var cpu = _process.TotalProcessorTime - _cpuStart; + var elapsedSeconds = Math.Max(_measurementClock.Elapsed.TotalSeconds, double.Epsilon); + + CollectRetainedMemory(); + _process.Refresh(); + var gcInfo = GC.GetGCMemoryInfo(); + var memory = ProcessMemorySnapshot.Capture(_process); + var result = new BenchmarkResult + { + Backend = _backend, + Page = _page, + WarmupFrames = _warmupFrames, + MeasuredFrames = _measureFrames, + ElapsedSeconds = elapsedSeconds, + FramesPerSecond = _measureFrames / elapsedSeconds, + ProcessCpuSeconds = cpu.TotalSeconds, + ProcessCpuPercent = cpu.TotalSeconds / elapsedSeconds / + Math.Max(1, Environment.ProcessorCount) * 100, + AllocatedBytes = allocatedBytes, + AllocatedBytesPerFrame = (double)allocatedBytes / _measureFrames, + ManagedBytes = GC.GetTotalMemory(forceFullCollection: false), + GcCommittedBytes = gcInfo.TotalCommittedBytes, + ManagedFragmentedBytes = gcInfo.FragmentedBytes, + ProcessWorkingSetBytes = _process.WorkingSet64, + ProcessResidentBytes = memory.ResidentBytes, + ProcessWiredBytes = memory.WiredBytes, + ProcessPhysicalFootprintBytes = memory.PhysicalFootprintBytes, + ProcessPeakPhysicalFootprintBytes = memory.LifetimeMaxPhysicalFootprintBytes, + Gen0Collections = GC.CollectionCount(0) - _gen0Start, + Gen1Collections = GC.CollectionCount(1) - _gen1Start, + Gen2Collections = GC.CollectionCount(2) - _gen2Start, + AverageFrameMs = _frameMs / _measureFrames, + MaxFrameMs = _maxFrameMs, +#if PROGPU_AVALONIA_BACKEND + AverageCompileMs = Average(_compileMs, _metricSamples), + AverageUploadMs = Average(_uploadMs, _metricSamples), + AverageRenderMs = Average(_renderMs, _metricSamples), + AverageCompositorMs = Average(_compositorMs, _metricSamples), + MaxCompileMs = _maxCompileMs, + FirstRenderedFrameMs = _firstRenderedFrameMs, + SceneCacheHits = _sceneCacheHits, + PathAtlasEntries = _lastMetrics.PathAtlasCachedCount, + PathAtlasCpuCacheBytes = _lastMetrics.PathAtlasCpuCacheBytes, + PathAtlasTextureBytes = _lastMetrics.PathAtlasTextureBytes, + PathAtlasWidth = _lastMetrics.PathAtlasWidth, + PathAtlasHeight = _lastMetrics.PathAtlasHeight, + PathRasterStagingBytes = _lastMetrics.PathRasterStagingBytes, + PathPeakRasterStagingBytes = _lastMetrics.PathPeakRasterStagingBytes, + PathPeakRasterWidth = _lastMetrics.PathPeakRasterWidth, + PathPeakRasterHeight = _lastMetrics.PathPeakRasterHeight, + GlyphAtlasTextureBytes = _lastMetrics.GlyphAtlasTextureBytes, + ColorGlyphAtlasTextureBytes = _lastMetrics.ColorGlyphAtlasTextureBytes, + GlyphUniformStagingBytes = _lastMetrics.GlyphUniformStagingBytes, + GlyphUniformUploadBytes = _lastMetrics.GlyphUniformUploadBytes, + GlyphCoverageStagingBytes = _lastMetrics.GlyphCoverageStagingBytes, + GlyphOutlineGpuBytes = _lastMetrics.GlyphOutlineGpuBytes, + GlyphOutlineCompiledCount = _lastMetrics.GlyphOutlineCompiledCount, + GlyphOutlineRecordCapacity = _lastMetrics.GlyphOutlineRecordCapacity, + GlyphOutlineSegmentCount = _lastMetrics.GlyphOutlineSegmentCount, + GlyphOutlineSegmentCapacity = _lastMetrics.GlyphOutlineSegmentCapacity, + GlyphRasterBatchSubmissions = _lastMetrics.GlyphRasterBatchSubmissions, + GlyphRasterComputePasses = _lastMetrics.GlyphRasterComputePasses, + SceneBufferBytes = _lastMetrics.SceneBufferBytes, + BrushStorageBufferBytes = _lastMetrics.BrushStorageBufferBytes, + GradientStopStorageBufferBytes = _lastMetrics.GradientStopStorageBufferBytes, + ActiveBrushCount = _lastMetrics.ActiveBrushCount, + ActiveGradientStopCount = _lastMetrics.ActiveGradientStopCount, + EffectParameterBufferBytes = _lastMetrics.EffectParameterBufferBytes, + EffectShaderCount = _lastMetrics.EffectShaderCount, + EffectPipelineCount = _lastMetrics.EffectPipelineCount, + SceneShaderCount = _lastMetrics.SceneShaderCount, + SceneRenderPipelineCount = _lastMetrics.SceneRenderPipelineCount, + SceneComputePipelineCount = _lastMetrics.SceneComputePipelineCount, + BaseBindGroupLayoutCount = _lastMetrics.BaseBindGroupLayoutCount, + BasePipelineLayoutCount = _lastMetrics.BasePipelineLayoutCount, + BaseBindGroupCount = _lastMetrics.BaseBindGroupCount, + PersistentTextureBindGroupCount = _lastMetrics.PersistentTextureBindGroupCount, + MaskBindGroupCount = _lastMetrics.MaskBindGroupCount, + GpuHitTestingEnabled = _lastMetrics.GpuHitTestingEnabled, + DrawCalls = _lastMetrics.DrawCallsCount, + RecordedCommands = _lastMetrics.RecordedCommandCount, + RecordedCommandCapacity = _lastMetrics.RecordedCommandCapacity, + VectorVertices = _lastMetrics.VectorVerticesCount, + TextVertices = _lastMetrics.TextVerticesCount +#endif + }; + + Console.WriteLine( + string.Create( + CultureInfo.InvariantCulture, + $"[SampleBenchmark] RESULT backend={result.Backend} page=\"{result.Page}\" frames={_measureFrames}" + + $" wallFps={result.FramesPerSecond:F2} frameMs={result.AverageFrameMs:F4}" + + $" compileMs={result.AverageCompileMs:F4} uploadMs={result.AverageUploadMs:F4}" + + $" renderMs={result.AverageRenderMs:F4} compositorMs={result.AverageCompositorMs:F4}" + + $" maxFrameMs={result.MaxFrameMs:F4} maxCompileMs={result.MaxCompileMs:F4}" + + $" firstRenderedFrameMs={result.FirstRenderedFrameMs:F2}" + + $" allocatedBytesPerFrame={result.AllocatedBytesPerFrame:F0}" + + $" managedHeapBytes={result.ManagedBytes} gcCommittedBytes={result.GcCommittedBytes}" + + $" managedFragmentedBytes={result.ManagedFragmentedBytes}" + + $" processWorkingSetBytes={result.ProcessWorkingSetBytes}" + + $" processResidentBytes={result.ProcessResidentBytes}" + + $" processWiredBytes={result.ProcessWiredBytes}" + + $" processPhysicalFootprintBytes={result.ProcessPhysicalFootprintBytes}" + + $" processPeakPhysicalFootprintBytes={result.ProcessPeakPhysicalFootprintBytes}" + + $" gen0Collections={result.Gen0Collections} gen1Collections={result.Gen1Collections}" + + $" gen2Collections={result.Gen2Collections} pathAtlasEntries={result.PathAtlasEntries}" + + $" pathAtlasCpuCacheBytes={result.PathAtlasCpuCacheBytes}" + + $" pathAtlasTextureBytes={result.PathAtlasTextureBytes}" + + $" pathAtlasWidth={result.PathAtlasWidth}" + + $" pathAtlasHeight={result.PathAtlasHeight}" + + $" pathPeakRasterWidth={result.PathPeakRasterWidth}" + + $" pathPeakRasterHeight={result.PathPeakRasterHeight}" + + $" glyphAtlasTextureBytes={result.GlyphAtlasTextureBytes}" + + $" colorGlyphAtlasTextureBytes={result.ColorGlyphAtlasTextureBytes}" + + $" glyphUniformStagingBytes={result.GlyphUniformStagingBytes}" + + $" glyphUniformUploadBytes={result.GlyphUniformUploadBytes}" + + $" glyphCoverageStagingBytes={result.GlyphCoverageStagingBytes}" + + $" glyphOutlineGpuBytes={result.GlyphOutlineGpuBytes}" + + $" glyphOutlineCompiled={result.GlyphOutlineCompiledCount}" + + $" glyphOutlineRecordCapacity={result.GlyphOutlineRecordCapacity}" + + $" glyphOutlineSegments={result.GlyphOutlineSegmentCount}" + + $" glyphOutlineSegmentCapacity={result.GlyphOutlineSegmentCapacity}" + + $" glyphRasterBatchSubmissions={result.GlyphRasterBatchSubmissions}" + + $" glyphRasterComputePasses={result.GlyphRasterComputePasses}" + + $" sceneBufferBytes={result.SceneBufferBytes}" + + $" brushStorageBufferBytes={result.BrushStorageBufferBytes}" + + $" gradientStopStorageBufferBytes={result.GradientStopStorageBufferBytes}" + + $" activeBrushes={result.ActiveBrushCount}" + + $" activeGradientStops={result.ActiveGradientStopCount}" + + $" effectParameterBufferBytes={result.EffectParameterBufferBytes}" + + $" effectShaders={result.EffectShaderCount}" + + $" effectPipelines={result.EffectPipelineCount}" + + $" sceneShaders={result.SceneShaderCount}" + + $" sceneRenderPipelines={result.SceneRenderPipelineCount}" + + $" sceneComputePipelines={result.SceneComputePipelineCount}" + + $" baseBindGroupLayouts={result.BaseBindGroupLayoutCount}" + + $" basePipelineLayouts={result.BasePipelineLayoutCount}" + + $" baseBindGroups={result.BaseBindGroupCount}" + + $" persistentTextureBindGroups={result.PersistentTextureBindGroupCount}" + + $" maskBindGroups={result.MaskBindGroupCount}" + + $" gpuHitTestingEnabled={result.GpuHitTestingEnabled} draws={result.DrawCalls}" + + $" recordedCommands={result.RecordedCommands}" + + $" recordedCommandCapacity={result.RecordedCommandCapacity}" + + $" vectorVertices={result.VectorVertices} textVertices={result.TextVertices}")); + + try + { + WriteScreenshot(); + var directory = Path.GetDirectoryName(_outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + File.WriteAllText( + _outputPath, + JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + Environment.Exit(0); + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + Environment.Exit(3); + } + } + + private void WriteScreenshot() + { + if (_screenshotPath is null || _window is not { } window) + { + return; + } + + int width = Math.Max(1, (int)Math.Ceiling(window.ClientSize.Width)); + int height = Math.Max(1, (int)Math.Ceiling(window.ClientSize.Height)); + using var bitmap = new RenderTargetBitmap( + new PixelSize(width, height), + new Vector(96 * window.RenderScaling, 96 * window.RenderScaling)); + bitmap.Render(window); + var directory = Path.GetDirectoryName(_screenshotPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + bitmap.Save(_screenshotPath); + } + + private static void CollectRetainedMemory() + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); + } + + private static double Average(double total, int samples) + => samples == 0 ? 0 : total / samples; + + public void Dispose() + { +#if PROGPU_AVALONIA_BACKEND + ProGpuRenderingDiagnostics.FrameRendered -= OnProGpuFrameRendered; +#endif + _process.Dispose(); + } + + private static int ReadPositiveInt(string variable, int fallback) + { + var value = Environment.GetEnvironmentVariable(variable); + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && + parsed > 0 + ? parsed + : fallback; + } + + private static bool ReadBoolean(string variable) + { + var value = Environment.GetEnvironmentVariable(variable); + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase); + } + + private sealed class BenchmarkResult + { + public string Backend { get; init; } = string.Empty; + public string Page { get; init; } = string.Empty; + public int WarmupFrames { get; init; } + public int MeasuredFrames { get; init; } + public double ElapsedSeconds { get; init; } + public double FramesPerSecond { get; init; } + public double ProcessCpuSeconds { get; init; } + public double ProcessCpuPercent { get; init; } + public long AllocatedBytes { get; init; } + public double AllocatedBytesPerFrame { get; init; } + public long ManagedBytes { get; init; } + public long GcCommittedBytes { get; init; } + public long ManagedFragmentedBytes { get; init; } + public long ProcessWorkingSetBytes { get; init; } + public long ProcessResidentBytes { get; init; } + public long ProcessWiredBytes { get; init; } + public long ProcessPhysicalFootprintBytes { get; init; } + public long ProcessPeakPhysicalFootprintBytes { get; init; } + public int Gen0Collections { get; init; } + public int Gen1Collections { get; init; } + public int Gen2Collections { get; init; } + public double AverageFrameMs { get; init; } + public double AverageCompileMs { get; init; } + public double AverageUploadMs { get; init; } + public double AverageRenderMs { get; init; } + public double AverageCompositorMs { get; init; } + public double MaxFrameMs { get; init; } + public double MaxCompileMs { get; init; } + public double FirstRenderedFrameMs { get; init; } + public int SceneCacheHits { get; init; } + public int PathAtlasEntries { get; init; } + public long PathAtlasCpuCacheBytes { get; init; } + public ulong PathAtlasTextureBytes { get; init; } + public uint PathAtlasWidth { get; init; } + public uint PathAtlasHeight { get; init; } + public uint PathRasterStagingBytes { get; init; } + public uint PathPeakRasterStagingBytes { get; init; } + public uint PathPeakRasterWidth { get; init; } + public uint PathPeakRasterHeight { get; init; } + public ulong GlyphAtlasTextureBytes { get; init; } + public ulong ColorGlyphAtlasTextureBytes { get; init; } + public ulong GlyphUniformStagingBytes { get; init; } + public ulong GlyphUniformUploadBytes { get; init; } + public ulong GlyphCoverageStagingBytes { get; init; } + public ulong GlyphOutlineGpuBytes { get; init; } + public int GlyphOutlineCompiledCount { get; init; } + public int GlyphOutlineRecordCapacity { get; init; } + public int GlyphOutlineSegmentCount { get; init; } + public int GlyphOutlineSegmentCapacity { get; init; } + public ulong GlyphRasterBatchSubmissions { get; init; } + public ulong GlyphRasterComputePasses { get; init; } + public ulong SceneBufferBytes { get; init; } + public ulong BrushStorageBufferBytes { get; init; } + public ulong GradientStopStorageBufferBytes { get; init; } + public int ActiveBrushCount { get; init; } + public int ActiveGradientStopCount { get; init; } + public ulong EffectParameterBufferBytes { get; init; } + public int EffectShaderCount { get; init; } + public int EffectPipelineCount { get; init; } + public int SceneShaderCount { get; init; } + public int SceneRenderPipelineCount { get; init; } + public int SceneComputePipelineCount { get; init; } + public int BaseBindGroupLayoutCount { get; init; } + public int BasePipelineLayoutCount { get; init; } + public int BaseBindGroupCount { get; init; } + public int PersistentTextureBindGroupCount { get; init; } + public int MaskBindGroupCount { get; init; } + public bool GpuHitTestingEnabled { get; init; } + public int DrawCalls { get; init; } + public int RecordedCommands { get; init; } + public int RecordedCommandCapacity { get; init; } + public int VectorVertices { get; init; } + public int TextVertices { get; init; } + } + } + + internal readonly record struct ProcessMemorySnapshot( + long ResidentBytes, + long WiredBytes, + long PhysicalFootprintBytes, + long LifetimeMaxPhysicalFootprintBytes) + { + public static ProcessMemorySnapshot Capture(Process process) + { + if (OperatingSystem.IsMacOS() && MacOsProcessMemory.TryCapture(process.Id, out var mac)) + { + return mac; + } + return new ProcessMemorySnapshot( + process.WorkingSet64, + 0, + process.WorkingSet64, + process.PeakWorkingSet64); + } + } + + internal static unsafe class MacOsProcessMemory + { + private const int RUsageInfoV4Flavor = 4; + + public static bool TryCapture(int processId, out ProcessMemorySnapshot snapshot) + { + var usage = default(RUsageInfoV4); + if (ProcPidRUsage(processId, RUsageInfoV4Flavor, ref usage) == 0) + { + snapshot = new ProcessMemorySnapshot( + checked((long)usage.ResidentSize), + checked((long)usage.WiredSize), + checked((long)usage.PhysicalFootprint), + checked((long)usage.LifetimeMaxPhysicalFootprint)); + return true; + } + snapshot = default; + return false; + } + + [DllImport("/usr/lib/libproc.dylib", EntryPoint = "proc_pid_rusage")] + private static extern int ProcPidRUsage( + int processId, + int flavor, + ref RUsageInfoV4 buffer); + + [StructLayout(LayoutKind.Sequential)] + private struct RUsageInfoV4 + { + public fixed byte Uuid[16]; + public ulong UserTime; + public ulong SystemTime; + public ulong PackageIdleWakeups; + public ulong InterruptWakeups; + public ulong PageIns; + public ulong WiredSize; + public ulong ResidentSize; + public ulong PhysicalFootprint; + public ulong ProcessStartAbsoluteTime; + public ulong ProcessExitAbsoluteTime; + public ulong ChildUserTime; + public ulong ChildSystemTime; + public ulong ChildPackageIdleWakeups; + public ulong ChildInterruptWakeups; + public ulong ChildPageIns; + public ulong ChildElapsedAbsoluteTime; + public ulong DiskBytesRead; + public ulong DiskBytesWritten; + public ulong CpuTimeDefault; + public ulong CpuTimeMaintenance; + public ulong CpuTimeBackground; + public ulong CpuTimeUtility; + public ulong CpuTimeLegacy; + public ulong CpuTimeUserInitiated; + public ulong CpuTimeUserInteractive; + public ulong BilledSystemTime; + public ulong ServicedSystemTime; + public ulong LogicalWrites; + public ulong LifetimeMaxPhysicalFootprint; + public ulong Instructions; + public ulong Cycles; + public ulong BilledEnergy; + public ulong ServicedEnergy; + public ulong IntervalMaxPhysicalFootprint; + public ulong RunnableTime; + } + } + + [EventSource(Name = "ProGPU-SampleBenchmark")] + internal sealed class AvaloniaBenchmarkEventSource : EventSource + { + public static readonly AvaloniaBenchmarkEventSource Log = new(); + + [Event(1, Level = EventLevel.Informational)] + public void WorkloadStarted(string page) => WriteEvent(1, page); + + [Event(2, Level = EventLevel.Informational)] + public void MeasurementStarted(string page) => WriteEvent(2, page); + + [Event(3, Level = EventLevel.Informational)] + public void MeasurementStopped(string page) => WriteEvent(3, page); + } +} diff --git a/samples/ControlCatalog.Desktop/Program.cs b/samples/ControlCatalog.Desktop/Program.cs index d5cfab8d..3e228586 100644 --- a/samples/ControlCatalog.Desktop/Program.cs +++ b/samples/ControlCatalog.Desktop/Program.cs @@ -20,6 +20,7 @@ static int Main(string[] args) { App.InitialPage = args[pageArgumentIndex + 1]; } + using var benchmark = ControlCatalogBenchmark.TryStart("ProGPU", App.InitialPage); if (args.Contains("--wait-for-attach")) { @@ -45,6 +46,7 @@ static int Main(string[] args) var builder = useSkiaShim ? BuildSkiaShimApp() : BuildAvaloniaApp(); + builder.AfterSetup(_ => benchmark?.Attach()); return builder.StartWithClassicDesktopLifetime(args); } diff --git a/samples/ControlCatalog.Skia/ControlCatalog.Skia.csproj b/samples/ControlCatalog.Skia/ControlCatalog.Skia.csproj new file mode 100644 index 00000000..a80367c9 --- /dev/null +++ b/samples/ControlCatalog.Skia/ControlCatalog.Skia.csproj @@ -0,0 +1,68 @@ + + + WinExe + net10.0 + ControlCatalog + true + true + enable + true + en + ..\ControlCatalog.Desktop\app.manifest + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\external\Avalonia')) + $(AvaloniaForkRoot)\samples\ControlCatalog + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + + + %(Filename) + + + %(RecursiveDir)%(Filename)%(Extension) + Designer + + + Assets\%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + + + + + + + + + + + + + + + MSBuild:Compile + + + + + + diff --git a/samples/ControlCatalog.Skia/PORTING-NOTICE.txt b/samples/ControlCatalog.Skia/PORTING-NOTICE.txt new file mode 100644 index 00000000..2a3f0b9a --- /dev/null +++ b/samples/ControlCatalog.Skia/PORTING-NOTICE.txt @@ -0,0 +1,15 @@ +PORTING NOTICE + +This comparison host source-links the ControlCatalog from: + +Repository: https://github.com/wieslawsoltes/Avalonia.git +Tag: progpu-avalonia-v12.0.5-preview.19 +Commit: 5378af03f17a4d9d2845882229ffed7f67350037 +Submodule path: external/Avalonia +Original path: samples/ControlCatalog + +Only the host project and startup entry point are kept locally. They run the +same linked catalog with the native Avalonia Skia renderer so memory, +performance, and rendering output can be compared with the ProGPU host. + +No Git history or commits were imported from the source repository. diff --git a/samples/ControlCatalog.Skia/Program.cs b/samples/ControlCatalog.Skia/Program.cs new file mode 100644 index 00000000..74089d86 --- /dev/null +++ b/samples/ControlCatalog.Skia/Program.cs @@ -0,0 +1,31 @@ +using System; +using Avalonia; +using ControlCatalog; +using ControlCatalog.Desktop; + +namespace ControlCatalog.Skia; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + var pageArgumentIndex = Array.IndexOf(args, "--page"); + if (pageArgumentIndex >= 0 && pageArgumentIndex + 1 < args.Length) + { + App.InitialPage = args[pageArgumentIndex + 1]; + } + + using var benchmark = ControlCatalogBenchmark.TryStart("Skia", App.InitialPage); + var builder = BuildAvaloniaApp(); + builder.AfterSetup(_ => benchmark?.Attach()); + return builder.StartWithClassicDesktopLifetime(args); + } + + private static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .UseHarfBuzz() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/ProGPU.Avalonia.Rendering/DrawingContextImpl.cs b/src/ProGPU.Avalonia.Rendering/DrawingContextImpl.cs index 96345b3b..5febdeb9 100644 --- a/src/ProGPU.Avalonia.Rendering/DrawingContextImpl.cs +++ b/src/ProGPU.Avalonia.Rendering/DrawingContextImpl.cs @@ -8,6 +8,7 @@ using ProGPU.Backend; using ProGPU.Vector; using ProGPU.Scene; +using ProGPU.Text; namespace Avalonia.ProGpu { @@ -27,15 +28,11 @@ internal partial class DrawingContextImpl : IDrawingContextImpl, private Matrix _currentTransform = Matrix.Identity; private double _currentOpacity = 1.0; private Vector4 _clearColor = new Vector4(1f, 1f, 1f, 1f); - private readonly Stack _opacityStack = new(); + private DrawingContextState? _state; private int _opacityMaskDepth; private bool _leased; private bool _disposed; - private readonly Stack _clipStack = new(); - private readonly Stack _renderOptionsStack = new(); -#if !AVALONIA11 - private readonly Stack _textOptionsStack = new(); -#endif + private bool _recordingContextReturned; private enum ClipKind { @@ -43,12 +40,54 @@ private enum ClipKind Geometry } + private sealed class DrawingContextState + { + internal const int MaximumRetainedDepth = 64; + + internal readonly Stack OpacityStack = new(); + internal readonly Stack ClipStack = new(); + internal readonly Stack RenderOptionsStack = new(); +#if !AVALONIA11 + internal readonly Stack TextOptionsStack = new(); +#endif + internal DrawingContextState? Next; + + internal void Clear() + { + OpacityStack.Clear(); + ClipStack.Clear(); + RenderOptionsStack.Clear(); +#if !AVALONIA11 + TextOptionsStack.Clear(); +#endif + } + + internal bool CanRetain() + { + return OpacityStack.EnsureCapacity(0) <= MaximumRetainedDepth && + ClipStack.EnsureCapacity(0) <= MaximumRetainedDepth && + RenderOptionsStack.EnsureCapacity(0) <= MaximumRetainedDepth +#if !AVALONIA11 + && TextOptionsStack.EnsureCapacity(0) <= MaximumRetainedDepth +#endif + ; + } + } + + [ThreadStatic] + private static DrawingContextState? s_drawingContextStatePool; + + [ThreadStatic] + private static int s_drawingContextStatePoolCount; + + private const int MaximumPooledDrawingContextStates = 4; + public Avalonia.Media.RenderOptions RenderOptions { get; private set; } #if !AVALONIA11 public Avalonia.Media.TextOptions TextOptions { get; private set; } #endif - public ProGPU.Scene.DrawingContext DrawingContext { get; private set; } = new(); + public ProGPU.Scene.DrawingContext DrawingContext { get; private set; } public Vector Dpi { get; } public struct CreateInfo @@ -169,6 +208,9 @@ public DrawingContextImpl(CreateInfo createInfo, params IDisposable?[]? disposab _preserveRecordedCommandsOnDispose = createInfo.PreserveRecordedCommandsOnDispose; _disableSubpixelTextRendering = createInfo.DisableSubpixelTextRendering; _offscreenCache = (createInfo.CacheHolder as OffscreenTextureCache) ?? GetFallbackCache(); + DrawingContext = _preserveRecordedCommandsOnDispose + ? new ProGPU.Scene.DrawingContext() + : _offscreenCache.RentRecordingContext(); if (createInfo.ScaleDrawingToDpi && TryGetDpiScale(createInfo.Dpi, out double scaleX, out double scaleY) && (!NearlyEqual(scaleX, 1.0) || !NearlyEqual(scaleY, 1.0))) @@ -220,6 +262,7 @@ public DrawingContextImpl(CreateInfo createInfo, params IDisposable?[]? disposab EnsureGpuContext(_framebuffer, preferredFormat); _gpuContext = s_wgpuContext ?? throw new InvalidOperationException("ProGPU did not initialize a WebGPU context."); + _state = RentDrawingContextState(); } private void CheckLease() @@ -229,6 +272,44 @@ private void CheckLease() throw new InvalidOperationException("The underlying ProGPU API is currently leased."); } + private DrawingContextState State => + _state ?? throw new ObjectDisposedException(nameof(DrawingContextImpl)); + + private static DrawingContextState RentDrawingContextState() + { + var state = s_drawingContextStatePool; + if (state == null) + { + return new DrawingContextState(); + } + + s_drawingContextStatePool = state.Next; + state.Next = null; + s_drawingContextStatePoolCount--; + return state; + } + + private void ReturnDrawingContextState() + { + var state = _state; + if (state == null) + { + return; + } + + _state = null; + state.Clear(); + if (!state.CanRetain() || + s_drawingContextStatePoolCount >= MaximumPooledDrawingContextStates) + { + return; + } + + state.Next = s_drawingContextStatePool; + s_drawingContextStatePool = state; + s_drawingContextStatePoolCount++; + } + private Matrix RenderTransform => _postTransform.HasValue ? _currentTransform * _postTransform.Value : _currentTransform; @@ -238,13 +319,8 @@ public void Reset() CheckLease(); _currentTransform = Matrix.Identity; _currentOpacity = 1.0; - _opacityStack.Clear(); + State.Clear(); _opacityMaskDepth = 0; - _clipStack.Clear(); - _renderOptionsStack.Clear(); -#if !AVALONIA11 - _textOptionsStack.Clear(); -#endif DrawingContext.Clear(); } @@ -322,13 +398,28 @@ public void DrawGeometry(IBrush? brush, IPen? pen, IGeometryImpl geometry) { if (pPen != null) { - DrawingContext.DrawPath(null, pPen, geomImpl.Path, ToMatrix4x4(RenderTransform)); + DrawingContext.DrawPath( + null, + pPen, + geomImpl.Path, + ToMatrix4x4(RenderTransform), + geomImpl.GetRenderCommandGeometryCache()); } return; } var pBrush = ConvertBrush(brush, bounds); - DrawingContext.DrawPath(pBrush, pPen, geomImpl.Path, ToMatrix4x4(RenderTransform)); + if (pBrush == null && pPen == null) + { + return; + } + + DrawingContext.DrawPath( + pBrush, + pPen, + geomImpl.Path, + ToMatrix4x4(RenderTransform), + geomImpl.GetRenderCommandGeometryCache()); } } @@ -402,23 +493,30 @@ public void DrawRectangle(IBrush? brush, IPen? pen, RoundedRect rect, BoxShadows CheckLease(); var pPen = ConvertPen(pen, rect.Rect); var localRect = ToLocalProGpuRect(rect.Rect); - var clipPath = rect.IsRounded - ? PrimitivePathGeometry.CreateRoundedRectangle( - localRect.X, - localRect.Y, - localRect.Width, - localRect.Height, - (float)rect.RadiiTopLeft.X, - (float)rect.RadiiTopLeft.Y) - : PrimitivePathGeometry.CreateRectangle(localRect.X, localRect.Y, localRect.Width, localRect.Height); - if (TryDrawSceneBrush(brush, rect.Rect, clipPath, useGeometryClip: false) || - TryDrawImageBrush(brush, rect.Rect, clipPath)) - { - if (pPen != null) + if (RequiresBrushClipPath(brush)) + { + var clipPath = rect.IsRounded + ? PrimitivePathGeometry.CreateRoundedRectangle( + localRect.X, + localRect.Y, + localRect.Width, + localRect.Height, + (float)rect.RadiiTopLeft.X, + (float)rect.RadiiTopLeft.Y) + : PrimitivePathGeometry.CreateRectangle( + localRect.X, + localRect.Y, + localRect.Width, + localRect.Height); + if (TryDrawSceneBrush(brush, rect.Rect, clipPath, useGeometryClip: false) || + TryDrawImageBrush(brush, rect.Rect, clipPath)) { - DrawingContext.DrawPath(null, pPen, clipPath, ToMatrix4x4(RenderTransform)); + if (pPen != null) + { + DrawingContext.DrawPath(null, pPen, clipPath, ToMatrix4x4(RenderTransform)); + } + return; } - return; } var pBrush = ConvertBrush(brush, rect.Rect); @@ -464,16 +562,19 @@ public void DrawEllipse(IBrush? brush, IPen? pen, Avalonia.Rect rect) var center = new Vector2((float)rect.Center.X, (float)rect.Center.Y); var radiusX = (float)(rect.Width / 2.0); var radiusY = (float)(rect.Height / 2.0); - var clipPath = PrimitivePathGeometry.CreateEllipse(center, radiusX, radiusY); var pPen = ConvertPen(pen, rect); - if (TryDrawSceneBrush(brush, rect, clipPath) || - TryDrawImageBrush(brush, rect, clipPath)) + if (RequiresBrushClipPath(brush)) { - if (pPen != null) + var clipPath = PrimitivePathGeometry.CreateEllipse(center, radiusX, radiusY); + if (TryDrawSceneBrush(brush, rect, clipPath) || + TryDrawImageBrush(brush, rect, clipPath)) { - DrawingContext.DrawPath(null, pPen, clipPath, ToMatrix4x4(RenderTransform)); + if (pPen != null) + { + DrawingContext.DrawPath(null, pPen, clipPath, ToMatrix4x4(RenderTransform)); + } + return; } - return; } var pBrush = ConvertBrush(brush, rect); @@ -628,13 +729,13 @@ public void PushClip(Avalonia.Rect clip) { CheckLease(); DrawingContext.PushClip(ToProGpuRect(clip)); - _clipStack.Push(ClipKind.Rectangle); + State.ClipStack.Push(ClipKind.Rectangle); } public void PushClip(RoundedRect clip) { CheckLease(); DrawingContext.PushClip(ToProGpuRect(clip.Rect)); - _clipStack.Push(ClipKind.Rectangle); + State.ClipStack.Push(ClipKind.Rectangle); } public void PushClip(IPlatformRenderInterfaceRegion region) { @@ -644,18 +745,19 @@ public void PushClip(IPlatformRenderInterfaceRegion region) { var rect = rects.Count == 0 ? default : rects[0]; DrawingContext.PushClip(ToProGpuRect(rect)); - _clipStack.Push(ClipKind.Rectangle); + State.ClipStack.Push(ClipKind.Rectangle); } else { DrawingContext.PushGeometryClip(CreateRegionGeometry(rects)); - _clipStack.Push(ClipKind.Geometry); + State.ClipStack.Push(ClipKind.Geometry); } } public void PopClip() { CheckLease(); - if (_clipStack.Count == 0 || _clipStack.Pop() == ClipKind.Rectangle) + var clipStack = State.ClipStack; + if (clipStack.Count == 0 || clipStack.Pop() == ClipKind.Rectangle) DrawingContext.PopClip(); else DrawingContext.PopGeometryClip(); @@ -675,7 +777,7 @@ public void PopLayer() public void PushOpacity(double opacity, Avalonia.Rect? bounds) { CheckLease(); - _opacityStack.Push(_currentOpacity); + State.OpacityStack.Push(_currentOpacity); _currentOpacity *= opacity; DrawingContext.PushOpacity((float)opacity); } @@ -683,9 +785,10 @@ public void PushOpacity(double opacity, Avalonia.Rect? bounds) public void PopOpacity() { CheckLease(); - if (_opacityStack.Count > 0) + var opacityStack = State.OpacityStack; + if (opacityStack.Count > 0) { - _currentOpacity = _opacityStack.Pop(); + _currentOpacity = opacityStack.Pop(); DrawingContext.PopOpacity(); } } @@ -764,28 +867,28 @@ private GpuPicture RecordOpacityMask(IBrush mask, Avalonia.Rect bounds) public void PushRenderOptions(Avalonia.Media.RenderOptions renderOptions) { CheckLease(); - _renderOptionsStack.Push(RenderOptions); + State.RenderOptionsStack.Push(RenderOptions); RenderOptions = RenderOptions.MergeWith(renderOptions); } public void PopRenderOptions() { CheckLease(); - RenderOptions = _renderOptionsStack.Pop(); + RenderOptions = State.RenderOptionsStack.Pop(); } #if !AVALONIA11 public void PushTextOptions(Avalonia.Media.TextOptions textOptions) { CheckLease(); - _textOptionsStack.Push(TextOptions); + State.TextOptionsStack.Push(TextOptions); TextOptions = TextOptions.MergeWith(textOptions); } public void PopTextOptions() { CheckLease(); - TextOptions = _textOptionsStack.Pop(); + TextOptions = State.TextOptionsStack.Pop(); } #endif @@ -810,6 +913,16 @@ public Matrix Transform private static WgpuContext? s_wgpuContext; private static readonly object s_initLock = new(); private static readonly Dictionary> s_compositors = new(); + internal static CompositorOptions BackendCompositorOptions { get; } = + CompositorOptions.Default with + { + InitialVertexCount = 1024, + InitialIndexCount = 1536, + InitialColorGlyphAtlasSize = 64, + GlyphUniformStagingBytes = 16 * 1024, + GlyphCoverageStagingBytes = GlyphAtlas.DefaultCoverageRingBufferSize, + EnableGpuHitTesting = false + }; private static Compositor GetCompositor(WgpuContext context, TextureFormat format) { @@ -823,7 +936,7 @@ private static Compositor GetCompositor(WgpuContext context, TextureFormat forma if (!dict.TryGetValue(format, out var compositor)) { - compositor = new Compositor(context, format); + compositor = new Compositor(context, format, BackendCompositorOptions); dict[format] = compositor; } @@ -885,16 +998,16 @@ public static unsafe void InvalidateForContext(WgpuContext context) { if (TryGetSurfacePointer(framebuffer, out var surfacePtr)) { - lock (s_initLock) + var current = WgpuContext.Current; + if (current is { IsDisposed: false } && + (IntPtr)current.Surface == surfacePtr) { - foreach (var context in WgpuContext.ActiveContexts) - { - if ((IntPtr)context.Surface == surfacePtr) - { - return context; - } - } + return current; } + + return WgpuContext.TryGetActiveContextForSurface(surfacePtr, out var context) + ? context + : null; } return null; } @@ -927,11 +1040,7 @@ private static unsafe void EnsureGpuContext(ILockedFramebuffer? framebuffer, Tex current = WgpuContext.Current; if (current == null) { - var activeContexts = WgpuContext.ActiveContexts; - if (activeContexts.Count > 0) - { - current = activeContexts[0]; - } + WgpuContext.TryGetFirstActiveContext(out current); } } @@ -1010,26 +1119,22 @@ private unsafe void FlushToFramebuffer() var (texture, readbackBuffer) = GetOffscreenResources(_offscreenCache, context, width, height, preferredFormat); var hostFrame = CreateHostFrame(width, height); - var drawingVisual = new DrawingVisual(); + var drawingVisual = new RecordedDrawingVisual(DrawingContext); drawingVisual.Size = hostFrame.LogicalSize; - drawingVisual.Context.Append(DrawingContext); - try - { - compositor.RenderOffscreen( - drawingVisual, - hostFrame, - texture, - 0.0f, - _clearColor, - loadExistingContents: false - ); - _offscreenCache.IsTextureFresh = false; - } - finally - { - drawingVisual.Context.Clear(); - } + compositor.RenderOffscreen( + drawingVisual, + hostFrame, + texture, + 0.0f, + _clearColor, + loadExistingContents: false + ); + var metrics = compositor.Metrics; + metrics.RecordedCommandCount = DrawingContext.Commands.Count; + metrics.RecordedCommandCapacity = DrawingContext.Commands.Capacity; + ProGpuRenderingDiagnostics.ReportFrame(metrics); + _offscreenCache.IsTextureFresh = false; if (TryGetSurfacePointer(_framebuffer, out var surfacePointer)) { @@ -1094,7 +1199,7 @@ public void Dispose() { if (!_preserveRecordedCommandsOnDispose) { - DrawingContext.Clear(); + ReturnRecordingContext(); } if (_disposables != null) @@ -1108,10 +1213,22 @@ public void Dispose() finally { _disposed = true; + ReturnDrawingContextState(); } } } + private void ReturnRecordingContext() + { + if (_recordingContextReturned) + { + return; + } + + _recordingContextReturned = true; + _offscreenCache.ReturnRecordingContext(DrawingContext); + } + private Vector2 TransformPoint(Point pt) { var p = pt * RenderTransform; @@ -1388,6 +1505,9 @@ private bool TryDrawImageBrush( return true; } + private static bool RequiresBrushClipPath(IBrush? brush) + => brush is ISceneBrush or ISceneBrushContent or IImageBrush; + private ProGPU.Vector.Brush? ConvertBrush(IBrush? avaloniaBrush, Avalonia.Rect? targetRect = null) { if (avaloniaBrush == null) return null; @@ -1397,8 +1517,7 @@ private bool TryDrawImageBrush( if (avaloniaBrush is ISolidColorBrush solid) { var c = solid.Color; - var vecColor = new Vector4(c.R / 255.0f, c.G / 255.0f, c.B / 255.0f, c.A / 255.0f); - return new ProGPU.Vector.SolidColorBrush(vecColor) { Opacity = opacity }; + return _offscreenCache.GetSolidBrush(c.R, c.G, c.B, c.A, opacity); } else if (avaloniaBrush is ILinearGradientBrush linear) { @@ -1481,6 +1600,21 @@ private bool TryDrawImageBrush( _ => ProGPU.Vector.PenLineCap.Flat }; + if (dashArray == null && avaloniaPen.Brush is ISolidColorBrush solid) + { + var color = solid.Color; + return _offscreenCache.GetSolidPen( + color.R, + color.G, + color.B, + color.A, + (float)avaloniaPen.Brush.Opacity, + (float)avaloniaPen.Thickness, + lineJoin, + (float)avaloniaPen.MiterLimit, + lineCap); + } + return new ProGPU.Vector.Pen( brush, (float)avaloniaPen.Thickness, @@ -1605,26 +1739,30 @@ internal static unsafe void RenderToTexture(ProGPU.Scene.DrawingContext sourceCo var compositor = GetCompositor(context, texture.Format); var hostFrame = CreateHostFrame(texture.Width, texture.Height); - var drawingVisual = new DrawingVisual(); + var drawingVisual = new RecordedDrawingVisual(sourceContext); drawingVisual.Size = hostFrame.LogicalSize; - drawingVisual.Context.Append(sourceContext); - try - { - compositor.RenderOffscreen( - drawingVisual, - hostFrame, - texture, - 0.0f, - new Vector4(0f, 0f, 0f, 0f), // Transparent clear color for layers - loadExistingContents: !isTextureFresh - ); - } - finally - { - drawingVisual.Context.Clear(); - } + compositor.RenderOffscreen( + drawingVisual, + hostFrame, + texture, + 0.0f, + new Vector4(0f, 0f, 0f, 0f), // Transparent clear color for layers + loadExistingContents: !isTextureFresh + ); + } + } + + private sealed class RecordedDrawingVisual : ProGPU.Scene.Visual, IOwnedRenderCommandCache + { + private readonly ProGPU.Scene.DrawingContext _context; + + public RecordedDrawingVisual(ProGPU.Scene.DrawingContext context) + { + _context = context; } + + public ProGPU.Scene.DrawingContext GetOrUpdateRenderCommandCache() => _context; } } } diff --git a/src/ProGPU.Avalonia.Rendering/FontManagerImpl.cs b/src/ProGPU.Avalonia.Rendering/FontManagerImpl.cs index 3ab8e5c8..f81dbd7f 100644 --- a/src/ProGPU.Avalonia.Rendering/FontManagerImpl.cs +++ b/src/ProGPU.Avalonia.Rendering/FontManagerImpl.cs @@ -13,6 +13,10 @@ namespace Avalonia.ProGpu { internal class FontManagerImpl : IFontManagerImpl { + private const long GlyphResidentFontFileSizeThreshold = 16L * 1024L * 1024L; + private const string WholeSystemFontFilesDiagnosticSwitch = + "ProGPU.Avalonia.Diagnostics.UseWholeSystemFontFiles"; + private readonly record struct FontLocation(string FilePath, int FaceIndex); private readonly record struct TypefaceRequest( @@ -30,18 +34,16 @@ private readonly record struct CharacterRequest( private sealed class CachedFont { - public CachedFont(FontLocation location, FontInfo info, TtfFont font, byte[] data) + public CachedFont(FontLocation location, FontInfo info, TtfFont font) { Location = location; Info = info; Font = font; - Data = data; } public FontLocation Location { get; } public FontInfo Info { get; } public TtfFont Font { get; } - public byte[] Data { get; } } private sealed class SystemFontCatalog @@ -104,8 +106,6 @@ private void AddName(string name, FontInfo font) private readonly Func> _systemFontProvider; private readonly IReadOnlyList _preferredFonts; private readonly Dictionary _fontCache = new(); - private readonly Dictionary _fontDataCache = - new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _failedFonts = new(); private readonly Dictionary _typefaceCache = new(); private readonly Dictionary _characterCache = new(); @@ -249,7 +249,8 @@ private bool TryMatchCharacterCore( foreach (var candidate in GetCharacterCandidates(request.FamilyName)) { - if (!TryLoadFont(candidate, out var cachedFont) || + if (!FontApi.TryGetGlyphIndex(candidate, (uint)codepoint, out ushort glyphIndex) || + !TryLoadFont(candidate, glyphIndex, out var cachedFont) || !cachedFont.Font.HasGlyph((uint)codepoint)) { continue; @@ -481,10 +482,17 @@ private IEnumerable GetCharacterCandidates(string familyName) } } - private bool TryLoadFont(FontInfo info, [NotNullWhen(true)] out CachedFont? cachedFont) + private bool TryLoadFont( + FontInfo info, + [NotNullWhen(true)] out CachedFont? cachedFont) => + TryLoadFont(info, null, out cachedFont); + + private bool TryLoadFont( + FontInfo info, + ushort? residentGlyphIndex, + [NotNullWhen(true)] out CachedFont? cachedFont) { var location = new FontLocation(info.FilePath, info.FaceIndex); - byte[]? data; lock (_sync) { if (_fontCache.TryGetValue(location, out cachedFont)) @@ -497,14 +505,18 @@ private bool TryLoadFont(FontInfo info, [NotNullWhen(true)] out CachedFont? cach cachedFont = null; return false; } - - _fontDataCache.TryGetValue(info.FilePath, out data); } try { - data ??= File.ReadAllBytes(info.FilePath); - var font = new TtfFont(data, info.FaceIndex); + bool glyphResident = ShouldUseGlyphResidentFont(info.FilePath); + TtfFont font = glyphResident + ? TtfFont.LoadGlyphResidentFile( + info.FilePath, + info.FaceIndex, + residentGlyphIndex ?? 0) + : new TtfFont(info.FilePath, info.FaceIndex); + if (!IsRenderable(font)) { lock (_sync) @@ -515,7 +527,7 @@ private bool TryLoadFont(FontInfo info, [NotNullWhen(true)] out CachedFont? cach return false; } - var created = new CachedFont(location, info, font, data); + var created = new CachedFont(location, info, font); lock (_sync) { if (_fontCache.TryGetValue(location, out cachedFont)) @@ -523,7 +535,6 @@ private bool TryLoadFont(FontInfo info, [NotNullWhen(true)] out CachedFont? cach return true; } - _fontDataCache[info.FilePath] = data; _fontCache[location] = created; cachedFont = created; return true; @@ -540,6 +551,43 @@ private bool TryLoadFont(FontInfo info, [NotNullWhen(true)] out CachedFont? cach } } + internal long CachedFontDataBytes + { + get + { + lock (_sync) + { + long total = 0; + foreach (var cached in _fontCache.Values) + { + if (!cached.Font.UsesMappedFileStorage) + { + total += cached.Font.FontData.Length; + } + } + return total; + } + } + } + + private static bool ShouldUseGlyphResidentFont(string filePath) + { + if (AppContext.TryGetSwitch(WholeSystemFontFilesDiagnosticSwitch, out bool useWholeFonts) && + useWholeFonts) + { + return false; + } + + try + { + return new FileInfo(filePath).Length > GlyphResidentFontFileSizeThreshold; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + private ProGpuTypeface GetOrCreateTypeface( CachedFont cachedFont, FontStyle requestedStyle, @@ -577,7 +625,7 @@ private ProGpuTypeface GetOrCreateTypeface( : font.FamilyName; var typeface = new ProGpuTypeface( font, - cachedFont.Data, + font.FontData, familyName, actualWeight, actualStyle, diff --git a/src/ProGPU.Avalonia.Rendering/GeometryImpl.cs b/src/ProGPU.Avalonia.Rendering/GeometryImpl.cs index b9b223a3..33ad4fa5 100644 --- a/src/ProGPU.Avalonia.Rendering/GeometryImpl.cs +++ b/src/ProGPU.Avalonia.Rendering/GeometryImpl.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Threading; using Avalonia.Media; using Avalonia.Platform; +using ProGPU.Scene; using PathGeometry = ProGPU.Vector.PathGeometry; using PathSegment = ProGPU.Vector.PathSegment; using LineSegment = ProGPU.Vector.LineSegment; @@ -16,10 +18,28 @@ namespace Avalonia.ProGpu { internal abstract class GeometryImpl : IGeometryImpl { + private RenderCommandGeometryCache? _renderCommandGeometryCache; + public abstract ProGPU.Vector.PathGeometry Path { get; } protected void InvalidateCaches() { + Volatile.Write(ref _renderCommandGeometryCache, null); + } + + internal RenderCommandGeometryCache GetRenderCommandGeometryCache() + { + var cache = Volatile.Read(ref _renderCommandGeometryCache); + if (cache != null) + { + return cache; + } + + var created = RenderCommandGeometryCache.ForPath(Path); + return Interlocked.CompareExchange( + ref _renderCommandGeometryCache, + created, + null) ?? created; } public Rect Bounds => CalculateBounds(Path); diff --git a/src/ProGPU.Avalonia.Rendering/OffscreenTextureCache.cs b/src/ProGPU.Avalonia.Rendering/OffscreenTextureCache.cs index f502d45d..053225b0 100644 --- a/src/ProGPU.Avalonia.Rendering/OffscreenTextureCache.cs +++ b/src/ProGPU.Avalonia.Rendering/OffscreenTextureCache.cs @@ -1,15 +1,35 @@ using System; +using System.Collections.Generic; +using System.Numerics; using ProGPU.Backend; +using ProGPU.Scene; +using ProGPU.Vector; namespace Avalonia.ProGpu { internal class OffscreenTextureCache : IDisposable { + private const int MaximumSolidStyleCacheEntries = 256; public GpuTexture? CachedTexture; public GpuTextureReadbackBuffer? CachedReadbackBuffer; public uint CachedWidth; public uint CachedHeight; public bool IsTextureFresh = true; + private readonly object _recordingContextLock = new(); + private readonly object _solidStyleCacheLock = new(); + private readonly Dictionary _solidBrushes = new(); + private readonly Dictionary _solidPens = new(); + private DrawingContext? _recordingContext; + private bool _disposed; + + private readonly record struct SolidBrushKey(uint Color, float Opacity); + + private readonly record struct SolidPenKey( + SolidBrushKey Brush, + float Thickness, + PenLineJoin LineJoin, + float MiterLimit, + PenLineCap LineCap); public OffscreenTextureCache() { @@ -24,6 +44,103 @@ private void OnContextDisposing(WgpuContext context) } } + public DrawingContext RentRecordingContext() + { + lock (_recordingContextLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var context = _recordingContext; + _recordingContext = null; + return context ?? new DrawingContext(); + } + } + + public void ReturnRecordingContext(DrawingContext context) + { + ArgumentNullException.ThrowIfNull(context); + context.Clear(); + + lock (_recordingContextLock) + { + if (_disposed || _recordingContext != null) + { + return; + } + + _recordingContext = context; + } + } + + public SolidColorBrush GetSolidBrush(byte red, byte green, byte blue, byte alpha, float opacity) + { + var key = new SolidBrushKey(PackColor(red, green, blue, alpha), opacity); + lock (_solidStyleCacheLock) + { + if (_solidBrushes.TryGetValue(key, out var brush)) + { + return brush; + } + + if (_solidBrushes.Count >= MaximumSolidStyleCacheEntries) + { + _solidBrushes.Clear(); + } + + brush = new SolidColorBrush(new Vector4( + red / 255.0f, + green / 255.0f, + blue / 255.0f, + alpha / 255.0f)) + { + Opacity = opacity + }; + _solidBrushes.Add(key, brush); + return brush; + } + } + + public Pen GetSolidPen( + byte red, + byte green, + byte blue, + byte alpha, + float opacity, + float thickness, + PenLineJoin lineJoin, + float miterLimit, + PenLineCap lineCap) + { + var brushKey = new SolidBrushKey(PackColor(red, green, blue, alpha), opacity); + var key = new SolidPenKey(brushKey, thickness, lineJoin, miterLimit, lineCap); + lock (_solidStyleCacheLock) + { + if (_solidPens.TryGetValue(key, out var pen)) + { + return pen; + } + + if (_solidPens.Count >= MaximumSolidStyleCacheEntries) + { + _solidPens.Clear(); + } + + var brush = GetSolidBrush(red, green, blue, alpha, opacity); + pen = new Pen( + brush, + thickness, + lineJoin, + miterLimit, + lineCap, + lineCap, + lineCap); + _solidPens.Add(key, pen); + return pen; + } + } + + private static uint PackColor(byte red, byte green, byte blue, byte alpha) => + ((uint)red << 24) | ((uint)green << 16) | ((uint)blue << 8) | alpha; + public void Invalidate(WgpuContext? context) { if (CachedTexture != null) @@ -43,6 +160,24 @@ public void Dispose() WgpuContext.Disposing -= OnContextDisposing; var context = CachedTexture?.Context ?? WgpuContext.Current; Invalidate(context); + + lock (_recordingContextLock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _recordingContext?.Clear(); + _recordingContext = null; + } + + lock (_solidStyleCacheLock) + { + _solidBrushes.Clear(); + _solidPens.Clear(); + } } } } diff --git a/src/ProGPU.Avalonia.Rendering/ProGpuRenderingDiagnostics.cs b/src/ProGPU.Avalonia.Rendering/ProGpuRenderingDiagnostics.cs new file mode 100644 index 00000000..381192b5 --- /dev/null +++ b/src/ProGPU.Avalonia.Rendering/ProGpuRenderingDiagnostics.cs @@ -0,0 +1,23 @@ +using System; +using ProGPU.Scene; + +namespace Avalonia.ProGpu +{ + /// + /// Provides opt-in per-frame diagnostics for the ProGPU Avalonia renderer. + /// + public static class ProGpuRenderingDiagnostics + { + /// + /// Raised after a top-level Avalonia frame has been rendered by ProGPU. + /// The event has no production overhead beyond a null check when there are + /// no subscribers. + /// + public static event Action? FrameRendered; + + internal static void ReportFrame(CompositorMetrics metrics) + { + FrameRendered?.Invoke(metrics); + } + } +} diff --git a/src/ProGPU.Avalonia.Rendering/ProGpuTypeface.cs b/src/ProGPU.Avalonia.Rendering/ProGpuTypeface.cs index ce5da7e0..7a6a63e7 100644 --- a/src/ProGPU.Avalonia.Rendering/ProGpuTypeface.cs +++ b/src/ProGPU.Avalonia.Rendering/ProGpuTypeface.cs @@ -14,8 +14,99 @@ internal class ProGpuTypeface : IPlatformTypeface #endif { + private sealed class FontDataStream : Stream + { + private readonly ReadOnlyMemory _data; + private int _position; + private bool _disposed; + + public FontDataStream(ReadOnlyMemory data) + { + _data = data; + } + + public override bool CanRead => !_disposed; + public override bool CanSeek => !_disposed; + public override bool CanWrite => false; + public override long Length + { + get + { + ThrowIfDisposed(); + return _data.Length; + } + } + + public override long Position + { + get + { + ThrowIfDisposed(); + return _position; + } + set + { + ThrowIfDisposed(); + if (value < 0 || value > _data.Length) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _position = checked((int)value); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + ThrowIfDisposed(); + int count = Math.Min(buffer.Length, _data.Length - _position); + _data.Span.Slice(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + long position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _data.Length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)) + }; + Position = position; + return position; + } + + public override void Flush() + { + } + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); + } + public TtfFont Font { get; } - private readonly byte[] _fontData; + private readonly ReadOnlyMemory _fontData; #if AVALONIA11 private readonly HarfBuzzSharp.Blob _shapingBlob; private readonly HarfBuzzSharp.Face _shapingFace; @@ -31,17 +122,17 @@ internal class ProGpuTypeface : public FontMetrics Metrics { get; } #endif - public ProGpuTypeface(TtfFont font, byte[] fontData, string familyName, FontWeight weight, FontStyle style, FontStretch stretch, FontSimulations fontSimulations = FontSimulations.None) + public ProGpuTypeface(TtfFont font, ReadOnlyMemory fontData, string familyName, FontWeight weight, FontStyle style, FontStretch stretch, FontSimulations fontSimulations = FontSimulations.None) { Font = font ?? throw new ArgumentNullException(nameof(font)); - _fontData = fontData ?? throw new ArgumentNullException(nameof(fontData)); + _fontData = fontData; FamilyName = familyName; Weight = weight; Style = style; Stretch = stretch; FontSimulations = fontSimulations; #if AVALONIA11 - _shapingBlob = HarfBuzzSharp.Blob.FromStream(new MemoryStream(_fontData, writable: false)); + _shapingBlob = HarfBuzzSharp.Blob.FromStream(new FontDataStream(_fontData)); _shapingFace = new HarfBuzzSharp.Face(_shapingBlob, font.FaceIndex); ShapingFont = new HarfBuzzSharp.Font(_shapingFace); ShapingFont.SetFunctionsOpenType(); @@ -168,7 +259,7 @@ public bool TryGetStream([NotNullWhen(true)] out Stream? stream) { try { - stream = new MemoryStream(_fontData); + stream = new FontDataStream(_fontData); return true; } catch diff --git a/src/ProGPU.Avalonia.Rendering/StreamGeometryImpl.cs b/src/ProGPU.Avalonia.Rendering/StreamGeometryImpl.cs index 5259bda6..bae9112d 100644 --- a/src/ProGPU.Avalonia.Rendering/StreamGeometryImpl.cs +++ b/src/ProGPU.Avalonia.Rendering/StreamGeometryImpl.cs @@ -96,11 +96,13 @@ private class StreamContext : IStreamGeometryContextImpl public StreamContext(StreamGeometryImpl geometryImpl) { _geometryImpl = geometryImpl; + _geometryImpl.InvalidateCaches(); _geometryImpl.Path.Figures.Clear(); } public void BeginFigure(Point startPoint, bool isFilled = true) { + _geometryImpl.InvalidateCaches(); _currentFigure = new ProGPU.Vector.PathFigure(new Vector2((float)startPoint.X, (float)startPoint.Y)) { IsFilled = isFilled @@ -112,12 +114,14 @@ public void EndFigure(bool isClosed) { if (_currentFigure != null) { + _geometryImpl.InvalidateCaches(); _currentFigure.IsClosed = isClosed; } } public void SetFillRule(Avalonia.Media.FillRule fillRule) { + _geometryImpl.InvalidateCaches(); _geometryImpl.Path.FillRule = fillRule == Avalonia.Media.FillRule.EvenOdd ? ProGPU.Vector.FillRule.EvenOdd : ProGPU.Vector.FillRule.Nonzero; @@ -126,6 +130,7 @@ public void SetFillRule(Avalonia.Media.FillRule fillRule) public void LineTo(Point point, bool isStroked = true) { if (_currentFigure == null) return; + _geometryImpl.InvalidateCaches(); _currentFigure.Segments.Add(new LineSegment( new Vector2((float)point.X, (float)point.Y), isStroked: isStroked)); @@ -152,6 +157,7 @@ private Point CurrentPoint public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, Avalonia.Media.SweepDirection sweepDirection, bool isStroked = true) { if (_currentFigure == null) return; + _geometryImpl.InvalidateCaches(); var endPoint = new Vector2((float)point.X, (float)point.Y); var radii = new Vector2((float)size.Width, (float)size.Height); var direction = sweepDirection == Avalonia.Media.SweepDirection.Clockwise @@ -175,6 +181,7 @@ public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, public void CubicBezierTo(Point point1, Point point2, Point point3, bool isStroked = true) { if (_currentFigure == null) return; + _geometryImpl.InvalidateCaches(); _currentFigure.Segments.Add(new CubicBezierSegment( new Vector2((float)point1.X, (float)point1.Y), new Vector2((float)point2.X, (float)point2.Y), @@ -191,6 +198,7 @@ public void CubicBezierTo(Point point1, Point point2, Point point3) => public void QuadraticBezierTo(Point point1, Point point2, bool isStroked = true) { if (_currentFigure == null) return; + _geometryImpl.InvalidateCaches(); _currentFigure.Segments.Add(new QuadraticBezierSegment( new Vector2((float)point1.X, (float)point1.Y), new Vector2((float)point2.X, (float)point2.Y), diff --git a/src/ProGPU.Avalonia.SilkNet/SilkNetPlatform.cs b/src/ProGPU.Avalonia.SilkNet/SilkNetPlatform.cs index 4770959f..51ef367a 100644 --- a/src/ProGPU.Avalonia.SilkNet/SilkNetPlatform.cs +++ b/src/ProGPU.Avalonia.SilkNet/SilkNetPlatform.cs @@ -2,6 +2,7 @@ using System.Buffers.Binary; using System.Collections.Generic; using System.IO; +using System.Threading; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Platform; @@ -18,6 +19,7 @@ public class SilkNetPlatform : IWindowingPlatform, IPlatformIconLoader public static SilkNetPlatform Instance => s_instance; private readonly List _windows = new(); + private WindowImpl[] _windowSnapshot = Array.Empty(); private SilkNetDispatcherImpl? _dispatcher; private static Compositor? s_compositor; @@ -63,6 +65,7 @@ public void RegisterWindow(WindowImpl window) lock (_windows) { _windows.Add(window); + _windowSnapshot = _windows.ToArray(); } } @@ -71,18 +74,15 @@ public void UnregisterWindow(WindowImpl window) lock (_windows) { _windows.Remove(window); + _windowSnapshot = _windows.ToArray(); } } public void DoEvents() { - WindowImpl[] windowsToProcess; - lock (_windows) - { - windowsToProcess = _windows.ToArray(); - } - - foreach (var win in windowsToProcess) + // Registration is rare; event pumping is continuous. Publish an immutable + // copy-on-write snapshot so the steady-state pump is O(W) with no allocation. + foreach (var win in Volatile.Read(ref _windowSnapshot)) { if (!win.IsDisposed && win.SilkWindow != null && win.SilkWindow.IsInitialized) { diff --git a/src/ProGPU.Backend/GpuFramebufferPresentationRegistry.cs b/src/ProGPU.Backend/GpuFramebufferPresentationRegistry.cs index 0fca70f1..7e3db556 100644 --- a/src/ProGPU.Backend/GpuFramebufferPresentationRegistry.cs +++ b/src/ProGPU.Backend/GpuFramebufferPresentationRegistry.cs @@ -90,16 +90,9 @@ public static bool TryPresent(IntPtr framebufferAddress, IntPtr surfaceHandle) return current; } - var activeContexts = WgpuContext.ActiveContexts; - for (var index = 0; index < activeContexts.Count; index++) - { - if (MatchesSurface(activeContexts[index], surfaceHandle)) - { - return activeContexts[index]; - } - } - - return null; + return WgpuContext.TryGetActiveContextForSurface(surfaceHandle, out var context) + ? context + : null; } private static unsafe bool MatchesSurface(WgpuContext? context, IntPtr surfaceHandle) diff --git a/src/ProGPU.Backend/RenderPipelineCache.cs b/src/ProGPU.Backend/RenderPipelineCache.cs index 48725222..4e1082c3 100644 --- a/src/ProGPU.Backend/RenderPipelineCache.cs +++ b/src/ProGPU.Backend/RenderPipelineCache.cs @@ -53,6 +53,12 @@ public RenderPipelineCache(WgpuContext context) _context = context; } + public int ShaderCount => _shaders.Count; + + public int RenderPipelineCount => _renderPipelines.Count; + + public int ComputePipelineCount => _computePipelines.Count; + public ShaderModule* GetOrCreateShader(string key, string wgslCode, string label = "ShaderModule") { if (_isDisposed) throw new ObjectDisposedException(nameof(RenderPipelineCache)); diff --git a/src/ProGPU.Backend/WgpuContext.cs b/src/ProGPU.Backend/WgpuContext.cs index 2313ac4c..21fc0f3e 100644 --- a/src/ProGPU.Backend/WgpuContext.cs +++ b/src/ProGPU.Backend/WgpuContext.cs @@ -452,6 +452,31 @@ public static bool TryGetFirstActiveContext([NotNullWhen(true)] out WgpuContext? return false; } + public static unsafe bool TryGetActiveContextForSurface( + IntPtr surfaceHandle, + [NotNullWhen(true)] out WgpuContext? context) + { + if (surfaceHandle != IntPtr.Zero) + { + lock (_activeContexts) + { + for (var i = 0; i < _activeContexts.Count; i++) + { + var active = _activeContexts[i]; + if (!active.IsDisposed && + (IntPtr)active.Surface == surfaceHandle) + { + context = active; + return true; + } + } + } + } + + context = null; + return false; + } + [ThreadStatic] private static WgpuContext? _current; diff --git a/src/ProGPU.Compute/ComputeAccelerator.cs b/src/ProGPU.Compute/ComputeAccelerator.cs index c1bc95e0..6f6ee90b 100644 --- a/src/ProGPU.Compute/ComputeAccelerator.cs +++ b/src/ProGPU.Compute/ComputeAccelerator.cs @@ -437,21 +437,62 @@ public ComputeAccelerator(WgpuContext context) { _context = context; _cache = new RenderPipelineCache(_context); - - InitializePipelines(); - InitializeBlurResources(); } - private void InitializePipelines() + public int CachedEffectShaderCount => _cache.ShaderCount; + + public int CachedEffectPipelineCount => _cache.ComputePipelineCount; + + public ulong PersistentEffectParameterBufferBytes => + (_blurHorizontalParams?.AllocatedSize ?? 0u) + + (_blurVerticalParams?.AllocatedSize ?? 0u) + + (_shadowParams?.AllocatedSize ?? 0u) + + (_combinedBlurParams?.AllocatedSize ?? 0u); + + private void EnsureGaussianBlurResources() { + if (_blurHorizPipeline != null) + { + return; + } + var shBlurH = _cache.GetOrCreateShader("BlurH", ComputeShaders.GaussianBlurHorizontal, "BlurHShader"); _blurHorizPipeline = _cache.GetOrCreateComputePipeline("BlurH", shBlurH); var shBlurV = _cache.GetOrCreateShader("BlurV", ComputeShaders.GaussianBlurVertical, "BlurVShader"); _blurVertPipeline = _cache.GetOrCreateComputePipeline("BlurV", shBlurV); + _blurHorizLayout = _context.Api.ComputePipelineGetBindGroupLayout(_blurHorizPipeline, 0); + _blurVertLayout = _context.Api.ComputePipelineGetBindGroupLayout(_blurVertPipeline, 0); + _blurHorizontalParams = new GpuBuffer( + _context, + (uint)Marshal.SizeOf(), + BufferUsage.Uniform | BufferUsage.CopyDst, + "Gaussian Blur Horizontal Params"); + _blurVerticalParams = new GpuBuffer( + _context, + (uint)Marshal.SizeOf(), + BufferUsage.Uniform | BufferUsage.CopyDst, + "Gaussian Blur Vertical Params"); + } + + private void EnsureSharpShadowPipeline() + { + if (_shadowPipeline != null) + { + return; + } + var shShadow = _cache.GetOrCreateShader("Shadow", ComputeShaders.DropShadow, "ShadowShader"); _shadowPipeline = _cache.GetOrCreateComputePipeline("Shadow", shShadow); + } + + private void EnsureShadowBlurResources() + { + if (_shadowBlurHorizPipeline != null) + { + return; + } var shShadowBlurH = _cache.GetOrCreateShader("ShadowBlurH", ComputeShaders.ShadowBlurHorizontal, "ShadowBlurHShader"); _shadowBlurHorizPipeline = _cache.GetOrCreateComputePipeline("ShadowBlurH", shShadowBlurH); @@ -459,14 +500,57 @@ private void InitializePipelines() var shShadowBlurV = _cache.GetOrCreateShader("ShadowBlurV", ComputeShaders.ShadowBlurVertical, "ShadowBlurVShader"); _shadowBlurVertPipeline = _cache.GetOrCreateComputePipeline("ShadowBlurV", shShadowBlurV); + _shadowBlurHorizLayout = _context.Api.ComputePipelineGetBindGroupLayout(_shadowBlurHorizPipeline, 0); + _shadowBlurVertLayout = _context.Api.ComputePipelineGetBindGroupLayout(_shadowBlurVertPipeline, 0); + _shadowParams = new GpuBuffer( + _context, + (uint)Marshal.SizeOf(), + BufferUsage.Uniform | BufferUsage.CopyDst, + "Shadow Params Buffer"); + } + + private ComputePipeline* GetOrCreateMorphologyPipeline() + { + if (_morphologyPipeline != null) + { + return _morphologyPipeline; + } + var morphologyShader = _cache.GetOrCreateShader("Morphology", ComputeShaders.Morphology, "MorphologyShader"); _morphologyPipeline = _cache.GetOrCreateComputePipeline("Morphology", morphologyShader); + return _morphologyPipeline; + } + + private ComputePipeline* GetOrCreateImageBlendPipeline() + { + if (_imageBlendPipeline != null) + { + return _imageBlendPipeline; + } var imageBlendShader = _cache.GetOrCreateShader("ImageBlend", ComputeShaders.ImageBlend, "ImageBlendShader"); _imageBlendPipeline = _cache.GetOrCreateComputePipeline("ImageBlend", imageBlendShader); + return _imageBlendPipeline; + } + + private ComputePipeline* GetOrCreateColorTablePipeline() + { + if (_colorTablePipeline != null) + { + return _colorTablePipeline; + } var colorTableShader = _cache.GetOrCreateShader("ColorTable", ComputeShaders.ColorTable, "ColorTableShader"); _colorTablePipeline = _cache.GetOrCreateComputePipeline("ColorTable", colorTableShader); + return _colorTablePipeline; + } + + private ComputePipeline* GetOrCreateArithmeticCompositePipeline() + { + if (_arithmeticCompositePipeline != null) + { + return _arithmeticCompositePipeline; + } var arithmeticCompositeShader = _cache.GetOrCreateShader( "ArithmeticComposite", @@ -475,6 +559,15 @@ private void InitializePipelines() _arithmeticCompositePipeline = _cache.GetOrCreateComputePipeline( "ArithmeticComposite", arithmeticCompositeShader); + return _arithmeticCompositePipeline; + } + + private ComputePipeline* GetOrCreateDisplacementMapPipeline() + { + if (_displacementMapPipeline != null) + { + return _displacementMapPipeline; + } var displacementMapShader = _cache.GetOrCreateShader( "DisplacementMap", @@ -483,6 +576,15 @@ private void InitializePipelines() _displacementMapPipeline = _cache.GetOrCreateComputePipeline( "DisplacementMap", displacementMapShader); + return _displacementMapPipeline; + } + + private ComputePipeline* GetOrCreateMatrixConvolutionPipeline() + { + if (_matrixConvolutionPipeline != null) + { + return _matrixConvolutionPipeline; + } var matrixConvolutionShader = _cache.GetOrCreateShader( "MatrixConvolution", @@ -491,6 +593,15 @@ private void InitializePipelines() _matrixConvolutionPipeline = _cache.GetOrCreateComputePipeline( "MatrixConvolution", matrixConvolutionShader); + return _matrixConvolutionPipeline; + } + + private ComputePipeline* GetOrCreateImageLightingPipeline() + { + if (_imageLightingPipeline != null) + { + return _imageLightingPipeline; + } var imageLightingShader = _cache.GetOrCreateShader( "ImageLighting", @@ -499,30 +610,7 @@ private void InitializePipelines() _imageLightingPipeline = _cache.GetOrCreateComputePipeline( "ImageLighting", imageLightingShader); - } - - private void InitializeBlurResources() - { - _blurHorizLayout = _context.Api.ComputePipelineGetBindGroupLayout(_blurHorizPipeline, 0); - _blurVertLayout = _context.Api.ComputePipelineGetBindGroupLayout(_blurVertPipeline, 0); - _shadowBlurHorizLayout = _context.Api.ComputePipelineGetBindGroupLayout(_shadowBlurHorizPipeline, 0); - _shadowBlurVertLayout = _context.Api.ComputePipelineGetBindGroupLayout(_shadowBlurVertPipeline, 0); - - _blurHorizontalParams = new GpuBuffer( - _context, - (uint)Marshal.SizeOf(), - BufferUsage.Uniform | BufferUsage.CopyDst, - "Gaussian Blur Horizontal Params"); - _blurVerticalParams = new GpuBuffer( - _context, - (uint)Marshal.SizeOf(), - BufferUsage.Uniform | BufferUsage.CopyDst, - "Gaussian Blur Vertical Params"); - _shadowParams = new GpuBuffer( - _context, - (uint)Marshal.SizeOf(), - BufferUsage.Uniform | BufferUsage.CopyDst, - "Shadow Params Buffer"); + return _imageLightingPipeline; } private void EnsureCombinedBlurResources(uint width, uint height) @@ -581,6 +669,7 @@ public void ApplyGaussianBlur( float sigmaY) { if (_isDisposed) throw new ObjectDisposedException(nameof(ComputeAccelerator)); + EnsureGaussianBlurResources(); uint width = source.Width; uint height = source.Height; @@ -654,6 +743,7 @@ public void ApplyMorphology( destination.CopyFrom(source); return; } + GetOrCreateMorphologyPipeline(); var width = source.Width; var height = source.Height; @@ -728,6 +818,7 @@ public void ApplyImageLighting( bool specular) { if (_isDisposed) throw new ObjectDisposedException(nameof(ComputeAccelerator)); + GetOrCreateImageLightingPipeline(); var width = source.Width; var height = source.Height; @@ -829,6 +920,7 @@ public void ApplyMatrixConvolution( { throw new ArgumentException("The convolution kernel does not match its declared dimensions.", nameof(kernel)); } + GetOrCreateMatrixConvolutionPipeline(); var width = source.Width; var height = source.Height; @@ -941,6 +1033,7 @@ public void ApplyDisplacementMap( uint yChannel) { if (_isDisposed) throw new ObjectDisposedException(nameof(ComputeAccelerator)); + GetOrCreateDisplacementMapPipeline(); var width = Math.Max(destination.Width, source.Width); var height = Math.Max(destination.Height, source.Height); destination.Resize(width, height); @@ -1110,6 +1203,7 @@ public void ApplyArithmeticComposite( bool enforcePremultipliedColor) { if (_isDisposed) throw new ObjectDisposedException(nameof(ComputeAccelerator)); + GetOrCreateArithmeticCompositePipeline(); var width = Math.Max(destination.Width, Math.Max(background.Width, foreground.Width)); var height = Math.Max(destination.Height, Math.Max(background.Height, foreground.Height)); destination.Resize(width, height); @@ -1186,6 +1280,7 @@ public void ApplyImageBlend( bool linearRgb) { if (_isDisposed) throw new ObjectDisposedException(nameof(ComputeAccelerator)); + GetOrCreateImageBlendPipeline(); var width = Math.Max(destination.Width, Math.Max(background.Width, foreground.Width)); var height = Math.Max(destination.Height, Math.Max(background.Height, foreground.Height)); destination.Resize(width, height); @@ -1262,6 +1357,7 @@ public void ApplyColorTable( { throw new ArgumentException("Color filter tables must contain 256 entries."); } + GetOrCreateColorTablePipeline(); var width = source.Width; var height = source.Height; @@ -1620,6 +1716,7 @@ private void RunShadowPass( private void RunSharpDropShadow(GpuTexture source, GpuTexture destination, Vector2 offset, Vector4 shadowColor, float blurRadius) { + EnsureSharpShadowPipeline(); var paramsBuffer = new GpuBuffer( _context, (uint)Marshal.SizeOf(), @@ -1690,6 +1787,7 @@ public void ApplyDropShadow(GpuTexture source, GpuTexture temp, GpuTexture desti RunSharpDropShadow(source, destination, offset, shadowColor, snappedBlurRadius); return; } + EnsureShadowBlurResources(); var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Compute Shadow Encoder") }; var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDesc); @@ -1769,6 +1867,8 @@ public void ApplyDropShadowAndGaussianBlur( height); return; } + EnsureShadowBlurResources(); + EnsureGaussianBlurResources(); _shadowParams!.WriteSingle(new ShadowParams(shadowOffset, shadowColor, snappedShadowRadius)); _blurHorizontalParams!.WriteSingle(new GaussianBlurParams(gaussianSigma)); @@ -1865,10 +1965,16 @@ public void Dispose() if (!_context.IsDisposed) { - _context.Api.BindGroupLayoutRelease(_blurHorizLayout); - _context.Api.BindGroupLayoutRelease(_blurVertLayout); - _context.Api.BindGroupLayoutRelease(_shadowBlurHorizLayout); - _context.Api.BindGroupLayoutRelease(_shadowBlurVertLayout); + if (_blurHorizLayout != null) + { + _context.Api.BindGroupLayoutRelease(_blurHorizLayout); + _context.Api.BindGroupLayoutRelease(_blurVertLayout); + } + if (_shadowBlurHorizLayout != null) + { + _context.Api.BindGroupLayoutRelease(_shadowBlurHorizLayout); + _context.Api.BindGroupLayoutRelease(_shadowBlurVertLayout); + } if (_combinedBlurHorizLayout != null) { _context.Api.BindGroupLayoutRelease(_combinedBlurHorizLayout); diff --git a/src/ProGPU.Fonts.Inter/InterFontFamily.cs b/src/ProGPU.Fonts.Inter/InterFontFamily.cs index 70ccfc92..971f5ffa 100644 --- a/src/ProGPU.Fonts.Inter/InterFontFamily.cs +++ b/src/ProGPU.Fonts.Inter/InterFontFamily.cs @@ -165,12 +165,6 @@ private static Lazy VariableFace(string fileName) => () => Load(ResourcePrefix + fileName), LazyThreadSafetyMode.ExecutionAndPublication); - private static TtfFont Load(string resourceName) - { - using var stream = typeof(InterFontFamily).Assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"The embedded Inter font '{resourceName}' is missing."); - var data = new byte[checked((int)stream.Length)]; - stream.ReadExactly(data); - return new TtfFont(data); - } + private static TtfFont Load(string resourceName) => + TtfFont.LoadEmbeddedResource(typeof(InterFontFamily).Assembly, resourceName); } diff --git a/src/ProGPU.Fonts.Noto/NotoFontFamily.cs b/src/ProGPU.Fonts.Noto/NotoFontFamily.cs index 0c3adf51..994c6743 100644 --- a/src/ProGPU.Fonts.Noto/NotoFontFamily.cs +++ b/src/ProGPU.Fonts.Noto/NotoFontFamily.cs @@ -42,12 +42,6 @@ public static void RegisterFallbacks(FontManager? manager = null) private static Lazy CreateLazy(string resourceName) => new(() => Load(resourceName), LazyThreadSafetyMode.ExecutionAndPublication); - private static TtfFont Load(string resourceName) - { - using var stream = typeof(NotoFontFamily).Assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"The embedded Noto font '{resourceName}' is missing."); - var data = new byte[checked((int)stream.Length)]; - stream.ReadExactly(data); - return new TtfFont(data); - } + private static TtfFont Load(string resourceName) => + TtfFont.LoadEmbeddedResource(typeof(NotoFontFamily).Assembly, resourceName); } diff --git a/src/ProGPU.Samples/Windows/MainWindowController.cs b/src/ProGPU.Samples/Windows/MainWindowController.cs index d83a990c..429104b3 100644 --- a/src/ProGPU.Samples/Windows/MainWindowController.cs +++ b/src/ProGPU.Samples/Windows/MainWindowController.cs @@ -635,7 +635,10 @@ internal static void EnsureEffectResources() return; } - AppState._offscreenCompositor ??= new Compositor(context, TextureFormat.Rgba8Unorm) + AppState._offscreenCompositor ??= new Compositor( + context, + TextureFormat.Rgba8Unorm, + CompositorOptions.Default with { EnableGpuHitTesting = false }) { VectorEngine = AppState.VectorEngine }; diff --git a/src/ProGPU.Scene/Compositor.cs b/src/ProGPU.Scene/Compositor.cs index 40c2d3bb..90c8b367 100644 --- a/src/ProGPU.Scene/Compositor.cs +++ b/src/ProGPU.Scene/Compositor.cs @@ -114,32 +114,59 @@ public struct CompositorMetrics public double GpuUploadTimeMs; public double RenderPassTimeMs; public int DrawCallsCount; + public int RecordedCommandCount; + public int RecordedCommandCapacity; public int VectorVerticesCount; public int TextVerticesCount; public int PathAtlasCachedCount; public long PathAtlasCpuCacheBytes; public ulong GlyphAtlasTextureBytes; public ulong ColorGlyphAtlasTextureBytes; + public ulong GlyphUniformStagingBytes; + public ulong GlyphUniformUploadBytes; public ulong GlyphCoverageStagingBytes; public ulong GlyphOutlineGpuBytes; public ulong PathAtlasTextureBytes; + public ulong SceneBufferBytes; + public ulong BrushStorageBufferBytes; + public ulong GradientStopStorageBufferBytes; + public ulong EffectParameterBufferBytes; public uint PathRasterStagingBytes; public uint PathPeakRasterStagingBytes; + public uint PathPeakRasterWidth; + public uint PathPeakRasterHeight; public uint GlyphAtlasSize; public uint GlyphAtlasMaximumSize; public uint ColorGlyphAtlasSize; public uint ColorGlyphAtlasMaximumSize; public uint PathAtlasSize; + public uint PathAtlasWidth; + public uint PathAtlasHeight; public uint PathAtlasMaximumSize; public int GlyphOutlineCompiledCount; public int GlyphOutlineRecordCapacity; + public int GlyphOutlineSegmentCount; + public int GlyphOutlineSegmentCapacity; public ulong GlyphOutlineUploadWrites; public ulong GlyphUniformUploadWrites; public ulong GlyphRasterBatchSubmissions; public ulong GlyphRasterBindGroupCreations; public ulong GlyphRasterComputePasses; public int GlyphLastBatchNewGlyphCount; + public int ActiveBrushCount; + public int ActiveGradientStopCount; + public int EffectShaderCount; + public int EffectPipelineCount; + public int SceneShaderCount; + public int SceneRenderPipelineCount; + public int SceneComputePipelineCount; + public int BaseBindGroupLayoutCount; + public int BasePipelineLayoutCount; + public int BaseBindGroupCount; + public int PersistentTextureBindGroupCount; + public int MaskBindGroupCount; public bool SceneCacheHit; + public bool GpuHitTestingEnabled; public string? SceneCacheMissReason; } @@ -229,12 +256,13 @@ private sealed class PathAtlasCapacityExceededException : InvalidOperationExcept public PathAtlasCapacityExceededException(PathAtlas atlas) : base( $"PathAtlas could not preserve valid coordinates while compiling the frame. " + - $"The live path set does not fit in the configured {atlas.AtlasSize}x{atlas.AtlasSize} atlas " + + $"The live path set does not fit in the configured {atlas.AtlasWidth}x{atlas.AtlasHeight} atlas " + $"after a reset ({atlas.CachedPathCount} cached paths).") { } } + internal const int MaxBrushes = 8192; internal const int MaxGradientStops = 65536; private const int PerlinNoiseTableEntryCount = 512; private const int MaxCachedPerlinNoiseTables = 16; @@ -788,9 +816,30 @@ private void EndExtensionFrame(bool ownsFrame) private RenderPipeline* _solidRoundedPipelineOffscreen; private RenderPipeline* _textPipelineOffscreen; private RenderPipeline* _texturePipelineOffscreen; + private readonly Dictionary _selectedPipelines = new(); + private readonly Dictionary _roundedRectanglePaths = new(); private BindGroupLayout* _textureBindGroupLayout; private BindGroupLayout* _textureBindGroupLayoutOffscreen; + private readonly record struct PipelineSelectionKey( + DrawCallType Type, + byte VectorKind, + GpuBlendMode BlendMode, + bool IsOffscreen, + TextureFormat? OverrideFormat, + GpuTextureAlphaMode TextureAlphaMode, + bool HasMask); + + private readonly record struct RoundedRectanglePathKey( + float X, + float Y, + float Width, + float Height, + float RadiusX, + float RadiusY); + + private const int MaximumRoundedRectanglePathCacheEntries = 256; + // High performance Chart GPGPU pipelines private RenderPipeline* _chartLinePipeline; private RenderPipeline* _chartScatterPipeline; @@ -820,7 +869,6 @@ private void EndExtensionFrame(bool ownsFrame) private readonly List _maskTexturePool = new(); private SmallValueStack _maskStack; private readonly Dictionary _maskBindGroups = new(); - private readonly Dictionary _maskBindGroupsOffscreen = new(); private PipelineLayout* _vectorPipelineLayout; private PipelineLayout* _textPipelineLayout; @@ -959,8 +1007,8 @@ public CachedBindGroup(nint bindGroupPtr, ulong lastUsedFrame) private readonly List _activeBrushes = new(); private readonly List _activeGradientStops = new(); private readonly Dictionary _perlinNoiseTableCache = new(); - private readonly GpuBuffer _brushesStorageBuffer; - private readonly GpuBuffer _gradientStopsStorageBuffer; + private GpuBuffer _brushesStorageBuffer; + private GpuBuffer _gradientStopsStorageBuffer; private ulong _frameNumber = 0; private bool _compiledSceneReusable; private string _compiledSceneCacheStateReason = "No compiled scene"; @@ -1142,6 +1190,15 @@ public int TextureDrawCallCount public PathAtlas PathAtlas => _pathAtlas; public TextureFormat RenderFormat { get; private set; } public CompositorOptions Options { get; } + public ulong PersistentSceneBufferBytes => + _uniformBuffer.AllocatedSize + + _brushesStorageBuffer.AllocatedSize + + _gradientStopsStorageBuffer.AllocatedSize + + _vectorVertexBuffer.AllocatedSize + + _vectorIndexBuffer.AllocatedSize + + _textVertexBuffer.AllocatedSize + + _textureVertexBuffer.AllocatedSize + + _textureIndexBuffer.AllocatedSize; private Vector4 ResolveDrawCallBrushColor(Brush? brush) { @@ -1175,7 +1232,10 @@ public Compositor(WgpuContext context, TextureFormat? renderFormat, CompositorOp _atlas = new GlyphAtlas( _context, options.GlyphAtlasSize, + Math.Min(options.InitialGlyphAtlasSize, options.GlyphAtlasSize), options.ColorGlyphAtlasSize, + Math.Min(options.InitialColorGlyphAtlasSize, options.ColorGlyphAtlasSize), + options.GlyphUniformStagingBytes, options.GlyphCoverageStagingBytes); _pathAtlas = new PathAtlas( _context, @@ -1194,13 +1254,13 @@ public Compositor(WgpuContext context, TextureFormat? renderFormat, CompositorOp // Allocate brushes storage buffer for the fixed GPU brush ABI. _brushesStorageBuffer = new GpuBuffer( _context, - 8192u * (uint)Marshal.SizeOf(), + options.InitialBrushCount * (uint)Marshal.SizeOf(), BufferUsage.Storage | BufferUsage.CopyDst, "Compositor Brushes Storage Buffer" ); _gradientStopsStorageBuffer = new GpuBuffer( _context, - (uint)MaxGradientStops * (uint)Marshal.SizeOf(), + options.InitialGradientStopCount * (uint)Marshal.SizeOf(), BufferUsage.Storage | BufferUsage.CopyDst, "Compositor Gradient Stops Storage Buffer" ); @@ -1519,31 +1579,26 @@ private void InitializePipelinesAndBindGroups() mipmapSamplerDesc.LodMaxClamp = 32f; _mipmapTextureSampler = _context.Api.DeviceCreateSampler(_context.Device, &mipmapSamplerDesc); - // 5. Compile WGSL shaders - var vecShaderModule = _pipelineCache.GetOrCreateShader("Vector", Shaders.VectorShader, "VectorShader"); - var textShaderModule = _pipelineCache.GetOrCreateShader("Text", Shaders.TextShader, "TextShader"); - var texShaderModule = _pipelineCache.GetOrCreateShader("Texture", Shaders.TextureShader, "TextureShader"); - var chartLineShaderModule = _pipelineCache.GetOrCreateShader("ChartLine", Shaders.ChartLineShader, "ChartLineShader"); - var chartScatterShaderModule = _pipelineCache.GetOrCreateShader("ChartScatter", Shaders.ChartScatterShader, "ChartScatterShader"); - // Create explicit BindGroupLayouts _vectorUniformBindGroupLayout = CreateVectorUniformLayout(); - _vectorUniformBindGroupLayoutOffscreen = CreateVectorUniformLayout(); _textUniformBindGroupLayout = CreateUniformOnlyLayout(); - _textUniformBindGroupLayoutOffscreen = CreateUniformOnlyLayout(); _textureUniformBindGroupLayout = CreateUniformOnlyLayout(); - _textureUniformBindGroupLayoutOffscreen = CreateUniformOnlyLayout(); - _pathAtlasBindGroupLayout = CreateSamplerTextureLayout(); - _pathAtlasBindGroupLayoutOffscreen = CreateSamplerTextureLayout(); _atlasBindGroupLayout = CreateTextAtlasLayout(); - _atlasBindGroupLayoutOffscreen = CreateTextAtlasLayout(); _textureBindGroupLayout = CreateSamplerTextureLayout(); - _textureBindGroupLayoutOffscreen = CreateSamplerTextureLayout(); _maskBindGroupLayout = CreateSamplerTextureLayout(); - _maskBindGroupLayoutOffscreen = CreateSamplerTextureLayout(); _retainedGlyphBindGroupLayout = CreateRetainedGlyphLayout(); + // Target sample count belongs to the render pipeline. Both target families use + // the same device resources and explicit binding ABI, so these objects are shared. + _vectorUniformBindGroupLayoutOffscreen = _vectorUniformBindGroupLayout; + _textUniformBindGroupLayoutOffscreen = _textUniformBindGroupLayout; + _textureUniformBindGroupLayoutOffscreen = _textureUniformBindGroupLayout; + _pathAtlasBindGroupLayoutOffscreen = _pathAtlasBindGroupLayout; + _atlasBindGroupLayoutOffscreen = _atlasBindGroupLayout; + _textureBindGroupLayoutOffscreen = _textureBindGroupLayout; + _maskBindGroupLayoutOffscreen = _maskBindGroupLayout; + // Create explicit PipelineLayouts to share layouts across dynamic blend pipelines var vectorLayouts = stackalloc BindGroupLayout*[3]; vectorLayouts[0] = _vectorUniformBindGroupLayout; @@ -1556,16 +1611,7 @@ private void InitializePipelinesAndBindGroups() }; _vectorPipelineLayout = _context.Api.DeviceCreatePipelineLayout(_context.Device, &vectorPipelineLayoutDesc); - var vectorLayoutsOffscreen = stackalloc BindGroupLayout*[3]; - vectorLayoutsOffscreen[0] = _vectorUniformBindGroupLayoutOffscreen; - vectorLayoutsOffscreen[1] = _pathAtlasBindGroupLayoutOffscreen; - vectorLayoutsOffscreen[2] = _maskBindGroupLayoutOffscreen; - var vectorPipelineLayoutDescOffscreen = new PipelineLayoutDescriptor - { - BindGroupLayoutCount = 3, - BindGroupLayouts = vectorLayoutsOffscreen - }; - _vectorPipelineLayoutOffscreen = _context.Api.DeviceCreatePipelineLayout(_context.Device, &vectorPipelineLayoutDescOffscreen); + _vectorPipelineLayoutOffscreen = _vectorPipelineLayout; var textLayouts = stackalloc BindGroupLayout*[3]; textLayouts[0] = _textUniformBindGroupLayout; @@ -1578,16 +1624,7 @@ private void InitializePipelinesAndBindGroups() }; _textPipelineLayout = _context.Api.DeviceCreatePipelineLayout(_context.Device, &textPipelineLayoutDesc); - var textLayoutsOffscreen = stackalloc BindGroupLayout*[3]; - textLayoutsOffscreen[0] = _textUniformBindGroupLayoutOffscreen; - textLayoutsOffscreen[1] = _atlasBindGroupLayoutOffscreen; - textLayoutsOffscreen[2] = _maskBindGroupLayoutOffscreen; - var textPipelineLayoutDescOffscreen = new PipelineLayoutDescriptor - { - BindGroupLayoutCount = 3, - BindGroupLayouts = textLayoutsOffscreen - }; - _textPipelineLayoutOffscreen = _context.Api.DeviceCreatePipelineLayout(_context.Device, &textPipelineLayoutDescOffscreen); + _textPipelineLayoutOffscreen = _textPipelineLayout; var textureLayouts = stackalloc BindGroupLayout*[3]; textureLayouts[0] = _textureUniformBindGroupLayout; @@ -1600,16 +1637,7 @@ private void InitializePipelinesAndBindGroups() }; _texturePipelineLayout = _context.Api.DeviceCreatePipelineLayout(_context.Device, &texturePipelineLayoutDesc); - var textureLayoutsOffscreen = stackalloc BindGroupLayout*[3]; - textureLayoutsOffscreen[0] = _textureUniformBindGroupLayoutOffscreen; - textureLayoutsOffscreen[1] = _textureBindGroupLayoutOffscreen; - textureLayoutsOffscreen[2] = _maskBindGroupLayoutOffscreen; - var texturePipelineLayoutDescOffscreen = new PipelineLayoutDescriptor - { - BindGroupLayoutCount = 3, - BindGroupLayouts = textureLayoutsOffscreen - }; - _texturePipelineLayoutOffscreen = _context.Api.DeviceCreatePipelineLayout(_context.Device, &texturePipelineLayoutDescOffscreen); + _texturePipelineLayoutOffscreen = _texturePipelineLayout; var retainedGlyphLayouts = stackalloc BindGroupLayout*[2]; retainedGlyphLayouts[0] = _retainedGlyphBindGroupLayout; @@ -1623,242 +1651,175 @@ private void InitializePipelinesAndBindGroups() _context.Device, &retainedGlyphPipelineLayoutDescriptor); - var retainedGlyphLayoutsOffscreen = stackalloc BindGroupLayout*[2]; - retainedGlyphLayoutsOffscreen[0] = _retainedGlyphBindGroupLayout; - retainedGlyphLayoutsOffscreen[1] = _maskBindGroupLayoutOffscreen; - var retainedGlyphPipelineLayoutOffscreenDescriptor = new PipelineLayoutDescriptor - { - BindGroupLayoutCount = 2, - BindGroupLayouts = retainedGlyphLayoutsOffscreen - }; - _retainedGlyphPipelineLayoutOffscreen = _context.Api.DeviceCreatePipelineLayout( - _context.Device, - &retainedGlyphPipelineLayoutOffscreenDescriptor); - - Span vectorAttrs = stackalloc VertexAttribute[8]; - vectorAttrs[0] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 0, ShaderLocation = 0 }; // Position - vectorAttrs[1] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 8, ShaderLocation = 1 }; // Color - vectorAttrs[2] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 24, ShaderLocation = 2 }; // TexCoord - vectorAttrs[3] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 32, ShaderLocation = 3 }; // BrushIndex - vectorAttrs[4] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 36, ShaderLocation = 4 }; // ShapeSize - vectorAttrs[5] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 44, ShaderLocation = 5 }; // CornerRadius - vectorAttrs[6] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 48, ShaderLocation = 6 }; // StrokeThickness - vectorAttrs[7] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 52, ShaderLocation = 7 }; // ShapeType + _retainedGlyphPipelineLayoutOffscreen = _retainedGlyphPipelineLayout; - fixed (VertexAttribute* attribsPtr = vectorAttrs) + if (Options.PrecompileBasePipelines) { - Span vectorVertexLayouts = stackalloc VertexBufferLayout[1]; - vectorVertexLayouts[0] = new VertexBufferLayout - { - ArrayStride = (uint)Unsafe.SizeOf(), - StepMode = VertexStepMode.Vertex, - AttributeCount = 8, - Attributes = attribsPtr - }; - - // Compile primary graphics pipelines with 4x MSAA - _vectorPipeline = _pipelineCache.GetOrCreateRenderPipeline( - "Vector_Unmasked", - vecShaderModule, - vectorVertexLayouts, - "vs_main", - "fs_main_unmasked", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: Options.PrimarySampleCount, - pipelineLayout: _vectorPipelineLayout - ); - - _solidRectPipeline = _pipelineCache.GetOrCreateRenderPipeline( - "SolidRect_Unmasked", - vecShaderModule, - vectorVertexLayouts, - "vs_solid_rect", - "fs_solid_rect_main_unmasked", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: Options.PrimarySampleCount, - pipelineLayout: _vectorPipelineLayout - ); - - Span textAttrs = stackalloc VertexAttribute[8]; - textAttrs[0] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 0, ShaderLocation = 0 }; // SnappedLogicalPos - textAttrs[1] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 8, ShaderLocation = 1 }; // BasisX - textAttrs[2] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 16, ShaderLocation = 2 }; // BasisY - textAttrs[3] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 24, ShaderLocation = 3 }; // BearSize - textAttrs[4] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 40, ShaderLocation = 4 }; // TexCoords - textAttrs[5] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 56, ShaderLocation = 5 }; // Color - textAttrs[6] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 72, ShaderLocation = 6 }; // ScaleBoldItalicUseMvp - textAttrs[7] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 88, ShaderLocation = 7 }; // BrushIndex - - fixed (VertexAttribute* textAttribsPtr = textAttrs) - { - Span textVertexLayouts = stackalloc VertexBufferLayout[1]; - textVertexLayouts[0] = new VertexBufferLayout - { - ArrayStride = (uint)Unsafe.SizeOf(), - StepMode = VertexStepMode.Instance, + var vecShaderModule = _pipelineCache.GetOrCreateShader( + "Vector", + Shaders.VectorShader, + "VectorShader"); + var textShaderModule = _pipelineCache.GetOrCreateShader( + "Text", + Shaders.TextShader, + "TextShader"); + var texShaderModule = _pipelineCache.GetOrCreateShader( + "Texture", + Shaders.TextureShader, + "TextureShader"); + Span vectorAttrs = stackalloc VertexAttribute[8]; + vectorAttrs[0] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 0, ShaderLocation = 0 }; // Position + vectorAttrs[1] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 8, ShaderLocation = 1 }; // Color + vectorAttrs[2] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 24, ShaderLocation = 2 }; // TexCoord + vectorAttrs[3] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 32, ShaderLocation = 3 }; // BrushIndex + vectorAttrs[4] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 36, ShaderLocation = 4 }; // ShapeSize + vectorAttrs[5] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 44, ShaderLocation = 5 }; // CornerRadius + vectorAttrs[6] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 48, ShaderLocation = 6 }; // StrokeThickness + vectorAttrs[7] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 52, ShaderLocation = 7 }; // ShapeType + + fixed (VertexAttribute* attribsPtr = vectorAttrs) + { + Span vectorVertexLayouts = stackalloc VertexBufferLayout[1]; + vectorVertexLayouts[0] = new VertexBufferLayout + { + ArrayStride = (uint)Unsafe.SizeOf(), + StepMode = VertexStepMode.Vertex, AttributeCount = 8, - Attributes = textAttribsPtr + Attributes = attribsPtr }; - _textPipeline = _pipelineCache.GetOrCreateRenderPipeline( - "Text_Unmasked", - textShaderModule, - textVertexLayouts, + // Explicit prewarming policy for clients that prefer startup compilation. + _vectorPipeline = _pipelineCache.GetOrCreateRenderPipeline( + "Vector_Unmasked", + vecShaderModule, + vectorVertexLayouts, "vs_main", "fs_main_unmasked", RenderFormat, PrimitiveTopology.TriangleList, enableBlend: true, sampleCount: Options.PrimarySampleCount, - pipelineLayout: _textPipelineLayout + pipelineLayout: _vectorPipelineLayout ); - _textPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "Text_Offscreen_Unmasked", - textShaderModule, - textVertexLayouts, - "vs_main", - "fs_main_unmasked", + _solidRectPipeline = _pipelineCache.GetOrCreateRenderPipeline( + "SolidRect_Unmasked", + vecShaderModule, + vectorVertexLayouts, + "vs_solid_rect", + "fs_solid_rect_main_unmasked", RenderFormat, PrimitiveTopology.TriangleList, enableBlend: true, - sampleCount: 1, - pipelineLayout: _textPipelineLayoutOffscreen + sampleCount: Options.PrimarySampleCount, + pipelineLayout: _vectorPipelineLayout ); - } - _texturePipeline = _pipelineCache.GetOrCreateRenderPipeline( - "Texture", - texShaderModule, - vectorVertexLayouts, - "vs_main", - "fs_main", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: Options.PrimarySampleCount, - pipelineLayout: _texturePipelineLayout, - sourceAlphaMode: GpuTextureAlphaMode.Premultiplied - ); + Span textAttrs = stackalloc VertexAttribute[8]; + textAttrs[0] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 0, ShaderLocation = 0 }; // SnappedLogicalPos + textAttrs[1] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 8, ShaderLocation = 1 }; // BasisX + textAttrs[2] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 16, ShaderLocation = 2 }; // BasisY + textAttrs[3] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 24, ShaderLocation = 3 }; // BearSize + textAttrs[4] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 40, ShaderLocation = 4 }; // TexCoords + textAttrs[5] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 56, ShaderLocation = 5 }; // Color + textAttrs[6] = new VertexAttribute { Format = VertexFormat.Float32x4, Offset = 72, ShaderLocation = 6 }; // ScaleBoldItalicUseMvp + textAttrs[7] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 88, ShaderLocation = 7 }; // BrushIndex - _vectorPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "Vector_Offscreen_Unmasked", - vecShaderModule, - vectorVertexLayouts, - "vs_main", - "fs_main_unmasked", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: 1, - pipelineLayout: _vectorPipelineLayoutOffscreen - ); - - _solidRectPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "SolidRect_Offscreen_Unmasked", - vecShaderModule, - vectorVertexLayouts, - "vs_solid_rect", - "fs_solid_rect_main_unmasked", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: 1, - pipelineLayout: _vectorPipelineLayoutOffscreen - ); - - _texturePipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "Texture_Offscreen", - texShaderModule, - vectorVertexLayouts, - "vs_main", - "fs_main", - RenderFormat, - PrimitiveTopology.TriangleList, - enableBlend: true, - sampleCount: 1, - pipelineLayout: _texturePipelineLayoutOffscreen, - sourceAlphaMode: GpuTextureAlphaMode.Premultiplied - ); - - // Compile high performance Chart GPGPU pipelines (with MSAA 4x) - _chartLinePipeline = _pipelineCache.GetOrCreateRenderPipeline( - "ChartLine", - chartLineShaderModule, - "vs_main", - "fs_main", - RenderFormat, - PrimitiveTopology.TriangleList, - Array.Empty(), - enableBlend: true, - sampleCount: Options.PrimarySampleCount - ); + fixed (VertexAttribute* textAttribsPtr = textAttrs) + { + Span textVertexLayouts = stackalloc VertexBufferLayout[1]; + textVertexLayouts[0] = new VertexBufferLayout + { + ArrayStride = (uint)Unsafe.SizeOf(), + StepMode = VertexStepMode.Instance, + AttributeCount = 8, + Attributes = textAttribsPtr + }; - _chartLinePipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "ChartLine_Offscreen", - chartLineShaderModule, - "vs_main", - "fs_main", - RenderFormat, - PrimitiveTopology.TriangleList, - Array.Empty(), - enableBlend: true, - sampleCount: 1 - ); + _textPipeline = _pipelineCache.GetOrCreateRenderPipeline( + "Text_Unmasked", + textShaderModule, + textVertexLayouts, + "vs_main", + "fs_main_unmasked", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: Options.PrimarySampleCount, + pipelineLayout: _textPipelineLayout + ); + + _textPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( + "Text_Offscreen_Unmasked", + textShaderModule, + textVertexLayouts, + "vs_main", + "fs_main_unmasked", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: 1, + pipelineLayout: _textPipelineLayoutOffscreen + ); + } - Span scatterAttrs = stackalloc VertexAttribute[2]; - scatterAttrs[0] = new VertexAttribute { Format = VertexFormat.Float32x2, Offset = 0, ShaderLocation = 0 }; // center - scatterAttrs[1] = new VertexAttribute { Format = VertexFormat.Float32, Offset = 8, ShaderLocation = 1 }; // radiusPx - fixed (VertexAttribute* scatterAttribsPtr = scatterAttrs) - { - Span scatterVertexLayouts = stackalloc VertexBufferLayout[1]; - scatterVertexLayouts[0] = new VertexBufferLayout - { - ArrayStride = (uint)Unsafe.SizeOf(), - StepMode = VertexStepMode.Instance, - AttributeCount = 2, - Attributes = scatterAttribsPtr - }; - _chartScatterPipeline = _pipelineCache.GetOrCreateRenderPipeline( - "ChartScatter", - chartScatterShaderModule, - scatterVertexLayouts, + _texturePipeline = _pipelineCache.GetOrCreateRenderPipeline( + "Texture", + texShaderModule, + vectorVertexLayouts, "vs_main", "fs_main", RenderFormat, PrimitiveTopology.TriangleList, enableBlend: true, - sampleCount: Options.PrimarySampleCount + sampleCount: Options.PrimarySampleCount, + pipelineLayout: _texturePipelineLayout, + sourceAlphaMode: GpuTextureAlphaMode.Premultiplied ); - _chartScatterPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( - "ChartScatter_Offscreen", - chartScatterShaderModule, - scatterVertexLayouts, + _vectorPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( + "Vector_Offscreen_Unmasked", + vecShaderModule, + vectorVertexLayouts, + "vs_main", + "fs_main_unmasked", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: 1, + pipelineLayout: _vectorPipelineLayoutOffscreen + ); + + _solidRectPipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( + "SolidRect_Offscreen_Unmasked", + vecShaderModule, + vectorVertexLayouts, + "vs_solid_rect", + "fs_solid_rect_main_unmasked", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: 1, + pipelineLayout: _vectorPipelineLayoutOffscreen + ); + + _texturePipelineOffscreen = _pipelineCache.GetOrCreateRenderPipeline( + "Texture_Offscreen", + texShaderModule, + vectorVertexLayouts, "vs_main", "fs_main", RenderFormat, PrimitiveTopology.TriangleList, enableBlend: true, - sampleCount: 1 + sampleCount: 1, + pipelineLayout: _texturePipelineLayoutOffscreen, + sourceAlphaMode: GpuTextureAlphaMode.Premultiplied ); } } - _chartLineBindGroupLayout = _context.Api.RenderPipelineGetBindGroupLayout(_chartLinePipeline, 0); - _chartScatterBindGroupLayout = _context.Api.RenderPipelineGetBindGroupLayout(_chartScatterPipeline, 0); - - var uBufferEntryVector = new BindGroupEntry - { - Binding = 0, - Buffer = _uniformBuffer.BufferPtr, - Offset = 0, - Size = (uint)Marshal.SizeOf() - }; + RecreateVectorUniformBindGroups(); var uBufferEntry = new BindGroupEntry { @@ -1868,43 +1829,6 @@ private void InitializePipelinesAndBindGroups() Size = (uint)Marshal.SizeOf() }; - var brushesEntry = new BindGroupEntry - { - Binding = 1, - Buffer = _brushesStorageBuffer.BufferPtr, - Offset = 0, - Size = _brushesStorageBuffer.Size - }; - - var gradientStopsEntry = new BindGroupEntry - { - Binding = 2, - Buffer = _gradientStopsStorageBuffer.BufferPtr, - Offset = 0, - Size = _gradientStopsStorageBuffer.Size - }; - - var vectorEntries = stackalloc BindGroupEntry[3]; - vectorEntries[0] = uBufferEntryVector; - vectorEntries[1] = brushesEntry; - vectorEntries[2] = gradientStopsEntry; - - var uDescVector = new BindGroupDescriptor - { - Layout = _vectorUniformBindGroupLayout, - EntryCount = 3, - Entries = vectorEntries - }; - _vectorUniformBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescVector); - - var uDescVectorOffscreen = new BindGroupDescriptor - { - Layout = _vectorUniformBindGroupLayoutOffscreen, - EntryCount = 3, - Entries = vectorEntries - }; - _vectorUniformBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescVectorOffscreen); - var uDescText = new BindGroupDescriptor { Layout = _textUniformBindGroupLayout, @@ -1912,14 +1836,7 @@ private void InitializePipelinesAndBindGroups() Entries = &uBufferEntry }; _textUniformBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescText); - - var uDescTextOffscreen = new BindGroupDescriptor - { - Layout = _textUniformBindGroupLayoutOffscreen, - EntryCount = 1, - Entries = &uBufferEntry - }; - _textUniformBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescTextOffscreen); + _textUniformBindGroupOffscreen = _textUniformBindGroup; var uDescTexture = new BindGroupDescriptor { @@ -1928,14 +1845,7 @@ private void InitializePipelinesAndBindGroups() Entries = &uBufferEntry }; _textureUniformBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescTexture); - - var uDescTextureOffscreen = new BindGroupDescriptor - { - Layout = _textureUniformBindGroupLayoutOffscreen, - EntryCount = 1, - Entries = &uBufferEntry - }; - _textureUniformBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &uDescTextureOffscreen); + _textureUniformBindGroupOffscreen = _textureUniformBindGroup; var samplerEntry = new BindGroupEntry { @@ -1965,14 +1875,7 @@ private void InitializePipelinesAndBindGroups() Entries = atlasEntries }; _atlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &atlasDesc); - - var atlasDescOffscreen = new BindGroupDescriptor - { - Layout = _atlasBindGroupLayoutOffscreen, - EntryCount = 3, - Entries = atlasEntries - }; - _atlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &atlasDescOffscreen); + _atlasBindGroupOffscreen = _atlasBindGroup; var pathViewEntry = new BindGroupEntry { @@ -1990,14 +1893,7 @@ private void InitializePipelinesAndBindGroups() Entries = pathAtlasEntries }; _pathAtlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &pathAtlasDesc); - - var pathAtlasDescOffscreen = new BindGroupDescriptor - { - Layout = _pathAtlasBindGroupLayoutOffscreen, - EntryCount = 2, - Entries = pathAtlasEntries - }; - _pathAtlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &pathAtlasDescOffscreen); + _pathAtlasBindGroupOffscreen = _pathAtlasBindGroup; _boundGlyphAtlasTextureRevision = _atlas.TextureRevision; _boundPathAtlasTextureRevision = _pathAtlas.TextureRevision; @@ -2023,14 +1919,61 @@ private void InitializePipelinesAndBindGroups() Entries = maskEntries }; _dummyMaskBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &bgDescMask); + _dummyMaskBindGroupOffscreen = _dummyMaskBindGroup; + } - var bgDescMaskOffscreen = new BindGroupDescriptor + private void RecreateVectorUniformBindGroups() + { + var entries = stackalloc BindGroupEntry[3]; + entries[0] = new BindGroupEntry { - Layout = _maskBindGroupLayoutOffscreen, - EntryCount = 2, - Entries = maskEntries + Binding = 0, + Buffer = _uniformBuffer.BufferPtr, + Offset = 0, + Size = (uint)Marshal.SizeOf() + }; + entries[1] = new BindGroupEntry + { + Binding = 1, + Buffer = _brushesStorageBuffer.BufferPtr, + Offset = 0, + Size = _brushesStorageBuffer.Size + }; + entries[2] = new BindGroupEntry + { + Binding = 2, + Buffer = _gradientStopsStorageBuffer.BufferPtr, + Offset = 0, + Size = _gradientStopsStorageBuffer.Size }; - _dummyMaskBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &bgDescMaskOffscreen); + + var descriptor = new BindGroupDescriptor + { + Layout = _vectorUniformBindGroupLayout, + EntryCount = 3, + Entries = entries + }; + BindGroup* replacement = + _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor); + + if (replacement == null) + { + throw new InvalidOperationException( + "Failed to create compositor scene-state bind groups."); + } + + BindGroup* previous = _vectorUniformBindGroup; + BindGroup* previousOffscreen = _vectorUniformBindGroupOffscreen; + _vectorUniformBindGroup = replacement; + _vectorUniformBindGroupOffscreen = replacement; + if (previous != null) + { + _context.QueueBindGroupDisposal((nint)previous); + } + if (previousOffscreen != null && previousOffscreen != previous) + { + _context.QueueBindGroupDisposal((nint)previousOffscreen); + } } private void RefreshAtlasBindGroupsIfNeeded() @@ -2041,7 +1984,7 @@ private void RefreshAtlasBindGroupsIfNeeded() { _context.QueueBindGroupDisposal((nint)_atlasBindGroup); } - if (_atlasBindGroupOffscreen != null) + if (_atlasBindGroupOffscreen != null && _atlasBindGroupOffscreen != _atlasBindGroup) { _context.QueueBindGroupDisposal((nint)_atlasBindGroupOffscreen); } @@ -2057,8 +2000,7 @@ private void RefreshAtlasBindGroupsIfNeeded() Entries = entries }; _atlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor); - descriptor.Layout = _atlasBindGroupLayoutOffscreen; - _atlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor); + _atlasBindGroupOffscreen = _atlasBindGroup; _boundGlyphAtlasTextureRevision = _atlas.TextureRevision; } @@ -2068,7 +2010,8 @@ private void RefreshAtlasBindGroupsIfNeeded() { _context.QueueBindGroupDisposal((nint)_pathAtlasBindGroup); } - if (_pathAtlasBindGroupOffscreen != null) + if (_pathAtlasBindGroupOffscreen != null && + _pathAtlasBindGroupOffscreen != _pathAtlasBindGroup) { _context.QueueBindGroupDisposal((nint)_pathAtlasBindGroupOffscreen); } @@ -2083,8 +2026,7 @@ private void RefreshAtlasBindGroupsIfNeeded() Entries = entries }; _pathAtlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor); - descriptor.Layout = _pathAtlasBindGroupLayoutOffscreen; - _pathAtlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor); + _pathAtlasBindGroupOffscreen = _pathAtlasBindGroup; _boundPathAtlasTextureRevision = _pathAtlas.TextureRevision; } } @@ -2626,6 +2568,7 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* _uniformBuffer.WriteSingle(uniformsData); // Upload compiled active brushes to storage buffer + EnsureSceneStateBufferCapacity(); if (_activeBrushes.Count > 0) { _brushesStorageBuffer.Write(CollectionsMarshal.AsSpan(_activeBrushes)); @@ -2671,9 +2614,7 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* } // 5. WebGPU Command Encoder and Render Pass Execution - var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Compositor Command Encoder") }; - encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDesc); - SilkMarshal.Free((nint)encoderDesc.Label); + encoder = CreateCommandEncoder("Compositor Command Encoder\0"u8); // Run mask render passes first! ExecuteMaskRenderPasses(encoder, isOffscreen: false); @@ -2964,7 +2905,12 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* }; var finalPass = _context.Api.CommandEncoderBeginRenderPass(encoder, &finalPassDescriptor); ApplyRenderPassViewport(finalPass, renderWidth, renderHeight, useRenderTargetViewport: true); - _context.Api.RenderPassEncoderSetPipeline(finalPass, _texturePipelineOffscreen); + var finalPipeline = GetPipeline( + DrawCallType.Texture, + GpuBlendMode.SrcOver, + isOffscreen: true, + hasMask: false); + _context.Api.RenderPassEncoderSetPipeline(finalPass, finalPipeline); _context.Api.RenderPassEncoderSetBindGroup(finalPass, 0, _textureUniformBindGroupOffscreen, 0, null); _context.Api.RenderPassEncoderSetBindGroup(finalPass, 1, _wavefrontTextureBindGroup, 0, null); _context.Api.RenderPassEncoderSetBindGroup(finalPass, 2, _dummyMaskBindGroupOffscreen, 0, null); @@ -2996,9 +2942,9 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* } // Submit to queue - var cmdDesc = new CommandBufferDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Compositor Command Buffer") }; - var cmdBuffer = _context.Api.CommandEncoderFinish(encoder, &cmdDesc); - SilkMarshal.Free((nint)cmdDesc.Label); + var cmdBuffer = FinishCommandEncoder( + encoder, + "Compositor Command Buffer\0"u8); _context.Api.QueueSubmit(_context.Queue, 1, &cmdBuffer); @@ -3031,26 +2977,51 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* GlyphAtlasTextureBytes = (ulong)_atlas.AtlasSize * _atlas.AtlasSize, ColorGlyphAtlasTextureBytes = (ulong)_atlas.ColorAtlasSize * _atlas.ColorAtlasSize * 4UL, + GlyphUniformStagingBytes = _atlas.UniformStagingBytes, + GlyphUniformUploadBytes = _atlas.UniformUploadBytes, GlyphCoverageStagingBytes = _atlas.CoverageStagingBytes, GlyphOutlineGpuBytes = _atlas.AllocatedGpuOutlineBytes, PathAtlasTextureBytes = _pathAtlas.PersistentTextureBytes, + SceneBufferBytes = PersistentSceneBufferBytes, + BrushStorageBufferBytes = _brushesStorageBuffer.AllocatedSize, + GradientStopStorageBufferBytes = _gradientStopsStorageBuffer.AllocatedSize, + EffectParameterBufferBytes = _compute.PersistentEffectParameterBufferBytes, PathRasterStagingBytes = _pathAtlas.LastRasterStagingBytes, PathPeakRasterStagingBytes = _pathAtlas.PeakRasterStagingBytes, + PathPeakRasterWidth = _pathAtlas.PeakRasterWidth, + PathPeakRasterHeight = _pathAtlas.PeakRasterHeight, GlyphAtlasSize = _atlas.AtlasSize, GlyphAtlasMaximumSize = _atlas.MaxAtlasSize, ColorGlyphAtlasSize = _atlas.ColorAtlasSize, ColorGlyphAtlasMaximumSize = _atlas.MaxColorAtlasSize, PathAtlasSize = _pathAtlas.AtlasSize, + PathAtlasWidth = _pathAtlas.AtlasWidth, + PathAtlasHeight = _pathAtlas.AtlasHeight, PathAtlasMaximumSize = _pathAtlas.MaxAtlasSize, GlyphOutlineCompiledCount = _atlas.CompiledGpuGlyphCount, GlyphOutlineRecordCapacity = _atlas.AllocatedGpuGlyphRecordCapacity, + GlyphOutlineSegmentCount = _atlas.CompiledGpuSegmentCount, + GlyphOutlineSegmentCapacity = _atlas.AllocatedGpuSegmentCapacity, GlyphOutlineUploadWrites = _atlas.OutlineUploadWriteCount, GlyphUniformUploadWrites = _atlas.UniformUploadWriteCount, GlyphRasterBatchSubmissions = _atlas.RasterBatchSubmissionCount, GlyphRasterBindGroupCreations = _atlas.RasterBindGroupCreationCount, GlyphRasterComputePasses = _atlas.RasterComputePassCount, GlyphLastBatchNewGlyphCount = _atlas.LastBatchNewGlyphCount, + ActiveBrushCount = _activeBrushes.Count, + ActiveGradientStopCount = _activeGradientStops.Count, + EffectShaderCount = _compute.CachedEffectShaderCount, + EffectPipelineCount = _compute.CachedEffectPipelineCount, + SceneShaderCount = _pipelineCache.ShaderCount, + SceneRenderPipelineCount = _pipelineCache.RenderPipelineCount, + SceneComputePipelineCount = _pipelineCache.ComputePipelineCount, + BaseBindGroupLayoutCount = 8, + BasePipelineLayoutCount = 4, + BaseBindGroupCount = 6, + PersistentTextureBindGroupCount = _persistentTextureBindGroups.Count, + MaskBindGroupCount = _maskBindGroups.Count, SceneCacheHit = reuseCompiledScene, + GpuHitTestingEnabled = Options.EnableGpuHitTesting, SceneCacheMissReason = reuseCompiledScene ? null : _currentSceneCacheMissReason }; } @@ -3272,7 +3243,6 @@ private void HandleTextureDisposed(ulong textureId) } RemoveMaskBindGroups(_maskBindGroups, textureId); - RemoveMaskBindGroups(_maskBindGroupsOffscreen, textureId); } private void HandleStaticDxfBufferDisposed(DxfStaticBuffer buffer) @@ -8195,16 +8165,56 @@ private static bool TryGetArcDirection(Vector2 segmentStart, ArcSegment arc, boo return TrySelectDirection(out direction, tangent); } - private static bool TrySelectDirection(out Vector2 direction, params Vector2[] candidates) + private static bool TrySelectDirection(out Vector2 direction, Vector2 candidate) { - foreach (var candidate in candidates) + if (TryUseDirection(candidate, out direction)) { - var length = candidate.Length(); - if (float.IsFinite(length) && length > StrokeEpsilon) - { - direction = candidate; - return true; - } + return true; + } + + direction = default; + return false; + } + + private static bool TrySelectDirection( + out Vector2 direction, + Vector2 first, + Vector2 second) + { + if (TryUseDirection(first, out direction) || + TryUseDirection(second, out direction)) + { + return true; + } + + direction = default; + return false; + } + + private static bool TrySelectDirection( + out Vector2 direction, + Vector2 first, + Vector2 second, + Vector2 third) + { + if (TryUseDirection(first, out direction) || + TryUseDirection(second, out direction) || + TryUseDirection(third, out direction)) + { + return true; + } + + direction = default; + return false; + } + + private static bool TryUseDirection(Vector2 candidate, out Vector2 direction) + { + var length = candidate.Length(); + if (float.IsFinite(length) && length > StrokeEpsilon) + { + direction = candidate; + return true; } direction = default; @@ -8925,7 +8935,7 @@ private void CompileRoundedRectCommand(RenderCommand cmd, Matrix4x4 transform) { var pathCommand = cmd; pathCommand.Type = RenderCommandType.DrawPath; - pathCommand.Path = PrimitivePathGeometry.CreateRoundedRectangle(r.X, r.Y, r.Width, r.Height, radiusX, radiusY); + pathCommand.Path = GetOrCreateRoundedRectanglePath(r, radiusX, radiusY); pathCommand.Transform = default; CompilePathCommand(pathCommand, transform); return; @@ -9040,6 +9050,36 @@ private void CompileRoundedRectCommand(RenderCommand cmd, Matrix4x4 transform) } } + private PathGeometry GetOrCreateRoundedRectanglePath(Rect rect, float radiusX, float radiusY) + { + var key = new RoundedRectanglePathKey( + rect.X, + rect.Y, + rect.Width, + rect.Height, + radiusX, + radiusY); + if (_roundedRectanglePaths.TryGetValue(key, out var path)) + { + return path; + } + + if (_roundedRectanglePaths.Count >= MaximumRoundedRectanglePathCacheEntries) + { + _roundedRectanglePaths.Clear(); + } + + path = PrimitivePathGeometry.CreateRoundedRectangle( + rect.X, + rect.Y, + rect.Width, + rect.Height, + radiusX, + radiusY); + _roundedRectanglePaths[key] = path; + return path; + } + internal float RegisterBrush(Brush? brush) { if (brush == null) return 0f; @@ -9148,7 +9188,7 @@ internal float RegisterBrush(Brush? brush) gpuBrush.ColorInterpolationMode = 0u; } - if (_activeBrushes.Count < 8192) + if (_activeBrushes.Count < MaxBrushes) { _activeBrushes.Add(gpuBrush); return (float)(_activeBrushes.Count - 1); @@ -10504,10 +10544,7 @@ private void EnsureTextVertexCapacity(int additionalCapacity) } int requiredCapacity = _textVerticesList.Count + additionalCapacity; - if (_textVerticesList.Capacity < requiredCapacity) - { - _textVerticesList.Capacity = requiredCapacity; - } + _textVerticesList.EnsureCapacity(requiredCapacity); } private void CompileTextureCommand(RenderCommand cmd, Matrix4x4 transform) @@ -10902,15 +10939,69 @@ private void CommitPendingDrawCalls() _currentBatchType = BatchType.None; } - private void EnsureBufferSize(ref GpuBuffer buffer, uint requiredSize, BufferUsage usage) + private void EnsureSceneStateBufferCapacity() { - if (buffer.Size >= requiredSize) return; + bool resized = EnsureBufferSize( + ref _brushesStorageBuffer, + checked( + (uint)Math.Max(1, _activeBrushes.Count) * + (uint)Marshal.SizeOf()), + BufferUsage.Storage, + "Compositor Brushes Resize Storage Buffer"); + resized |= EnsureBufferSize( + ref _gradientStopsStorageBuffer, + checked( + (uint)Math.Max(1, _activeGradientStops.Count) * + (uint)Marshal.SizeOf()), + BufferUsage.Storage, + "Compositor Gradient Stops Resize Storage Buffer"); + if (resized) + { + // A WebGPU bind group captures a concrete buffer and exposed range. + // Growing either storage buffer therefore requires one replacement pair. + RecreateVectorUniformBindGroups(); + } + } + + private bool EnsureBufferSize( + ref GpuBuffer buffer, + uint requiredSize, + BufferUsage usage, + string? label = null) + { + if (buffer.Size >= requiredSize) return false; uint newSize = Math.Max(buffer.Size * 2, requiredSize); - buffer.Dispose(); + string resolvedLabel = label ?? + (usage == BufferUsage.Vertex + ? "Vector/Text Resize Vertex Buffer" + : "Vector/Text Resize Index Buffer"); + var replacement = + new GpuBuffer(_context, newSize, usage | BufferUsage.CopyDst, resolvedLabel); + GpuBuffer previous = buffer; + buffer = replacement; + previous.Dispose(); + return true; + } - string lbl = usage == BufferUsage.Vertex ? "Vector/Text Resize Vertex Buffer" : "Vector/Text Resize Index Buffer"; - buffer = new GpuBuffer(_context, newSize, usage | BufferUsage.CopyDst, lbl); + private CommandEncoder* CreateCommandEncoder(ReadOnlySpan label) + { + fixed (byte* labelPointer = label) + { + var descriptor = new CommandEncoderDescriptor { Label = labelPointer }; + return _context.Api.DeviceCreateCommandEncoder(_context.Device, &descriptor); + } + } + + private CommandBuffer* FinishCommandEncoder( + CommandEncoder* encoder, + ReadOnlySpan label) + { + fixed (byte* labelPointer = label) + { + var descriptor = new CommandBufferDescriptor { Label = labelPointer }; + return _context.Api.CommandEncoderFinish(encoder, &descriptor); + } } private void PrepareWavefrontComposite( @@ -11046,9 +11137,15 @@ public void Dispose() { if (_pathAtlasBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_pathAtlasBindGroup); if (_pathAtlasBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_pathAtlasBindGroupLayout); - if (_pathAtlasBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_pathAtlasBindGroupOffscreen); - if (_pathAtlasBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_pathAtlasBindGroupLayoutOffscreen); - } + if (_pathAtlasBindGroupOffscreen != null && + _pathAtlasBindGroupOffscreen != _pathAtlasBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_pathAtlasBindGroupOffscreen); + if (_pathAtlasBindGroupLayoutOffscreen != null && + _pathAtlasBindGroupLayoutOffscreen != _pathAtlasBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_pathAtlasBindGroupLayoutOffscreen); + } + _selectedPipelines.Clear(); + _roundedRectanglePaths.Clear(); _pipelineCache.Dispose(); _compute.Dispose(); var effectTextureEnumerator = _effectTextures.Values.GetEnumerator(); @@ -11087,44 +11184,64 @@ public void Dispose() _anisotropicTextureSamplers.Clear(); if (_vectorUniformBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_vectorUniformBindGroup); - if (_vectorUniformBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_vectorUniformBindGroupOffscreen); + if (_vectorUniformBindGroupOffscreen != null && + _vectorUniformBindGroupOffscreen != _vectorUniformBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_vectorUniformBindGroupOffscreen); if (_textUniformBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_textUniformBindGroup); - if (_textUniformBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_textUniformBindGroupOffscreen); + if (_textUniformBindGroupOffscreen != null && + _textUniformBindGroupOffscreen != _textUniformBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_textUniformBindGroupOffscreen); if (_textureUniformBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_textureUniformBindGroup); - if (_textureUniformBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_textureUniformBindGroupOffscreen); + if (_textureUniformBindGroupOffscreen != null && + _textureUniformBindGroupOffscreen != _textureUniformBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_textureUniformBindGroupOffscreen); if (_vectorUniformBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_vectorUniformBindGroupLayout); - if (_vectorUniformBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_vectorUniformBindGroupLayoutOffscreen); + if (_vectorUniformBindGroupLayoutOffscreen != null && + _vectorUniformBindGroupLayoutOffscreen != _vectorUniformBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_vectorUniformBindGroupLayoutOffscreen); if (_textUniformBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textUniformBindGroupLayout); - if (_textUniformBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textUniformBindGroupLayoutOffscreen); + if (_textUniformBindGroupLayoutOffscreen != null && + _textUniformBindGroupLayoutOffscreen != _textUniformBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_textUniformBindGroupLayoutOffscreen); if (_textureUniformBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textureUniformBindGroupLayout); - if (_textureUniformBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textureUniformBindGroupLayoutOffscreen); + if (_textureUniformBindGroupLayoutOffscreen != null && + _textureUniformBindGroupLayoutOffscreen != _textureUniformBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_textureUniformBindGroupLayoutOffscreen); if (_vectorPipelineLayout != null) _context.QueuePipelineLayoutDisposal((IntPtr)_vectorPipelineLayout); if (_textPipelineLayout != null) _context.QueuePipelineLayoutDisposal((IntPtr)_textPipelineLayout); if (_texturePipelineLayout != null) _context.QueuePipelineLayoutDisposal((IntPtr)_texturePipelineLayout); - if (_vectorPipelineLayoutOffscreen != null) _context.QueuePipelineLayoutDisposal((IntPtr)_vectorPipelineLayoutOffscreen); - if (_textPipelineLayoutOffscreen != null) _context.QueuePipelineLayoutDisposal((IntPtr)_textPipelineLayoutOffscreen); - if (_texturePipelineLayoutOffscreen != null) _context.QueuePipelineLayoutDisposal((IntPtr)_texturePipelineLayoutOffscreen); + if (_vectorPipelineLayoutOffscreen != null && + _vectorPipelineLayoutOffscreen != _vectorPipelineLayout) + _context.QueuePipelineLayoutDisposal((IntPtr)_vectorPipelineLayoutOffscreen); + if (_textPipelineLayoutOffscreen != null && + _textPipelineLayoutOffscreen != _textPipelineLayout) + _context.QueuePipelineLayoutDisposal((IntPtr)_textPipelineLayoutOffscreen); + if (_texturePipelineLayoutOffscreen != null && + _texturePipelineLayoutOffscreen != _texturePipelineLayout) + _context.QueuePipelineLayoutDisposal((IntPtr)_texturePipelineLayoutOffscreen); if (_retainedGlyphPipelineLayout != null) _context.QueuePipelineLayoutDisposal((IntPtr)_retainedGlyphPipelineLayout); - if (_retainedGlyphPipelineLayoutOffscreen != null) _context.QueuePipelineLayoutDisposal((IntPtr)_retainedGlyphPipelineLayoutOffscreen); + if (_retainedGlyphPipelineLayoutOffscreen != null && + _retainedGlyphPipelineLayoutOffscreen != _retainedGlyphPipelineLayout) + _context.QueuePipelineLayoutDisposal((IntPtr)_retainedGlyphPipelineLayoutOffscreen); if (_retainedGlyphBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_retainedGlyphBindGroupLayout); if (_advancedBlendPipelineLayout != null) _context.QueuePipelineLayoutDisposal((IntPtr)_advancedBlendPipelineLayout); if (_advancedBlendBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_advancedBlendBindGroupLayout); if (_atlasBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_atlasBindGroup); - if (_atlasBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_atlasBindGroupOffscreen); + if (_atlasBindGroupOffscreen != null && + _atlasBindGroupOffscreen != _atlasBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_atlasBindGroupOffscreen); if (_atlasBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_atlasBindGroupLayout); - if (_atlasBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_atlasBindGroupLayoutOffscreen); + if (_atlasBindGroupLayoutOffscreen != null && + _atlasBindGroupLayoutOffscreen != _atlasBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_atlasBindGroupLayoutOffscreen); - if (_texturePipeline != null) _context.QueueRenderPipelineDisposal((IntPtr)_texturePipeline); if (_textureBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textureBindGroupLayout); - if (_textureBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_textureBindGroupLayoutOffscreen); - - if (_chartLinePipeline != null) _context.QueueRenderPipelineDisposal((IntPtr)_chartLinePipeline); - if (_chartScatterPipeline != null) _context.QueueRenderPipelineDisposal((IntPtr)_chartScatterPipeline); - if (_chartLinePipelineOffscreen != null) _context.QueueRenderPipelineDisposal((IntPtr)_chartLinePipelineOffscreen); - if (_chartScatterPipelineOffscreen != null) _context.QueueRenderPipelineDisposal((IntPtr)_chartScatterPipelineOffscreen); + if (_textureBindGroupLayoutOffscreen != null && + _textureBindGroupLayoutOffscreen != _textureBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_textureBindGroupLayoutOffscreen); if (_chartLineBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_chartLineBindGroupLayout); if (_chartScatterBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_chartScatterBindGroupLayout); @@ -11151,9 +11268,13 @@ public void Dispose() if (!_context.IsDisposed) { if (_dummyMaskBindGroup != null) _context.QueueBindGroupDisposal((IntPtr)_dummyMaskBindGroup); - if (_dummyMaskBindGroupOffscreen != null) _context.QueueBindGroupDisposal((IntPtr)_dummyMaskBindGroupOffscreen); + if (_dummyMaskBindGroupOffscreen != null && + _dummyMaskBindGroupOffscreen != _dummyMaskBindGroup) + _context.QueueBindGroupDisposal((IntPtr)_dummyMaskBindGroupOffscreen); if (_maskBindGroupLayout != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_maskBindGroupLayout); - if (_maskBindGroupLayoutOffscreen != null) _context.QueueBindGroupLayoutDisposal((IntPtr)_maskBindGroupLayoutOffscreen); + if (_maskBindGroupLayoutOffscreen != null && + _maskBindGroupLayoutOffscreen != _maskBindGroupLayout) + _context.QueueBindGroupLayoutDisposal((IntPtr)_maskBindGroupLayoutOffscreen); var maskBindGroupEnumerator = _maskBindGroups.Values.GetEnumerator(); while (maskBindGroupEnumerator.MoveNext()) @@ -11162,15 +11283,8 @@ public void Dispose() _context.QueueBindGroupDisposal((IntPtr)bg); } - var offscreenMaskBindGroupEnumerator = _maskBindGroupsOffscreen.Values.GetEnumerator(); - while (offscreenMaskBindGroupEnumerator.MoveNext()) - { - var bg = offscreenMaskBindGroupEnumerator.Current; - _context.QueueBindGroupDisposal((IntPtr)bg); - } } _maskBindGroups.Clear(); - _maskBindGroupsOffscreen.Clear(); ReturnMaskRenderPassDrawCallLists(); _drawCallListPool.Dispose(); _clipStack.Dispose(); @@ -12346,6 +12460,7 @@ private void RenderOffscreenCore( bool includeRootTransform, bool includeRootVisualState) { + long totalStartTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); using var currentContextScope = WgpuContext.PushCurrent(_context); var savedWidth = _currentWidth; @@ -12448,6 +12563,7 @@ private void RenderOffscreenCore( includeRootVisualState); CommitPendingDrawCalls(); + long compileEndTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); // Cached layers can be compiled while the outer scene's glyph batch is still // active. Submit newly rasterized glyphs before this offscreen render samples @@ -12500,6 +12616,7 @@ private void RenderOffscreenCore( DpiScale = _currentDpiScale }; _uniformBuffer.WriteSingle(uniformsData); + EnsureSceneStateBufferCapacity(); if (_activeBrushes.Count > 0) { _brushesStorageBuffer.Write(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_activeBrushes)); @@ -12510,14 +12627,13 @@ private void RenderOffscreenCore( } _pathAtlas.RasterizePendingPaths(); RefreshAtlasBindGroupsIfNeeded(); + long uploadEndTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); // Render target view for offscreen GpuTexture var targetView = targetTexture.ViewPtr; // Render pass for offscreen (1x MSAA) - var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Offscreen Compositor Encoder") }; - encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDesc); - SilkMarshal.Free((nint)encoderDesc.Label); + encoder = CreateCommandEncoder("Offscreen Compositor Encoder\0"u8); // Run mask render passes first! ExecuteMaskRenderPasses(encoder, isOffscreen: true); @@ -12829,9 +12945,9 @@ private void RenderOffscreenCore( EndExtensionFrame(extensionFrame); extensionFrameEnded = true; - var cmdDesc = new CommandBufferDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Offscreen Compositor Command Buffer") }; - var cmdBuffer = _context.Api.CommandEncoderFinish(encoder, &cmdDesc); - SilkMarshal.Free((nint)cmdDesc.Label); + var cmdBuffer = FinishCommandEncoder( + encoder, + "Offscreen Compositor Command Buffer\0"u8); _context.Api.QueueSubmit(_context.Queue, 1, &cmdBuffer); @@ -12839,6 +12955,73 @@ private void RenderOffscreenCore( _context.Api.CommandEncoderRelease(encoder); targetTexture.AlphaMode = GpuTextureAlphaMode.Premultiplied; targetTexture.MarkContentsDirty(); + long renderEndTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + + Metrics = new CompositorMetrics + { + FrameTimeMs = System.Diagnostics.Stopwatch + .GetElapsedTime(totalStartTimestamp, renderEndTimestamp).TotalMilliseconds, + VisualTreeCompileTimeMs = System.Diagnostics.Stopwatch + .GetElapsedTime(totalStartTimestamp, compileEndTimestamp).TotalMilliseconds, + GpuUploadTimeMs = System.Diagnostics.Stopwatch + .GetElapsedTime(compileEndTimestamp, uploadEndTimestamp).TotalMilliseconds, + RenderPassTimeMs = System.Diagnostics.Stopwatch + .GetElapsedTime(uploadEndTimestamp, renderEndTimestamp).TotalMilliseconds, + DrawCallsCount = _drawCalls.Count, + VectorVerticesCount = _vectorVerticesList.Count, + TextVerticesCount = _textVerticesList.Count, + PathAtlasCachedCount = _pathAtlas.CachedPathCount, + PathAtlasCpuCacheBytes = _pathAtlas.CompiledPathCacheBytes, + GlyphAtlasTextureBytes = (ulong)_atlas.AtlasSize * _atlas.AtlasSize, + ColorGlyphAtlasTextureBytes = + (ulong)_atlas.ColorAtlasSize * _atlas.ColorAtlasSize * 4UL, + GlyphUniformStagingBytes = _atlas.UniformStagingBytes, + GlyphUniformUploadBytes = _atlas.UniformUploadBytes, + GlyphCoverageStagingBytes = _atlas.CoverageStagingBytes, + GlyphOutlineGpuBytes = _atlas.AllocatedGpuOutlineBytes, + PathAtlasTextureBytes = _pathAtlas.PersistentTextureBytes, + SceneBufferBytes = PersistentSceneBufferBytes, + BrushStorageBufferBytes = _brushesStorageBuffer.AllocatedSize, + GradientStopStorageBufferBytes = _gradientStopsStorageBuffer.AllocatedSize, + EffectParameterBufferBytes = _compute.PersistentEffectParameterBufferBytes, + PathRasterStagingBytes = _pathAtlas.LastRasterStagingBytes, + PathPeakRasterStagingBytes = _pathAtlas.PeakRasterStagingBytes, + PathPeakRasterWidth = _pathAtlas.PeakRasterWidth, + PathPeakRasterHeight = _pathAtlas.PeakRasterHeight, + GlyphAtlasSize = _atlas.AtlasSize, + GlyphAtlasMaximumSize = _atlas.MaxAtlasSize, + ColorGlyphAtlasSize = _atlas.ColorAtlasSize, + ColorGlyphAtlasMaximumSize = _atlas.MaxColorAtlasSize, + PathAtlasSize = _pathAtlas.AtlasSize, + PathAtlasWidth = _pathAtlas.AtlasWidth, + PathAtlasHeight = _pathAtlas.AtlasHeight, + PathAtlasMaximumSize = _pathAtlas.MaxAtlasSize, + GlyphOutlineCompiledCount = _atlas.CompiledGpuGlyphCount, + GlyphOutlineRecordCapacity = _atlas.AllocatedGpuGlyphRecordCapacity, + GlyphOutlineSegmentCount = _atlas.CompiledGpuSegmentCount, + GlyphOutlineSegmentCapacity = _atlas.AllocatedGpuSegmentCapacity, + GlyphOutlineUploadWrites = _atlas.OutlineUploadWriteCount, + GlyphUniformUploadWrites = _atlas.UniformUploadWriteCount, + GlyphRasterBatchSubmissions = _atlas.RasterBatchSubmissionCount, + GlyphRasterBindGroupCreations = _atlas.RasterBindGroupCreationCount, + GlyphRasterComputePasses = _atlas.RasterComputePassCount, + GlyphLastBatchNewGlyphCount = _atlas.LastBatchNewGlyphCount, + ActiveBrushCount = _activeBrushes.Count, + ActiveGradientStopCount = _activeGradientStops.Count, + EffectShaderCount = _compute.CachedEffectShaderCount, + EffectPipelineCount = _compute.CachedEffectPipelineCount, + SceneShaderCount = _pipelineCache.ShaderCount, + SceneRenderPipelineCount = _pipelineCache.RenderPipelineCount, + SceneComputePipelineCount = _pipelineCache.ComputePipelineCount, + BaseBindGroupLayoutCount = 8, + BasePipelineLayoutCount = 4, + BaseBindGroupCount = 6, + PersistentTextureBindGroupCount = _persistentTextureBindGroups.Count, + MaskBindGroupCount = _maskBindGroups.Count, + SceneCacheHit = false, + GpuHitTestingEnabled = Options.EnableGpuHitTesting, + SceneCacheMissReason = "Offscreen render" + }; ReturnPendingMaskTexturesToPool(); @@ -14347,9 +14530,135 @@ private void CompileGpuScatterSeriesCommand(IRenderDataProvider? provider, Rende _pendingTextStart = (uint)_textVerticesList.Count; } + private unsafe void EnsureChartLinePipelines() + { + if (_chartLinePipeline != null && + _chartLinePipelineOffscreen != null && + _chartLineBindGroupLayout != null) + { + return; + } + + ShaderModule* shader = _pipelineCache.GetOrCreateShader( + "ChartLine", + Shaders.ChartLineShader, + "ChartLineShader"); + RenderPipeline* pipeline = _pipelineCache.GetOrCreateRenderPipeline( + "ChartLine", + shader, + "vs_main", + "fs_main", + RenderFormat, + PrimitiveTopology.TriangleList, + Array.Empty(), + enableBlend: true, + sampleCount: Options.PrimarySampleCount); + RenderPipeline* offscreenPipeline = _pipelineCache.GetOrCreateRenderPipeline( + "ChartLine_Offscreen", + shader, + "vs_main", + "fs_main", + RenderFormat, + PrimitiveTopology.TriangleList, + Array.Empty(), + enableBlend: true, + sampleCount: 1); + BindGroupLayout* bindGroupLayout = + _context.Api.RenderPipelineGetBindGroupLayout(pipeline, 0); + if (pipeline == null || offscreenPipeline == null || bindGroupLayout == null) + { + if (bindGroupLayout != null) + { + _context.QueueBindGroupLayoutDisposal((nint)bindGroupLayout); + } + + throw new InvalidOperationException("Failed to create chart-line pipelines."); + } + + _chartLinePipeline = pipeline; + _chartLinePipelineOffscreen = offscreenPipeline; + _chartLineBindGroupLayout = bindGroupLayout; + } + + private unsafe void EnsureChartScatterPipelines() + { + if (_chartScatterPipeline != null && + _chartScatterPipelineOffscreen != null && + _chartScatterBindGroupLayout != null) + { + return; + } + + ShaderModule* shader = _pipelineCache.GetOrCreateShader( + "ChartScatter", + Shaders.ChartScatterShader, + "ChartScatterShader"); + Span scatterAttrs = stackalloc VertexAttribute[2]; + scatterAttrs[0] = new VertexAttribute + { + Format = VertexFormat.Float32x2, + Offset = 0, + ShaderLocation = 0 + }; + scatterAttrs[1] = new VertexAttribute + { + Format = VertexFormat.Float32, + Offset = 8, + ShaderLocation = 1 + }; + + fixed (VertexAttribute* scatterAttribsPtr = scatterAttrs) + { + Span scatterVertexLayouts = stackalloc VertexBufferLayout[1]; + scatterVertexLayouts[0] = new VertexBufferLayout + { + ArrayStride = (uint)Unsafe.SizeOf(), + StepMode = VertexStepMode.Instance, + AttributeCount = 2, + Attributes = scatterAttribsPtr + }; + RenderPipeline* pipeline = _pipelineCache.GetOrCreateRenderPipeline( + "ChartScatter", + shader, + scatterVertexLayouts, + "vs_main", + "fs_main", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: Options.PrimarySampleCount); + RenderPipeline* offscreenPipeline = _pipelineCache.GetOrCreateRenderPipeline( + "ChartScatter_Offscreen", + shader, + scatterVertexLayouts, + "vs_main", + "fs_main", + RenderFormat, + PrimitiveTopology.TriangleList, + enableBlend: true, + sampleCount: 1); + BindGroupLayout* bindGroupLayout = + _context.Api.RenderPipelineGetBindGroupLayout(pipeline, 0); + if (pipeline == null || offscreenPipeline == null || bindGroupLayout == null) + { + if (bindGroupLayout != null) + { + _context.QueueBindGroupLayoutDisposal((nint)bindGroupLayout); + } + + throw new InvalidOperationException("Failed to create chart-scatter pipelines."); + } + + _chartScatterPipeline = pipeline; + _chartScatterPipelineOffscreen = offscreenPipeline; + _chartScatterBindGroupLayout = bindGroupLayout; + } + } + private unsafe void RenderChartLine(RenderPassEncoder* pass, CompositorDrawCall dc, bool isOffscreen) { if (dc.StaticBuffer is not GpuSeriesBuffer seriesBuffer || seriesBuffer.Buffer == null || seriesBuffer.PointsCount < 2) return; + EnsureChartLinePipelines(); var wgpu = _context.Api; var device = _context.Device; @@ -14430,6 +14739,7 @@ private unsafe void RenderChartLine(RenderPassEncoder* pass, CompositorDrawCall private unsafe void RenderChartScatter(RenderPassEncoder* pass, CompositorDrawCall dc, bool isOffscreen) { if (dc.StaticBuffer is not GpuSeriesBuffer seriesBuffer || seriesBuffer.Buffer == null || seriesBuffer.PointsCount < 1) return; + EnsureChartScatterPipelines(); var wgpu = _context.Api; var device = _context.Device; @@ -14542,7 +14852,7 @@ private GpuTexture GetMaskTexture(uint width, uint height) { if (maskTexture == null) { - return isOffscreen ? _dummyMaskBindGroupOffscreen : _dummyMaskBindGroup; + return _dummyMaskBindGroup; } if (!CanReuseMaskTexture(maskTexture)) @@ -14550,7 +14860,7 @@ private GpuTexture GetMaskTexture(uint width, uint height) throw new ObjectDisposedException(nameof(GpuTexture), "Cannot bind a disposed or foreign-context mask texture."); } - var cache = isOffscreen ? _maskBindGroupsOffscreen : _maskBindGroups; + var cache = _maskBindGroups; if (cache.TryGetValue(maskTexture, out var bgNint)) { return (BindGroup*)bgNint; @@ -14562,7 +14872,7 @@ private GpuTexture GetMaskTexture(uint width, uint height) var bgDescMask = new BindGroupDescriptor { - Layout = isOffscreen ? _maskBindGroupLayoutOffscreen : _maskBindGroupLayout, + Layout = _maskBindGroupLayout, EntryCount = 2, Entries = maskEntries }; @@ -14686,7 +14996,26 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => blendMode == GpuBlendMode.SrcOver && !hasMask) { - return isOffscreen ? _solidRectPipelineOffscreen : _solidRectPipeline; + var cachedPipeline = isOffscreen + ? _solidRectPipelineOffscreen + : _solidRectPipeline; + if (cachedPipeline != null) + { + return cachedPipeline; + } + } + + var selectionKey = new PipelineSelectionKey( + DrawCallType.Vector, + 1, + blendMode, + isOffscreen, + overrideFormat, + GpuTextureAlphaMode.Straight, + hasMask); + if (_selectedPipelines.TryGetValue(selectionKey, out var selectedPipeline)) + { + return (RenderPipeline*)selectedPipeline; } var shaderModule = _pipelineCache.GetOrCreateShader( @@ -14725,7 +15054,7 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => hasMask); string targetKey = overrideFormat.HasValue ? $"_{overrideFormat.Value}" : string.Empty; string pipelineKey = $"SolidRect_{(isOffscreen ? "Offscreen_" : string.Empty)}{blendMode}{targetKey}_{fragmentEntryPoint}"; - return _pipelineCache.GetOrCreateRenderPipeline( + var pipeline = _pipelineCache.GetOrCreateRenderPipeline( pipelineKey, shaderModule, layouts, @@ -14742,6 +15071,8 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => DrawCallType.Vector, blendMode, GpuTextureAlphaMode.Straight)); + _selectedPipelines[selectionKey] = (nint)pipeline; + return pipeline; } } @@ -14764,6 +15095,19 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => } } + var selectionKey = new PipelineSelectionKey( + DrawCallType.Vector, + 2, + blendMode, + isOffscreen, + overrideFormat, + GpuTextureAlphaMode.Straight, + hasMask); + if (_selectedPipelines.TryGetValue(selectionKey, out var selectedPipeline)) + { + return (RenderPipeline*)selectedPipeline; + } + var shaderModule = _pipelineCache.GetOrCreateShader( "Vector", Shaders.VectorShader, @@ -14830,6 +15174,10 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => _solidRoundedPipeline = pipeline; } } + else + { + _selectedPipelines[selectionKey] = (nint)pipeline; + } return pipeline; } @@ -14869,6 +15217,38 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => TextureFormat? overrideFormat = null, GpuTextureAlphaMode textureAlphaMode = GpuTextureAlphaMode.Premultiplied, bool hasMask = true) + { + var key = new PipelineSelectionKey( + type, + 0, + blendMode, + isOffscreen, + overrideFormat, + textureAlphaMode, + hasMask); + if (_selectedPipelines.TryGetValue(key, out var selectedPipeline)) + { + return (RenderPipeline*)selectedPipeline; + } + + var pipeline = CreatePipeline( + type, + blendMode, + isOffscreen, + overrideFormat, + textureAlphaMode, + hasMask); + _selectedPipelines[key] = (nint)pipeline; + return pipeline; + } + + private RenderPipeline* CreatePipeline( + DrawCallType type, + GpuBlendMode blendMode, + bool isOffscreen, + TextureFormat? overrideFormat, + GpuTextureAlphaMode textureAlphaMode, + bool hasMask) { uint sampleCount = isOffscreen ? 1u : Options.PrimarySampleCount; @@ -14876,17 +15256,35 @@ private static byte GetVectorPipelineKind(in CompositorDrawCall drawCall) => { if (type == DrawCallType.Text && !hasMask) { - return isOffscreen ? _textPipelineOffscreen : _textPipeline; + var cachedPipeline = isOffscreen + ? _textPipelineOffscreen + : _textPipeline; + if (cachedPipeline != null) + { + return cachedPipeline; + } } if (type == DrawCallType.Vector && !hasMask) { - return isOffscreen ? _vectorPipelineOffscreen : _vectorPipeline; + var cachedPipeline = isOffscreen + ? _vectorPipelineOffscreen + : _vectorPipeline; + if (cachedPipeline != null) + { + return cachedPipeline; + } } if (type == DrawCallType.Texture && textureAlphaMode == GpuTextureAlphaMode.Premultiplied) { - return isOffscreen ? _texturePipelineOffscreen : _texturePipeline; + var cachedPipeline = isOffscreen + ? _texturePipelineOffscreen + : _texturePipeline; + if (cachedPipeline != null) + { + return cachedPipeline; + } } } diff --git a/src/ProGPU.Scene/CompositorOptions.cs b/src/ProGPU.Scene/CompositorOptions.cs index c3dc73c0..10692c61 100644 --- a/src/ProGPU.Scene/CompositorOptions.cs +++ b/src/ProGPU.Scene/CompositorOptions.cs @@ -9,8 +9,16 @@ public sealed record CompositorOptions public uint GlyphAtlasSize { get; init; } = 2048; + public uint InitialGlyphAtlasSize { get; init; } = GlyphAtlas.DefaultInitialAtlasSize; + public uint ColorGlyphAtlasSize { get; init; } = GlyphAtlas.DefaultColorAtlasSize; + public uint InitialColorGlyphAtlasSize { get; init; } = + GlyphAtlas.DefaultInitialColorAtlasSize; + + public uint GlyphUniformStagingBytes { get; init; } = + GlyphAtlas.DefaultUniformRingBufferSize; + public uint GlyphCoverageStagingBytes { get; init; } = GlyphAtlas.DefaultCoverageRingBufferSize; public uint PathAtlasSize { get; init; } = 2048; @@ -22,10 +30,16 @@ public sealed record CompositorOptions public uint InitialIndexCount { get; init; } = 24576; + public uint InitialBrushCount { get; init; } = 64; + + public uint InitialGradientStopCount { get; init; } = 512; + public bool EnableGpuHitTesting { get; init; } = true; public bool EnableCompiledSceneCache { get; init; } = true; + public bool PrecompileBasePipelines { get; init; } + public uint PrimarySampleCount { get; init; } = 4; internal void Validate() @@ -34,10 +48,22 @@ internal void Validate() { throw new ArgumentOutOfRangeException(nameof(GlyphAtlasSize)); } + if (InitialGlyphAtlasSize <= 4) + { + throw new ArgumentOutOfRangeException(nameof(InitialGlyphAtlasSize)); + } if (ColorGlyphAtlasSize <= 4) { throw new ArgumentOutOfRangeException(nameof(ColorGlyphAtlasSize)); } + if (InitialColorGlyphAtlasSize <= 4) + { + throw new ArgumentOutOfRangeException(nameof(InitialColorGlyphAtlasSize)); + } + if (GlyphUniformStagingBytes < 256 || GlyphUniformStagingBytes % 256 != 0) + { + throw new ArgumentOutOfRangeException(nameof(GlyphUniformStagingBytes)); + } if (GlyphCoverageStagingBytes < 256 || GlyphCoverageStagingBytes % 256 != 0) { throw new ArgumentOutOfRangeException(nameof(GlyphCoverageStagingBytes)); @@ -58,6 +84,15 @@ internal void Validate() { throw new ArgumentOutOfRangeException(nameof(InitialIndexCount)); } + if (InitialBrushCount == 0 || InitialBrushCount > Compositor.MaxBrushes) + { + throw new ArgumentOutOfRangeException(nameof(InitialBrushCount)); + } + if (InitialGradientStopCount == 0 || + InitialGradientStopCount > Compositor.MaxGradientStops) + { + throw new ArgumentOutOfRangeException(nameof(InitialGradientStopCount)); + } if (PrimarySampleCount is not (1 or 4)) { throw new ArgumentOutOfRangeException(nameof(PrimarySampleCount)); diff --git a/src/ProGPU.Scene/RenderCommand.cs b/src/ProGPU.Scene/RenderCommand.cs index 4eec5cc6..1451483a 100644 --- a/src/ProGPU.Scene/RenderCommand.cs +++ b/src/ProGPU.Scene/RenderCommand.cs @@ -1135,6 +1135,36 @@ public void DrawPath(Brush? brush, Pen? pen, PathGeometry path, Matrix4x4 transf }); } + /// + /// Records a transformed retained path using a cache previously created for the same + /// geometry. The transform remains command-local and does not affect cache identity. + /// + public void DrawPath( + Brush? brush, + Pen? pen, + PathGeometry path, + Matrix4x4 transform, + RenderCommandGeometryCache geometryCache) + { + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(geometryCache); + if ((brush != null && !ReferenceEquals(geometryCache.FillPath, path)) || + (pen != null && !ReferenceEquals(geometryCache.StrokePath, path))) + { + throw new ArgumentException("The retained geometry cache does not match the path.", nameof(geometryCache)); + } + + Commands.Add(new RenderCommand + { + Type = RenderCommandType.DrawPath, + Brush = brush, + Pen = pen, + Path = path, + Transform = transform, + GeometryCache = geometryCache + }); + } + public void DrawText( string text, TtfFont font, @@ -1464,8 +1494,10 @@ public void DrawLine(Pen pen, Vector2 p1, Vector2 p2) Pen = pen, Position = p1, Position2 = p2, - GeometryCache = RenderCommandGeometryCache.ForStrokePath( - RenderCommandGeometryCache.CreateLinePath(p1, p2)) + GeometryCache = pen.HasDashPattern + ? RenderCommandGeometryCache.ForStrokePath( + RenderCommandGeometryCache.CreateLinePath(p1, p2)) + : null }); } diff --git a/src/ProGPU.Tests.Headless/SamplePagesTests.cs b/src/ProGPU.Tests.Headless/SamplePagesTests.cs index c7c57791..921eaeb4 100644 --- a/src/ProGPU.Tests.Headless/SamplePagesTests.cs +++ b/src/ProGPU.Tests.Headless/SamplePagesTests.cs @@ -725,7 +725,10 @@ public unsafe void Test_ComputeFxPage_Renders() EnsureFontsAndStateLoaded(); var context = HeadlessWindow.Shared.Context; - AppState._offscreenCompositor = new Compositor(context, Silk.NET.WebGPU.TextureFormat.Rgba8Unorm); + AppState._offscreenCompositor = new Compositor( + context, + Silk.NET.WebGPU.TextureFormat.Rgba8Unorm, + CompositorOptions.Default with { EnableGpuHitTesting = false }); AppState._compute = new ProGPU.Compute.ComputeAccelerator(context); AppState._canvasSourceTexture = new ProGPU.Backend.GpuTexture(context, 600, 600, Silk.NET.WebGPU.TextureFormat.Rgba8Unorm, @@ -766,6 +769,8 @@ public unsafe void Test_ComputeFxPage_Renders() AppState._canvasShadowTexture.Resize(canvasW, canvasH); AppState._offscreenCompositor.RenderScene(AppState._gearCanvasVisual, canvasW, canvasH, AppState._canvasSourceTexture.ViewPtr); + Assert.Null(AppState._offscreenCompositor.LastHitTestIndex); + Assert.Null(AppState._offscreenCompositor.LastHitTestDeviceIndex); if (AppState._shadowRadius > 0 && AppState._blurRadius > 0) { diff --git a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs index 56ec442f..507fede3 100644 --- a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs +++ b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs @@ -39,6 +39,16 @@ public void BidiParagraphUsesExplicitRtlBaseForLeadingLatin() Assert.Equal(new[] { 6, 5, 4, 3, 0, 1, 2 }, BidiParagraph.GetVisualOrder(paragraph.Utf16Levels)); } + [Fact] + public void BidiParagraphOmitsOnlyIdentityVisualOrderStorage() + { + Assert.Null(BidiParagraph.GetVisualOrderIfNeeded([0, 0, 0])); + Assert.Null(BidiParagraph.GetVisualOrderIfNeeded([2, 2, 2])); + Assert.Equal( + new[] { 0, 1, 2, 3, 6, 5, 4 }, + BidiParagraph.GetVisualOrderIfNeeded([0, 0, 0, 0, 1, 1, 1])); + } + [Fact] public void BidiParagraphAppliesInlineDirectionAsAnIsolateWithoutChangingTextLength() { @@ -84,6 +94,63 @@ public void BidiParagraphHonorsIsolatesAndPairedBrackets() Assert.Equal(paragraph.Utf16Levels.Length, "אבג \u2066(abc 123)\u2069 דהו".Length); } + [Fact] + public void BidiParagraphAsciiFastPathMatchesUax9ForEveryPair() + { + Span text = stackalloc char[2]; + foreach (ShapingDirection direction in new[] + { + ShapingDirection.LeftToRight, + ShapingDirection.Unspecified + }) + { + for (int first = 0; first <= 0x7F; first++) + { + text[0] = (char)first; + for (int second = 0; second <= 0x7F; second++) + { + text[1] = (char)second; + AssertMatchesStandardResolver(text, direction); + } + } + } + } + + [Theory] + [InlineData("")] + [InlineData("Font Glyph Browser")] + [InlineData("()[]{} 0123 +-$\t")] + [InlineData("\0\u0001\u0009\u000A\u000D\u001B\u001F\u007F")] + public void BidiParagraphAsciiFastPathPreservesResultOwnership(string text) + { + BidiParagraph first = BidiParagraph.Resolve(text, ShapingDirection.LeftToRight); + BidiParagraph second = BidiParagraph.Resolve(text, ShapingDirection.LeftToRight); + + AssertMatchesStandardResolver(text, ShapingDirection.LeftToRight); + if (text.Length == 0) + { + Assert.Same(first, second); + } + else + { + Assert.NotSame(first, second); + Assert.NotSame(first.Utf16Levels, second.Utf16Levels); + Assert.NotSame(first.Runs, second.Runs); + } + } + + [Fact] + public void BidiParagraphExplicitRtlAsciiStillUsesStandardLevels() + { + const string text = "abc 123"; + + BidiParagraph paragraph = BidiParagraph.Resolve(text, ShapingDirection.RightToLeft); + + AssertMatchesStandardResolver(text, ShapingDirection.RightToLeft); + Assert.Equal(1, paragraph.ParagraphLevel); + Assert.Contains(paragraph.Utf16Levels, static level => level != 0); + } + [Fact] public void TextLayoutShapesLogicalRunsAndPlacesThemInVisualOrder() { @@ -98,6 +165,51 @@ public void TextLayoutShapesLogicalRunsAndPlacesThemInVisualOrder() Assert.True(layout.Glyphs.Zip(layout.Glyphs.Skip(1), static (left, right) => left.Position.X <= right.Position.X).All(static value => value)); } + private static void AssertMatchesStandardResolver( + ReadOnlySpan text, + ShapingDirection direction) + { + sbyte requestedLevel = direction switch + { + ShapingDirection.LeftToRight => 0, + ShapingDirection.RightToLeft => 1, + _ => 2 + }; + (sbyte expectedParagraphLevel, Uax9Resolver.ScalarLevel[] scalarLevels) = + Uax9Resolver.Resolve(text, requestedLevel); + var expectedLevels = new sbyte[text.Length]; + for (int index = 0; index < scalarLevels.Length; index++) + { + Uax9Resolver.ScalarLevel scalar = scalarLevels[index]; + expectedLevels.AsSpan(scalar.Utf16Start, scalar.Utf16Length).Fill(scalar.Level); + } + + BidiParagraph actual = BidiParagraph.Resolve(text, direction); + Assert.Equal(expectedParagraphLevel, actual.ParagraphLevel); + Assert.Equal(expectedLevels, actual.Utf16Levels); + + var expectedRuns = new List(); + if (expectedLevels.Length > 0) + { + int start = 0; + for (int index = 1; index <= expectedLevels.Length; index++) + { + if (index < expectedLevels.Length && + expectedLevels[index] == expectedLevels[start]) + { + continue; + } + + expectedRuns.Add(new BidiRun( + start, + index - start, + expectedLevels[start])); + start = index; + } + } + Assert.Equal(expectedRuns, actual.Runs); + } + [Fact] public void TextLayoutHitTestingCaretStopsAndSelectionUseShapedBidiClusters() { diff --git a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs index 91eaa205..ad4d0abd 100644 --- a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs +++ b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs @@ -13,6 +13,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using ProGPU.Backend; +using ProGPU.Compute; using ProGPU.Fonts.Inter; using ProGPU.Scene; using ProGPU.Scene.Extensions; @@ -781,6 +782,48 @@ public void PathAtlasRetryUsesAlternativeDeterministicStrategyForFeasibleSet() Assert.Equal(firstPacking, repeated.Select(static info => (info.X, info.Y)).ToArray()); } + [Fact] + public void RectangularPathAtlasRecoveryUsesIndependentExtents() + { + using var atlas = new PathAtlas(HeadlessWindow.Shared.Context, atlasSize: 2048); + PathGeometry[] paths = + [ + PrimitivePathGeometry.CreateRectangle(0f, 0f, 24f, 900f), + PrimitivePathGeometry.CreateRectangle(40f, 0f, 40f, 40f), + PrimitivePathGeometry.CreateRectangle(90f, 0f, 36f, 42f), + PrimitivePathGeometry.CreateRectangle(140f, 0f, 38f, 38f), + PrimitivePathGeometry.CreateRectangle(190f, 0f, 34f, 44f) + ]; + + foreach (PathGeometry path in paths) + { + _ = atlas.GetOrCreatePath(path, scale: 1f); + } + + Assert.Equal(512u, atlas.AtlasWidth); + Assert.Equal(1024u, atlas.AtlasHeight); + atlas.ResetForRenderRetry(); + PathAtlas.PathInfo[] recovered = paths + .Select(path => atlas.GetOrCreatePath(path, scale: 1f)) + .ToArray(); + + Assert.False(atlas.CapacityExceeded); + AssertAtlasRectanglesDoNotOverlap( + recovered, + atlas.AtlasWidth, + atlas.AtlasHeight); + Assert.All(recovered, static info => + { + Assert.InRange(info.TexCoordMin.X, 0f, 1f); + Assert.InRange(info.TexCoordMin.Y, 0f, 1f); + Assert.InRange(info.TexCoordMax.X, 0f, 1f); + Assert.InRange(info.TexCoordMax.Y, 0f, 1f); + }); + + atlas.RasterizePendingPaths(); + Assert.Contains(atlas.AtlasTexture.ReadPixels(), static value => value > 0); + } + [Fact] public void PathAtlasRetryRejectsMathematicallyUnfitTypographyLiveSet() { @@ -952,13 +995,19 @@ public void CrossingCanonicalRoundedRingFallsBackToPathAtlas() private static void AssertAtlasRectanglesDoNotOverlap( IReadOnlyList paths, - uint atlasSize) + uint atlasSize) => + AssertAtlasRectanglesDoNotOverlap(paths, atlasSize, atlasSize); + + private static void AssertAtlasRectanglesDoNotOverlap( + IReadOnlyList paths, + uint atlasWidth, + uint atlasHeight) { for (int index = 0; index < paths.Count; index++) { PathAtlas.PathInfo current = paths[index]; - Assert.True(current.X + current.Width + 2 <= atlasSize); - Assert.True(current.Y + current.Height + 2 <= atlasSize); + Assert.True(current.X + current.Width + 2 <= atlasWidth); + Assert.True(current.Y + current.Height + 2 <= atlasHeight); for (int otherIndex = index + 1; otherIndex < paths.Count; otherIndex++) { PathAtlas.PathInfo other = paths[otherIndex]; @@ -1099,6 +1148,50 @@ public void CompositorFailsExplicitlyWhenSingleArbitraryPathExceedsAtlas() Assert.Contains("64x64", exception.Message, StringComparison.Ordinal); } + [Fact] + public unsafe void TallPathGrowsOnlyAtlasHeightAndRendersOnTheFirstFrame() + { + using var window = new HeadlessWindow( + 64, + 1024, + CompositorOptions.Default with { PathAtlasSize = 2048 }); + var visual = new DrawingVisual + { + Size = new Vector2(64f, 1024f) + }; + using var target = new GpuTexture( + window.Context, + 64, + 1024, + TextureFormat.Rgba8Unorm, + TextureUsage.RenderAttachment | TextureUsage.CopySrc, + "Rectangular path-atlas render target"); + visual.Context.DrawPath( + new SolidColorBrush(new Vector4(1f, 0f, 0f, 1f)), + pen: null, + PrimitivePathGeometry.CreateRoundedRectangle( + 12f, + 40f, + 24f, + 900f, + 3f, + 3f)); + + window.Compositor.RenderScene(visual, 64, 1024, target.ViewPtr); + + Assert.Equal(512u, window.Compositor.PathAtlas.AtlasWidth); + Assert.Equal(1024u, window.Compositor.PathAtlas.AtlasHeight); + Assert.Equal(512UL * 1024UL, window.Compositor.PathAtlas.PersistentTextureBytes); + byte[] pixels = target.ReadPixels(); + int center = (512 * 64 + 24) * 4; + Assert.True(pixels[center] > 200); + Assert.True(pixels[center + 3] > 200); + + window.Compositor.RenderScene(visual, 64, 1024, target.ViewPtr); + Assert.Equal(512u, window.Compositor.PathAtlas.AtlasWidth); + Assert.Equal(1024u, window.Compositor.PathAtlas.AtlasHeight); + } + private static DrawingVisual CreatePathAtlasPressureDrawingVisual(int pathCount, int variant) { var visual = new DrawingVisual { Size = new Vector2(640f, 64f) }; @@ -2289,6 +2382,187 @@ public void GpuSeriesDrawCallColorsIncludeBrushAndActiveOpacity() } } + [Fact] + public void ChartPipelinesAreCreatedOnlyByChartCommandsAndThenReused() + { + using var window = new HeadlessWindow(64, 64); + window.Content = new GpuSeriesOpacityVisual(); + + window.Render(); + + Assert.Equal(2, window.Compositor.Metrics.SceneShaderCount); + Assert.Equal(4, window.Compositor.Metrics.SceneRenderPipelineCount); + Assert.Contains(window.ReadPixels(), static value => value != 0); + + window.Render(); + + Assert.Equal(2, window.Compositor.Metrics.SceneShaderCount); + Assert.Equal(4, window.Compositor.Metrics.SceneRenderPipelineCount); + } + + [Fact] + public unsafe void BasePipelinesAreCreatedOnlyForTheRenderedTargetAndThenReused() + { + using var window = new HeadlessWindow(32, 32); + using var compositor = new Compositor( + window.Context, + TextureFormat.Rgba8Unorm, + CompositorOptions.Default with { EnableGpuHitTesting = false }); + using var target = new GpuTexture( + window.Context, + 32, + 32, + TextureFormat.Rgba8Unorm, + TextureUsage.RenderAttachment | TextureUsage.CopySrc, + "Lazy target pipeline test"); + var visual = new DrawingVisual { Size = new Vector2(32f, 32f) }; + visual.Context.DrawRectangle( + new SolidColorBrush(new Vector4(1f, 0f, 0f, 1f)), + pen: null, + new Rect(0f, 0f, 32f, 32f)); + RenderPipelineCache pipelineCache = + GetPrivateField(compositor, "_pipelineCache"); + + Assert.Equal(0, pipelineCache.ShaderCount); + Assert.Equal(0, pipelineCache.RenderPipelineCount); + + compositor.RenderScene(visual, 32, 32, target.ViewPtr); + + Assert.Equal(1, pipelineCache.ShaderCount); + Assert.Equal(1, pipelineCache.RenderPipelineCount); + Assert.True(target.ReadPixels()[3] > 200); + + compositor.RenderScene(visual, 32, 32, target.ViewPtr); + Assert.Equal(1, pipelineCache.RenderPipelineCount); + + compositor.RenderOffscreen( + visual, + width: 32, + height: 32, + targetTexture: target, + padding: 0f, + dpiScale: 1f); + + Assert.Equal(1, pipelineCache.ShaderCount); + Assert.Equal(2, pipelineCache.RenderPipelineCount); + Assert.True(target.ReadPixels()[3] > 200); + + compositor.RenderOffscreen( + visual, + width: 32, + height: 32, + targetTexture: target, + padding: 0f, + dpiScale: 1f); + Assert.Equal(2, pipelineCache.RenderPipelineCount); + } + + [Fact] + public unsafe void PrimaryAndOffscreenTargetsShareBindingObjectsAcrossRefreshes() + { + using var window = new HeadlessWindow(32, 32); + using var compositor = new Compositor( + window.Context, + TextureFormat.Rgba8Unorm, + CompositorOptions.Default with + { + EnableGpuHitTesting = false, + InitialBrushCount = 1 + }); + using var target = new GpuTexture( + window.Context, + 32, + 32, + TextureFormat.Rgba8Unorm, + TextureUsage.RenderAttachment | TextureUsage.CopySrc, + "Shared target bindings test"); + var visual = new DrawingVisual { Size = new Vector2(32f, 32f) }; + for (int i = 0; i < 128; i++) + { + visual.Context.DrawRectangle( + new SolidColorBrush( + new Vector4(i / 127f, (127 - i) / 127f, (i % 17) / 16f, 1f)), + pen: null, + new Rect(i % 32, i / 32, 1f, 1f)); + } + + AssertSharedTargetBindingObjects(compositor); + nint vectorBindGroupBefore = + GetPrivatePointerField(compositor, "_vectorUniformBindGroup"); + List activeBrushes = + GetPrivateField>(compositor, "_activeBrushes"); + for (int i = 0; i < 1024; i++) + { + activeBrushes.Add(default); + } + InvokePrivateMethod(compositor, "EnsureSceneStateBufferCapacity"); + + Assert.NotEqual( + vectorBindGroupBefore, + GetPrivatePointerField(compositor, "_vectorUniformBindGroup")); + AssertSharedTargetBindingObjects(compositor); + + compositor.RenderScene(visual, 32, 32, target.ViewPtr); + + Assert.Equal(8, compositor.Metrics.BaseBindGroupLayoutCount); + Assert.Equal(4, compositor.Metrics.BasePipelineLayoutCount); + Assert.Equal(6, compositor.Metrics.BaseBindGroupCount); + Assert.True(target.ReadPixels()[3] > 200); + + nint atlasBindGroupBefore = GetPrivatePointerField(compositor, "_atlasBindGroup"); + nint pathAtlasBindGroupBefore = + GetPrivatePointerField(compositor, "_pathAtlasBindGroup"); + SetCompositorField(compositor, "_boundGlyphAtlasTextureRevision", ulong.MaxValue); + SetCompositorField(compositor, "_boundPathAtlasTextureRevision", ulong.MaxValue); + InvokePrivateMethod(compositor, "RefreshAtlasBindGroupsIfNeeded"); + + Assert.NotEqual( + atlasBindGroupBefore, + GetPrivatePointerField(compositor, "_atlasBindGroup")); + Assert.NotEqual( + pathAtlasBindGroupBefore, + GetPrivatePointerField(compositor, "_pathAtlasBindGroup")); + AssertSharedTargetBindingObjects(compositor); + + compositor.RenderOffscreen( + visual, + width: 32, + height: 32, + targetTexture: target, + padding: 0f, + dpiScale: 1f); + + AssertSharedTargetBindingObjects(compositor); + Assert.Equal(8, compositor.Metrics.BaseBindGroupLayoutCount); + Assert.Equal(4, compositor.Metrics.BasePipelineLayoutCount); + Assert.Equal(6, compositor.Metrics.BaseBindGroupCount); + Assert.True(target.ReadPixels()[3] > 200); + + var maskTexture = new GpuTexture( + window.Context, + 8, + 8, + TextureFormat.R8Unorm, + TextureUsage.TextureBinding | TextureUsage.RenderAttachment, + "Shared target mask binding test"); + nint primaryMaskBindGroup = InvokePrivatePointerMethod( + compositor, + "GetMaskBindGroup", + maskTexture, + false); + nint offscreenMaskBindGroup = InvokePrivatePointerMethod( + compositor, + "GetMaskBindGroup", + maskTexture, + true); + Assert.Equal(primaryMaskBindGroup, offscreenMaskBindGroup); + Assert.Single( + GetPrivateField>(compositor, "_maskBindGroups")); + maskTexture.Dispose(); + Assert.Empty( + GetPrivateField>(compositor, "_maskBindGroups")); + } + [Fact] public void CachedLayerRefreshesWhenPhysicalTextureSizeChanges() { @@ -3188,6 +3462,230 @@ public unsafe void GpuTextureBlitterPreservesPixelOrientationAndChannels() destination.ReadPixels()); } + [Fact] + public void ComputeAcceleratorCreatesOnlyTheRequestedEffectFamilyAndReusesIt() + { + using var window = new HeadlessWindow(4, 4); + const TextureUsage usage = + TextureUsage.TextureBinding | + TextureUsage.StorageBinding | + TextureUsage.CopySrc | + TextureUsage.CopyDst; + using var source = new GpuTexture( + window.Context, + 4, + 4, + TextureFormat.Rgba8Unorm, + usage, + "Lazy compute source"); + using var temporary = new GpuTexture( + window.Context, + 4, + 4, + TextureFormat.Rgba8Unorm, + usage, + "Lazy compute temporary"); + using var destination = new GpuTexture( + window.Context, + 4, + 4, + TextureFormat.Rgba8Unorm, + usage, + "Lazy compute destination"); + using var accelerator = new ComputeAccelerator(window.Context); + + Assert.Equal(0, accelerator.CachedEffectShaderCount); + Assert.Equal(0, accelerator.CachedEffectPipelineCount); + Assert.Equal(0UL, accelerator.PersistentEffectParameterBufferBytes); + + byte[] pixels = new byte[4 * 4 * 4]; + for (int offset = 0; offset < pixels.Length; offset += 4) + { + pixels[offset] = 64; + pixels[offset + 1] = 128; + pixels[offset + 2] = 192; + pixels[offset + 3] = 255; + } + source.WritePixels(pixels); + + accelerator.ApplyGaussianBlur(source, temporary, destination, 1f); + byte[] firstResult = destination.ReadPixels(); + + Assert.Equal(2, accelerator.CachedEffectShaderCount); + Assert.Equal(2, accelerator.CachedEffectPipelineCount); + Assert.Equal(32UL, accelerator.PersistentEffectParameterBufferBytes); + Assert.Contains(firstResult, static value => value != 0); + + accelerator.ApplyGaussianBlur(source, temporary, destination, 1f); + + Assert.Equal(2, accelerator.CachedEffectShaderCount); + Assert.Equal(2, accelerator.CachedEffectPipelineCount); + Assert.Equal(32UL, accelerator.PersistentEffectParameterBufferBytes); + Assert.Equal(firstResult, destination.ReadPixels()); + } + + [Fact] + public void ComputeAcceleratorLazilyCreatesEveryEffectFamilyOnFirstUse() + { + using var window = new HeadlessWindow(4, 4); + const TextureUsage usage = + TextureUsage.TextureBinding | + TextureUsage.StorageBinding | + TextureUsage.CopySrc | + TextureUsage.CopyDst; + using var source = new GpuTexture( + window.Context, 4, 4, TextureFormat.Rgba8Unorm, usage, "Lazy effect source"); + using var secondary = new GpuTexture( + window.Context, 4, 4, TextureFormat.Rgba8Unorm, usage, "Lazy effect secondary"); + using var temporary = new GpuTexture( + window.Context, 4, 4, TextureFormat.Rgba8Unorm, usage, "Lazy effect temporary"); + using var destination = new GpuTexture( + window.Context, 4, 4, TextureFormat.Rgba8Unorm, usage, "Lazy effect destination"); + using var accelerator = new ComputeAccelerator(window.Context); + var opaquePixels = new byte[4 * 4 * 4]; + for (int offset = 0; offset < opaquePixels.Length; offset += 4) + { + opaquePixels[offset] = 32; + opaquePixels[offset + 1] = 64; + opaquePixels[offset + 2] = 96; + opaquePixels[offset + 3] = 255; + } + source.WritePixels(opaquePixels); + secondary.WritePixels(opaquePixels); + + accelerator.ApplyMorphology(source, temporary, destination, 1f, 1f, dilate: true); + Assert.Equal(1, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyImageLighting( + source, + destination, + new Vector3(2f, 2f, 4f), + lightType: 0, + lightTarget: Vector3.Zero, + spotExponent: 1f, + lightColor: Vector4.One, + surfaceScale: 1f, + lightingConstant: 1f, + shininess: 8f, + cutoffAngle: 45f, + specular: false); + Assert.Equal(2, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyMatrixConvolution( + source, + destination, + kernelWidth: 1, + kernelHeight: 1, + [1f], + gain: 1f, + bias: 0f, + kernelOffsetX: 0, + kernelOffsetY: 0, + tileMode: 0, + convolveAlpha: true, + tileOriginX: 0, + tileOriginY: 0, + tileWidth: 4, + tileHeight: 4); + Assert.Equal(3, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyDisplacementMap( + source, + secondary, + destination, + scale: 1f, + xChannel: 0, + yChannel: 1); + Assert.Equal(4, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyArithmeticComposite( + source, + secondary, + destination, + k1: 0f, + k2: 1f, + k3: 0f, + k4: 0f, + enforcePremultipliedColor: true); + Assert.Equal(5, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyImageBlend( + source, + secondary, + destination, + GpuBlendMode.SrcOver, + linearRgb: true); + Assert.Equal(6, accelerator.CachedEffectPipelineCount); + + byte[] identityTable = Enumerable.Range(0, 256).Select(static value => (byte)value).ToArray(); + accelerator.ApplyColorTable( + source, + destination, + identityTable, + identityTable, + identityTable, + identityTable); + Assert.Equal(7, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyNonlinearColorFilter( + source, + destination, + ReadOnlySpan.Empty, + hsla: false, + grayscale: false, + invertStyle: 0, + contrast: 0f); + Assert.Equal(8, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyMagnifier( + source, + destination, + new Vector4(0f, 0f, 4f, 4f), + new Vector4(0f, 0f, 4f, 4f), + new Vector4(1f, 1f, 0f, 0f), + Vector2.Zero, + samplingMode: 0, + cubic: Vector2.Zero); + Assert.Equal(9, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyDropShadow( + source, + temporary, + destination, + Vector2.Zero, + Vector4.One, + blurRadius: 0f); + Assert.Equal(10, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyDropShadow( + source, + temporary, + destination, + Vector2.Zero, + Vector4.One, + blurRadius: 2f); + Assert.Equal(12, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyGaussianBlur(source, temporary, destination, sigma: 1f); + Assert.Equal(14, accelerator.CachedEffectPipelineCount); + + accelerator.ApplyDropShadowAndGaussianBlur( + source, + temporary, + secondary, + destination, + Vector2.Zero, + Vector4.One, + shadowBlurRadius: 1f, + gaussianSigma: 1f); + Assert.Equal(16, accelerator.CachedEffectPipelineCount); + Assert.Equal(16, accelerator.CachedEffectShaderCount); + + int retainedPipelineCount = accelerator.CachedEffectPipelineCount; + accelerator.ApplyMorphology(source, temporary, destination, 1f, 1f, dilate: true); + Assert.Equal(retainedPipelineCount, accelerator.CachedEffectPipelineCount); + } + [Fact] public void CompositorOptionsControlEagerGpuReservations() { @@ -3195,15 +3693,26 @@ public void CompositorOptionsControlEagerGpuReservations() var options = new CompositorOptions { GlyphAtlasSize = 256, + InitialGlyphAtlasSize = 128, PathAtlasSize = 512, PathAtlasCpuCacheBudgetBytes = 2048, + InitialColorGlyphAtlasSize = 32, + GlyphUniformStagingBytes = 8 * 1024, + GlyphCoverageStagingBytes = 16 * 1024, InitialVertexCount = 1024, - InitialIndexCount = 1536 + InitialIndexCount = 1536, + InitialBrushCount = 8, + InitialGradientStopCount = 16, + PrecompileBasePipelines = true }; using var compositor = new Compositor(window.Context, TextureFormat.Rgba8Unorm, options); Assert.Same(options, compositor.Options); - Assert.Equal(256u, compositor.Atlas.AtlasSize); + Assert.Equal(128u, compositor.Atlas.AtlasSize); + Assert.Equal(32u, compositor.Atlas.ColorAtlasSize); + Assert.Equal(8UL * 1024UL, compositor.Atlas.UniformStagingBytes); + Assert.Equal(8UL * 1024UL, compositor.Atlas.UniformUploadBytes); + Assert.Equal(16UL * 1024UL, compositor.Atlas.CoverageStagingBytes); Assert.Equal(512u, compositor.PathAtlas.AtlasSize); Assert.Equal(2048, compositor.PathAtlas.CompiledPathCacheBudgetBytes); Assert.Equal( @@ -3212,6 +3721,132 @@ public void CompositorOptionsControlEagerGpuReservations() Assert.Equal( options.InitialIndexCount * sizeof(uint), GetCompositorField(compositor, "_vectorIndexBuffer").Size); + Assert.Equal( + options.InitialBrushCount * (uint)Marshal.SizeOf(), + GetCompositorField(compositor, "_brushesStorageBuffer").Size); + Assert.Equal( + options.InitialGradientStopCount * (uint)Marshal.SizeOf(), + GetCompositorField(compositor, "_gradientStopsStorageBuffer").Size); + Assert.Equal( + GetCompositorField(compositor, "_uniformBuffer").AllocatedSize + + GetCompositorField(compositor, "_brushesStorageBuffer").AllocatedSize + + GetCompositorField(compositor, "_gradientStopsStorageBuffer").AllocatedSize + + GetCompositorField(compositor, "_vectorVertexBuffer").AllocatedSize + + GetCompositorField(compositor, "_vectorIndexBuffer").AllocatedSize + + GetCompositorField(compositor, "_textVertexBuffer").AllocatedSize + + GetCompositorField(compositor, "_textureVertexBuffer").AllocatedSize + + GetCompositorField(compositor, "_textureIndexBuffer").AllocatedSize, + compositor.PersistentSceneBufferBytes); + RenderPipelineCache pipelineCache = + GetPrivateField(compositor, "_pipelineCache"); + Assert.Equal(3, pipelineCache.ShaderCount); + Assert.Equal(8, pipelineCache.RenderPipelineCount); + } + + [Fact] + public void CompactColorGlyphMaximumClampsTheDefaultInitialReservation() + { + using var window = new HeadlessWindow(1, 1); + var options = CompositorOptions.Default with + { + ColorGlyphAtlasSize = 32 + }; + using var compositor = new Compositor( + window.Context, + TextureFormat.Rgba8Unorm, + options); + + Assert.Equal(32u, compositor.Atlas.ColorAtlasSize); + Assert.Equal(32u, compositor.Atlas.MaxColorAtlasSize); + } + + [Fact] + public unsafe void CompactSceneBuffersGrowOnceForLargerRetainedWorkloads() + { + using var window = new HeadlessWindow(64, 64); + using var target = new GpuTexture( + window.Context, + 64, + 64, + TextureFormat.Rgba8Unorm, + TextureUsage.RenderAttachment | TextureUsage.CopySrc, + "Compact scene-buffer growth target"); + var options = CompositorOptions.Default with + { + InitialVertexCount = 16, + InitialIndexCount = 24, + InitialBrushCount = 4, + InitialGradientStopCount = 4, + EnableGpuHitTesting = false + }; + using var compositor = new Compositor( + window.Context, + TextureFormat.Rgba8Unorm, + options); + var visual = new DrawingVisual(); + for (int index = 0; index < 300; index++) + { + float phase = index / 300f; + visual.Context.DrawRectangle( + new LinearGradientBrush( + Vector2.Zero, + Vector2.One, + [ + new GradientStop(new Vector4(phase, 0.4f, 0.8f, 1f), 0f), + new GradientStop(new Vector4(0.2f, phase, 0.6f, 1f), 1f) + ]), + null, + new Rect(index % 60, index / 60, 4, 4)); + } + + compositor.RenderScene(visual, 64, 64, target.ViewPtr); + byte[] pixels = target.ReadPixels(); + bool hasVisiblePixel = false; + for (int offset = 3; offset < pixels.Length; offset += 4) + { + if (pixels[offset] == 0) + { + continue; + } + + hasVisiblePixel = true; + break; + } + Assert.True(hasVisiblePixel); + + GpuBuffer vectorBuffer = + GetCompositorField(compositor, "_vectorVertexBuffer"); + uint grownSize = vectorBuffer.Size; + Assert.True(grownSize > options.InitialVertexCount * (uint)Marshal.SizeOf()); + Assert.True( + grownSize >= + (uint)compositor.Metrics.VectorVerticesCount * (uint)Marshal.SizeOf()); + GpuBuffer brushBuffer = + GetCompositorField(compositor, "_brushesStorageBuffer"); + GpuBuffer gradientBuffer = + GetCompositorField(compositor, "_gradientStopsStorageBuffer"); + uint grownBrushSize = brushBuffer.Size; + uint grownGradientSize = gradientBuffer.Size; + Assert.True(grownBrushSize >= 300u * (uint)Marshal.SizeOf()); + Assert.True(grownGradientSize >= 600u * (uint)Marshal.SizeOf()); + Assert.Equal((ulong)grownBrushSize, compositor.Metrics.BrushStorageBufferBytes); + Assert.Equal((ulong)grownGradientSize, compositor.Metrics.GradientStopStorageBufferBytes); + Assert.Equal(300, compositor.Metrics.ActiveBrushCount); + Assert.Equal(600, compositor.Metrics.ActiveGradientStopCount); + Assert.Equal(0UL, compositor.Metrics.EffectParameterBufferBytes); + Assert.Equal(0, compositor.Metrics.EffectShaderCount); + Assert.Equal(0, compositor.Metrics.EffectPipelineCount); + Assert.Equal(1, compositor.Metrics.SceneShaderCount); + Assert.Equal(1, compositor.Metrics.SceneRenderPipelineCount); + Assert.Equal(0, compositor.Metrics.SceneComputePipelineCount); + Assert.Null(compositor.LastHitTestIndex); + + compositor.RenderScene(visual, 64, 64, target.ViewPtr); + + Assert.Equal(grownSize, vectorBuffer.Size); + Assert.Equal(grownSize, GetCompositorField(compositor, "_vectorVertexBuffer").Size); + Assert.Equal(grownBrushSize, brushBuffer.Size); + Assert.Equal(grownGradientSize, gradientBuffer.Size); } [Fact] @@ -4555,6 +5190,91 @@ private static T GetPrivateField(object target, string fieldName) return Assert.IsType(field.GetValue(target)); } + private static unsafe nint GetPrivatePointerField(object target, string fieldName) + { + var field = target.GetType().GetField( + fieldName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + object? value = field.GetValue(target); + Assert.NotNull(value); + return (nint)Pointer.Unbox(value); + } + + private static void InvokePrivateMethod(object target, string methodName) + { + var method = target.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + method.Invoke(target, parameters: null); + } + + private static unsafe nint InvokePrivatePointerMethod( + object target, + string methodName, + params object[] parameters) + { + var method = target.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + object? value = method.Invoke(target, parameters); + Assert.NotNull(value); + return (nint)Pointer.Unbox(value); + } + + private static unsafe void AssertSharedTargetBindingObjects(Compositor compositor) + { + string[] primaryFields = + [ + "_vectorUniformBindGroupLayout", + "_textUniformBindGroupLayout", + "_textureUniformBindGroupLayout", + "_pathAtlasBindGroupLayout", + "_atlasBindGroupLayout", + "_textureBindGroupLayout", + "_maskBindGroupLayout", + "_vectorPipelineLayout", + "_textPipelineLayout", + "_texturePipelineLayout", + "_retainedGlyphPipelineLayout", + "_vectorUniformBindGroup", + "_textUniformBindGroup", + "_textureUniformBindGroup", + "_pathAtlasBindGroup", + "_atlasBindGroup", + "_dummyMaskBindGroup" + ]; + string[] offscreenFields = + [ + "_vectorUniformBindGroupLayoutOffscreen", + "_textUniformBindGroupLayoutOffscreen", + "_textureUniformBindGroupLayoutOffscreen", + "_pathAtlasBindGroupLayoutOffscreen", + "_atlasBindGroupLayoutOffscreen", + "_textureBindGroupLayoutOffscreen", + "_maskBindGroupLayoutOffscreen", + "_vectorPipelineLayoutOffscreen", + "_textPipelineLayoutOffscreen", + "_texturePipelineLayoutOffscreen", + "_retainedGlyphPipelineLayoutOffscreen", + "_vectorUniformBindGroupOffscreen", + "_textUniformBindGroupOffscreen", + "_textureUniformBindGroupOffscreen", + "_pathAtlasBindGroupOffscreen", + "_atlasBindGroupOffscreen", + "_dummyMaskBindGroupOffscreen" + ]; + + for (int i = 0; i < primaryFields.Length; i++) + { + Assert.Equal( + GetPrivatePointerField(compositor, primaryFields[i]), + GetPrivatePointerField(compositor, offscreenFields[i])); + } + } + private static object? GetRawCompositorField(Compositor compositor, string fieldName) { var field = typeof(Compositor).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); diff --git a/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs b/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs index f28772a3..a737c70c 100644 --- a/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs +++ b/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs @@ -414,9 +414,18 @@ public void WgpuContextFirstActiveLookupAvoidsSnapshotArrays() string systemDrawingGpuProvider = ReadSource("src", "System.Drawing.Common", "GpuProvider.cs"); string skiaSharp = ReadSource("src", "SkiaSharp", "SkiaSharp.cs"); string avaloniaHost = ReadSource("src", "ProGPU.Avalonia", "ProGpuHostControl.cs"); + string avaloniaDrawingContext = ReadSource( + "src", + "ProGPU.Avalonia.Rendering", + "DrawingContextImpl.cs"); + string framebufferPresentationRegistry = ReadSource( + "src", + "ProGPU.Backend", + "GpuFramebufferPresentationRegistry.cs"); Assert.Contains("using System.Diagnostics.CodeAnalysis;", source, StringComparison.Ordinal); Assert.Contains("public static bool TryGetFirstActiveContext([NotNullWhen(true)] out WgpuContext? context)", source, StringComparison.Ordinal); + Assert.Contains("public static unsafe bool TryGetActiveContextForSurface(", source, StringComparison.Ordinal); Assert.Contains("for (var i = 0; i < _activeContexts.Count; i++)", source, StringComparison.Ordinal); Assert.Contains("var active = _activeContexts[i];", source, StringComparison.Ordinal); Assert.Contains("if (active.IsInitialized)", source, StringComparison.Ordinal); @@ -435,6 +444,55 @@ public void WgpuContextFirstActiveLookupAvoidsSnapshotArrays() Assert.DoesNotContain("var active = WgpuContext.ActiveContexts;", skiaSharp, StringComparison.Ordinal); Assert.Contains("WgpuContext.TryGetFirstActiveContext(out var context);", avaloniaHost, StringComparison.Ordinal); Assert.DoesNotContain("var active = WgpuContext.ActiveContexts;", avaloniaHost, StringComparison.Ordinal); + Assert.Contains("WgpuContext.TryGetActiveContextForSurface(surfacePtr, out var context)", avaloniaDrawingContext, StringComparison.Ordinal); + Assert.Contains("WgpuContext.TryGetFirstActiveContext(out current);", avaloniaDrawingContext, StringComparison.Ordinal); + Assert.DoesNotContain("WgpuContext.ActiveContexts", avaloniaDrawingContext, StringComparison.Ordinal); + Assert.Contains("WgpuContext.TryGetActiveContextForSurface(surfaceHandle, out var context)", framebufferPresentationRegistry, StringComparison.Ordinal); + Assert.DoesNotContain("WgpuContext.ActiveContexts", framebufferPresentationRegistry, StringComparison.Ordinal); + } + + [Fact] + public void CompositorCommandLabelsAvoidPerFrameStringMarshalling() + { + string source = ReadSource("src", "ProGPU.Scene", "Compositor.cs"); + + Assert.Contains("CreateCommandEncoder(\"Compositor Command Encoder\\0\"u8)", source, StringComparison.Ordinal); + Assert.Contains("\"Compositor Command Buffer\\0\"u8", source, StringComparison.Ordinal); + Assert.Contains("CreateCommandEncoder(\"Offscreen Compositor Encoder\\0\"u8)", source, StringComparison.Ordinal); + Assert.Contains("\"Offscreen Compositor Command Buffer\\0\"u8", source, StringComparison.Ordinal); + Assert.Contains("private CommandEncoder* CreateCommandEncoder(ReadOnlySpan label)", source, StringComparison.Ordinal); + Assert.Contains("private CommandBuffer* FinishCommandEncoder(", source, StringComparison.Ordinal); + Assert.DoesNotContain("StringToPtr(\"Compositor Command", source, StringComparison.Ordinal); + Assert.DoesNotContain("StringToPtr(\"Offscreen Compositor", source, StringComparison.Ordinal); + } + + [Fact] + public void GlyphRasterizerCommandLabelsAvoidPerBatchStringMarshalling() + { + string source = ReadSource("src", "ProGPU.Text", "GlyphAtlas.cs"); + + Assert.Contains("CreateCommandEncoder(\"Glyph Rasterizer Batch Encoder\\0\"u8)", source, StringComparison.Ordinal); + Assert.Contains("\"Glyph Rasterizer Batch Command Buffer\\0\"u8", source, StringComparison.Ordinal); + Assert.Contains("CreateCommandEncoder(\"Glyph Rasterizer Encoder\\0\"u8)", source, StringComparison.Ordinal); + Assert.Contains("\"Glyph Rasterizer Command Buffer\\0\"u8", source, StringComparison.Ordinal); + Assert.Contains("private CommandEncoder* CreateCommandEncoder(ReadOnlySpan label)", source, StringComparison.Ordinal); + Assert.Contains("private CommandBuffer* FinishCommandEncoder(", source, StringComparison.Ordinal); + Assert.DoesNotContain("SilkMarshal.StringToPtr(\"Glyph Rasterizer", source, StringComparison.Ordinal); + } + + [Fact] + public void CompositorTextVertexCapacityUsesGeometricListGrowth() + { + string source = ReadSource("src", "ProGPU.Scene", "Compositor.cs"); + + Assert.Contains( + "_textVerticesList.EnsureCapacity(requiredCapacity);", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_textVerticesList.Capacity = requiredCapacity;", + source, + StringComparison.Ordinal); } [Fact] @@ -661,7 +719,7 @@ public void CompositorTransientStateSnapshotsUseArrayPool() Assert.DoesNotContain("foreach (var entry in _allocatedLayerTextures)", source, StringComparison.Ordinal); Assert.DoesNotContain("foreach (var cachedBg in _persistentTextureBindGroups.Values)", source, StringComparison.Ordinal); Assert.DoesNotContain("foreach (var bg in _maskBindGroups.Values)", source, StringComparison.Ordinal); - Assert.DoesNotContain("foreach (var bg in _maskBindGroupsOffscreen.Values)", source, StringComparison.Ordinal); + Assert.DoesNotContain("_maskBindGroupsOffscreen", source, StringComparison.Ordinal); Assert.DoesNotContain("foreach (var ext in _registeredExtensions)", source, StringComparison.Ordinal); Assert.DoesNotContain("foreach (var figure in cmd.Path.Figures)", source, StringComparison.Ordinal); Assert.DoesNotContain("foreach (var figure in source.Figures)", source, StringComparison.Ordinal); @@ -688,7 +746,6 @@ public void CompositorTransientStateSnapshotsUseArrayPool() Assert.Contains("var effectTextureEnumerator = _effectTextures.Values.GetEnumerator();", source, StringComparison.Ordinal); Assert.Contains("var allocatedLayerTextureEnumerator = _allocatedLayerTextures.GetEnumerator();", source, StringComparison.Ordinal); Assert.Contains("var cachedBindGroupEnumerator = _persistentTextureBindGroups.Values.GetEnumerator();", source, StringComparison.Ordinal); - Assert.Contains("var offscreenMaskBindGroupEnumerator = _maskBindGroupsOffscreen.Values.GetEnumerator();", source, StringComparison.Ordinal); Assert.Contains("private void DisposeMaskTexturePool()", source, StringComparison.Ordinal); Assert.Contains("var pooledMaskTextures = RentListSnapshot(_maskTexturePool", source, StringComparison.Ordinal); Assert.Contains("_clipStack.Dispose();", source, StringComparison.Ordinal); @@ -909,6 +966,25 @@ public void GpuHitTestIndexBuilderUsesPooledPrimitiveBuckets() Assert.DoesNotContain("for (int i = 0; i < 4; i++)\n {\n var child = GetChildBounds(i, nodeMin, nodeMax, center);", source, StringComparison.Ordinal); } + [Fact] + public void SampleOffscreenEffectsDoNotBuildUnconsumedGpuHitTestIndexes() + { + string source = ReadSource("src", "ProGPU.Samples", "Windows/MainWindowController.cs"); + + Assert.Contains( + "CompositorOptions.Default with { EnableGpuHitTesting = false }", + source, + StringComparison.Ordinal); + Assert.Contains( + "AppState._offscreenCompositor!.RenderScene(", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "AppState._offscreenCompositor ??= new Compositor(context, TextureFormat.Rgba8Unorm)", + source, + StringComparison.Ordinal); + } + [Fact] public void EffectExtensionCacheCleanupUsesPooledRemovalBuffers() { diff --git a/src/ProGPU.Tests/InterFontFamilyTests.cs b/src/ProGPU.Tests/InterFontFamilyTests.cs index 0a813182..6bd8dfdb 100644 --- a/src/ProGPU.Tests/InterFontFamilyTests.cs +++ b/src/ProGPU.Tests/InterFontFamilyTests.cs @@ -14,6 +14,7 @@ public sealed class InterFontFamilyTests public void RegularFaceIsUnmodifiedOfficialInter41Asset() { var font = InterFontFamily.Regular; + Assert.True(font.UsesExternalStorage); var hash = Convert.ToHexString(SHA256.HashData(font.FontData.Span)).ToLowerInvariant(); Assert.Equal("4.1", InterFontFamily.Version); diff --git a/src/ProGPU.Tests/NotoFontFamilyTests.cs b/src/ProGPU.Tests/NotoFontFamilyTests.cs index 074605d7..52dcc2e7 100644 --- a/src/ProGPU.Tests/NotoFontFamilyTests.cs +++ b/src/ProGPU.Tests/NotoFontFamilyTests.cs @@ -11,6 +11,8 @@ public sealed class NotoFontFamilyTests [Fact] public void FacesAreUnmodifiedOfficialReleaseAssets() { + Assert.True(NotoFontFamily.Japanese.UsesExternalStorage); + Assert.True(NotoFontFamily.Symbols.UsesExternalStorage); string japaneseHash = Convert.ToHexString(SHA256.HashData(NotoFontFamily.Japanese.FontData.Span)).ToLowerInvariant(); string symbolsHash = Convert.ToHexString(SHA256.HashData(NotoFontFamily.Symbols.FontData.Span)).ToLowerInvariant(); diff --git a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs index 8a255c62..92b07cb7 100644 --- a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs +++ b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs @@ -39,7 +39,7 @@ public void CoverageAtlasesUseCompactSingleChannelResidency() } [Fact] - public void SampleAtlasConfigurationStartsAtThreePercentOfLegacyResidency() + public void SampleAtlasConfigurationStartsBelowTwoPercentOfLegacyResidency() { const ulong glyphSize = 2_560; const ulong pathSize = 2_048; @@ -53,12 +53,24 @@ public void SampleAtlasConfigurationStartsAtThreePercentOfLegacyResidency() PathAtlas.DefaultInitialAtlasSize * PathAtlas.DefaultInitialAtlasSize; Assert.Equal(42_991_616UL, legacyBytes); - Assert.Equal(1_048_576UL, initialBytes); - Assert.True(initialBytes * 100UL / legacyBytes <= 3UL); + Assert.Equal(608_272UL, initialBytes); + Assert.True(initialBytes * 100UL / legacyBytes <= 2UL); } [Fact] - public void CoverageAtlasesGrowWithoutMovingTexelCoordinatesOrChangingGeneration() + public void GlyphOutlineBuffersStartAtMeasuredCompactCapacity() + { + using var atlas = new GlyphAtlas( + HeadlessWindow.Shared.Context, + atlasSize: 1024); + + Assert.Equal(48, atlas.AllocatedGpuGlyphRecordCapacity); + Assert.Equal(896, atlas.AllocatedGpuSegmentCapacity); + Assert.Equal(44_544UL, atlas.AllocatedGpuOutlineBytes); + } + + [Fact] + public void CoverageAtlasesGrowOneAxisWithoutMovingTexelCoordinatesAndAdvanceUvGeneration() { using var pathAtlas = new PathAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024); PathAtlas.PathInfo first = pathAtlas.GetOrCreatePath( @@ -69,7 +81,7 @@ public void CoverageAtlasesGrowWithoutMovingTexelCoordinatesOrChangingGeneration uint initialLocalX = checked((uint)(20 - (int)first.MinX)); uint initialLocalY = checked((uint)(20 - (int)first.MinY)); int initialCoverageOffset = checked((int)( - (first.Y + initialLocalY) * pathAtlas.AtlasSize + first.X + initialLocalX)); + (first.Y + initialLocalY) * pathAtlas.AtlasWidth + first.X + initialLocalX)); Assert.True(initialPixels[initialCoverageOffset] > 200); ulong generation = pathAtlas.Generation; ulong revision = pathAtlas.TextureRevision; @@ -85,21 +97,30 @@ public void CoverageAtlasesGrowWithoutMovingTexelCoordinatesOrChangingGeneration PrimitivePathGeometry.CreateRectangle(0f, 0f, 72f, 72f), scale: 1f); Assert.Equal(1024u, pathAtlas.AtlasSize); + Assert.Equal(512u, pathAtlas.AtlasWidth); + Assert.Equal(1024u, pathAtlas.AtlasHeight); + Assert.Equal(512UL * 1024UL, pathAtlas.PersistentTextureBytes); + Assert.Equal(first.Width, pathAtlas.PeakRasterWidth); + Assert.Equal(first.Height, pathAtlas.PeakRasterHeight); Assert.True(pathAtlas.TextureRevision > revision); - Assert.Equal(generation, pathAtlas.Generation); + Assert.True(pathAtlas.Generation > generation); Assert.Equal(first.X, repeated.X); Assert.Equal(first.Y, repeated.Y); Assert.Equal(first.Width, repeated.Width); Assert.Equal(first.Height, repeated.Height); Assert.Equal( - repeated.X / (float)pathAtlas.AtlasSize, + repeated.X / (float)pathAtlas.AtlasWidth, repeated.TexCoordMin.X, precision: 6); + Assert.Equal( + repeated.Y / (float)pathAtlas.AtlasHeight, + repeated.TexCoordMin.Y, + precision: 6); byte[] grownPixels = pathAtlas.AtlasTexture.ReadPixels(); uint grownLocalX = checked((uint)(20 - (int)repeated.MinX)); uint grownLocalY = checked((uint)(20 - (int)repeated.MinY)); int grownCoverageOffset = checked((int)( - (repeated.Y + grownLocalY) * pathAtlas.AtlasSize + repeated.X + grownLocalX)); + (repeated.Y + grownLocalY) * pathAtlas.AtlasWidth + repeated.X + grownLocalX)); Assert.Equal(initialPixels[initialCoverageOffset], grownPixels[grownCoverageOffset]); } @@ -107,7 +128,15 @@ public void CoverageAtlasesGrowWithoutMovingTexelCoordinatesOrChangingGeneration public void GlyphAtlasGrowthPreservesResidentCoverageAndStableTexelCoordinates() { TtfFont font = LoadTestFont(); - using var atlas = new GlyphAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024); + using var atlas = new GlyphAtlas( + HeadlessWindow.Shared.Context, + atlasSize: 1024, + initialAtlasSize: 260, + colorAtlasSize: 512, + initialColorAtlasSize: 64, + uniformRingBufferSize: 16 * 1024, + coverageRingBufferSize: 256 * 1024); + Assert.Equal(260u, atlas.AtlasSize); ushort glyph = font.GetGlyphIndex('A'); atlas.BeginBatch(); GlyphInfo first; @@ -143,7 +172,7 @@ public void GlyphAtlasGrowthPreservesResidentCoverageAndStableTexelCoordinates() atlas.BeginBatch(); try { - for (byte phase = 1; phase < 64 && atlas.AtlasSize == GlyphAtlas.DefaultInitialAtlasSize; phase++) + for (byte phase = 1; phase < 64 && atlas.AtlasSize == 260u; phase++) { _ = atlas.GetOrCreateGlyphByIndex(font, glyph, 128f, phase); } @@ -154,7 +183,7 @@ public void GlyphAtlasGrowthPreservesResidentCoverageAndStableTexelCoordinates() } GlyphInfo repeated = atlas.GetOrCreateGlyphByIndex(font, glyph, 128f, subpixelX: 0); - Assert.Equal(1024u, atlas.AtlasSize); + Assert.Equal(512u, atlas.AtlasSize); Assert.True(atlas.TextureRevision > 0); Assert.Equal(generation, atlas.Generation); Assert.Equal(first.X, repeated.X); @@ -169,7 +198,13 @@ public void GlyphAtlasGrowthPreservesResidentCoverageAndStableTexelCoordinates() public void GlyphAtlasCoalescesFirstUseQueueWritesAcrossACompilationBatch() { TtfFont font = LoadTestFont(); - using var atlas = new GlyphAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024); + using var atlas = new GlyphAtlas( + HeadlessWindow.Shared.Context, + atlasSize: 1024, + colorAtlasSize: 512, + initialColorAtlasSize: 64, + uniformRingBufferSize: 16 * 1024, + coverageRingBufferSize: 256 * 1024); const string text = "GPU text batching"; int uniqueGlyphCount = text.Distinct().Count(character => character != ' '); ulong outlineWritesBefore = atlas.OutlineUploadWriteCount; diff --git a/src/ProGPU.Tests/SfntFontFaceTests.cs b/src/ProGPU.Tests/SfntFontFaceTests.cs index ee63d29d..0aa8688d 100644 --- a/src/ProGPU.Tests/SfntFontFaceTests.cs +++ b/src/ProGPU.Tests/SfntFontFaceTests.cs @@ -5,6 +5,7 @@ using ProGPU.Tests.Headless; using ProGPU.Text; using ProGPU.Vector; +using SkiaSharp; using Xunit; namespace ProGPU.Tests; @@ -209,6 +210,34 @@ public void TtfFontLoadsRequestedCollectionFace() Assert.Throws(() => new TtfFont(fontData, 2)); } + [Fact] + public void TtfFontFileConstructorMapsRawSfntWithoutManagedPayloadCopy() + { + byte[] fontData = BuildMetricsSfnt(); + string file = Path.Combine(Path.GetTempPath(), $"progpu-mapped-font-{Guid.NewGuid():N}.ttf"); + File.WriteAllBytes(file, fontData); + + try + { + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + + var font = new TtfFont(file); + + long allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + Assert.True(font.UsesMappedFileStorage); + Assert.Equal(fontData.Length, font.FontData.Length); + Assert.Equal(fontData, font.FontData.ToArray()); + Assert.True(font.HasGlyph('A')); + Assert.True( + allocatedBytes < fontData.Length + 128 * 1024, + $"Allocated {allocatedBytes:N0} bytes for a {fontData.Length:N0}-byte font."); + } + finally + { + File.Delete(file); + } + } + [Fact] public void TtfFontReadsClosestSbixStrikeAndDuplicateGlyph() { @@ -262,7 +291,9 @@ public void FallbackFontLoadsOnlyRequestedSbixGlyphData() Assert.True(allocatedBytes < 2 * 1024 * 1024, $"Allocated {allocatedBytes:N0} bytes."); Assert.True(font.TryGetBitmapGlyph(1, 35, out BitmapGlyphData glyph)); Assert.Equal(new byte[] { 40, 41, 42 }, glyph.Data.ToArray()); - Assert.False(font.TryGetBitmapGlyph(2, 35, out _)); + Assert.True(font.TryGetBitmapGlyph(2, 19, out BitmapGlyphData demandLoaded)); + Assert.Equal(new byte[] { 20, 21, 22 }, demandLoaded.Data.ToArray()); + Assert.InRange(font.GlyphResidentBitmapCacheBytes, 1, 16 * 1024 * 1024); } finally { @@ -295,6 +326,51 @@ public void GlyphAtlasUploadsSbixAsIntrinsicColorBitmap() Assert.All(atlas.AtlasTexture.ReadPixels(), static value => Assert.Equal(0, value)); } + [Fact] + public void CompactColorGlyphAtlasGrowsOnceAndPreservesTheResidentBitmap() + { + const int bitmapSize = 96; + using var bitmap = new SKBitmap(new SKImageInfo( + bitmapSize, + bitmapSize, + SKColorType.Rgba8888, + SKAlphaType.Premul)); + bitmap.Erase(new SKColor(24, 96, 224, 255)); + using var encoded = bitmap.Encode(SKEncodedImageFormat.Png, 100); + byte[] fontData = BuildSfntWithTables( + ("head", BuildHeadTable()), + ("maxp", BuildMaxpTable(3)), + ("cmap", BuildCmapFormat4Table()), + ("sbix", BuildSingleSbixTable(encoded.ToArray()))); + var font = new TtfFont(fontData); + using var atlas = new GlyphAtlas( + HeadlessWindow.Shared.Context, + atlasSize: 256, + colorAtlasSize: 256, + initialColorAtlasSize: 16, + uniformRingBufferSize: 256, + coverageRingBufferSize: 256); + + Assert.Equal(16u, atlas.ColorAtlasSize); + Assert.Equal(0UL, atlas.TextureRevision); + + GlyphInfo first = atlas.GetOrCreateGlyphByIndex(font, 1, 40f); + + Assert.True(first.IsColorBitmap); + Assert.Equal((uint)bitmapSize, first.Width); + Assert.Equal((uint)bitmapSize, first.Height); + Assert.Equal(128u, atlas.ColorAtlasSize); + Assert.Equal(1UL, atlas.TextureRevision); + Assert.Contains(atlas.ColorAtlasTexture.ReadPixels(), static value => value != 0); + + GlyphInfo repeated = atlas.GetOrCreateGlyphByIndex(font, 1, 40f); + Assert.Equal(first.X, repeated.X); + Assert.Equal(first.Y, repeated.Y); + Assert.Equal(first.TexCoordMin, repeated.TexCoordMin); + Assert.Equal(first.TexCoordMax, repeated.TexCoordMax); + Assert.Equal(1UL, atlas.TextureRevision); + } + [Theory] [InlineData(1)] [InlineData(2)] diff --git a/src/ProGPU.Text/Bidi/BidiParagraph.cs b/src/ProGPU.Text/Bidi/BidiParagraph.cs index 5b00a2f2..8f356604 100644 --- a/src/ProGPU.Text/Bidi/BidiParagraph.cs +++ b/src/ProGPU.Text/Bidi/BidiParagraph.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using ProGPU.Text.Shaping; namespace ProGPU.Text.Bidi; @@ -14,6 +13,11 @@ internal readonly record struct BidiRun(int Start, int Length, sbyte Level) /// internal sealed class BidiParagraph { + private static readonly BidiParagraph s_emptyLeftToRight = + new(0, Array.Empty(), Array.Empty()); + private static readonly BidiParagraph s_emptyRightToLeft = + new(1, Array.Empty(), Array.Empty()); + private BidiParagraph(sbyte paragraphLevel, sbyte[] utf16Levels, BidiRun[] runs) { ParagraphLevel = paragraphLevel; @@ -27,6 +31,27 @@ private BidiParagraph(sbyte paragraphLevel, sbyte[] utf16Levels, BidiRun[] runs) public static BidiParagraph Resolve(ReadOnlySpan text, ShapingDirection baseDirection) { + if (text.IsEmpty) + { + return baseDirection == ShapingDirection.RightToLeft + ? s_emptyRightToLeft + : s_emptyLeftToRight; + } + + // UAX #9 P2-P3, W1-W7, N0-N2, I1, and L1 resolve every ASCII + // code point to level zero in a level-zero paragraph. Scanning is O(N) + // time and O(1) workspace; the retained level and run arrays are the + // only O(N) output. Explicit RTL and every non-ASCII paragraph continue + // through the complete resolver. + if (baseDirection != ShapingDirection.RightToLeft && + IsAscii(text)) + { + return new BidiParagraph( + 0, + new sbyte[text.Length], + [new BidiRun(0, text.Length, 0)]); + } + sbyte requestedLevel = baseDirection switch { ShapingDirection.LeftToRight => 0, @@ -105,17 +130,42 @@ public static BidiParagraph Resolve( /// Applies UAX #9 rule L2 to logical units on one already-broken line. public static int[] GetVisualOrder(ReadOnlySpan levels) { - var order = new int[levels.Length]; + int[]? order = GetVisualOrderIfNeeded(levels); + if (order is not null) + { + return order; + } + + order = new int[levels.Length]; + for (int index = 0; index < order.Length; index++) + { + order[index] = index; + } + + return order; + } + + /// + /// Applies UAX #9 rule L2 and returns a visual-to-logical map only when the + /// input contains an odd embedding level. A null result is the identity map. + /// + public static int[]? GetVisualOrderIfNeeded(ReadOnlySpan levels) + { sbyte maximum = 0; sbyte lowestOdd = sbyte.MaxValue; for (int index = 0; index < levels.Length; index++) { - order[index] = index; sbyte level = levels[index]; maximum = Math.Max(maximum, level); if ((level & 1) != 0) lowestOdd = Math.Min(lowestOdd, level); } - if (lowestOdd == sbyte.MaxValue) return order; + if (lowestOdd == sbyte.MaxValue) return null; + + var order = new int[levels.Length]; + for (int index = 0; index < order.Length; index++) + { + order[index] = index; + } for (int level = maximum; level >= lowestOdd; level--) { @@ -135,20 +185,54 @@ public static int[] GetVisualOrder(ReadOnlySpan levels) private static BidiParagraph Create(sbyte paragraphLevel, sbyte[] levels) { if (levels.Length == 0) - return new BidiParagraph(paragraphLevel, levels, Array.Empty()); + { + return paragraphLevel == 1 + ? s_emptyRightToLeft + : s_emptyLeftToRight; + } - var runs = new List(); + int runCount = 1; + for (int index = 1; index < levels.Length; index++) + { + if (levels[index] != levels[index - 1]) + { + runCount++; + } + } + + var runs = new BidiRun[runCount]; + int runIndex = 0; int start = 0; sbyte level = levels[0]; - for (int index = 1; index < levels.Length; index++) + for (int index = 1; index <= levels.Length; index++) { - if (levels[index] == level) continue; - runs.Add(new BidiRun(start, index - start, level)); - start = index; - level = levels[index]; + if (index < levels.Length && levels[index] == level) + { + continue; + } + + runs[runIndex++] = new BidiRun(start, index - start, level); + if (index < levels.Length) + { + start = index; + level = levels[index]; + } + } + + return new BidiParagraph(paragraphLevel, levels, runs); + } + + private static bool IsAscii(ReadOnlySpan text) + { + for (int index = 0; index < text.Length; index++) + { + if (text[index] > '\u007F') + { + return false; + } } - runs.Add(new BidiRun(start, levels.Length - start, level)); - return new BidiParagraph(paragraphLevel, levels, runs.ToArray()); + + return true; } private static ShapingDirection NormalizeInlineDirection(ShapingDirection direction) => direction switch diff --git a/src/ProGPU.Text/EmbeddedFontData.cs b/src/ProGPU.Text/EmbeddedFontData.cs new file mode 100644 index 00000000..7ab78c80 --- /dev/null +++ b/src/ProGPU.Text/EmbeddedFontData.cs @@ -0,0 +1,89 @@ +using System.Buffers; +using System.Reflection; + +namespace ProGPU.Text; + +/// +/// Keeps an embedded raw font backed by the assembly image. The runtime already maps +/// that image, so parsing the resource directly avoids a second managed payload copy. +/// +internal sealed unsafe class EmbeddedFontData : MemoryManager +{ + private UnmanagedMemoryStream? _stream; + private byte* _pointer; + private readonly int _length; + + internal nint DataAddress => (nint)_pointer; + + private EmbeddedFontData(UnmanagedMemoryStream stream) + { + _stream = stream; + _length = checked((int)stream.Length); + stream.Position = 0; + _pointer = stream.PositionPointer; + } + + public static bool TryOpen( + Assembly assembly, + string resourceName, + out EmbeddedFontData? data) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + data = null; + + Stream? stream = assembly.GetManifestResourceStream(resourceName); + if (stream is not UnmanagedMemoryStream unmanaged || + unmanaged.Length <= 0 || + unmanaged.Length > int.MaxValue) + { + stream?.Dispose(); + return false; + } + + try + { + data = new EmbeddedFontData(unmanaged); + return true; + } + catch + { + unmanaged.Dispose(); + throw; + } + } + + public override Span GetSpan() + { + ObjectDisposedException.ThrowIf(_stream is null, this); + return new Span(_pointer, _length); + } + + public override MemoryHandle Pin(int elementIndex = 0) + { + ObjectDisposedException.ThrowIf(_stream is null, this); + ArgumentOutOfRangeException.ThrowIfNegative(elementIndex); + if (elementIndex > _length) + { + throw new ArgumentOutOfRangeException(nameof(elementIndex)); + } + + return new MemoryHandle(_pointer + elementIndex); + } + + public override void Unpin() + { + } + + protected override void Dispose(bool disposing) + { + UnmanagedMemoryStream? stream = Interlocked.Exchange(ref _stream, null); + _pointer = null; + stream?.Dispose(); + } + + ~EmbeddedFontData() + { + Dispose(disposing: false); + } +} diff --git a/src/ProGPU.Text/GlyphAtlas.cs b/src/ProGPU.Text/GlyphAtlas.cs index e448cbf6..13b9b9b0 100644 --- a/src/ProGPU.Text/GlyphAtlas.cs +++ b/src/ProGPU.Text/GlyphAtlas.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; -using Silk.NET.Core.Native; using Silk.NET.WebGPU; using ProGPU.Backend; using ProGPU.Vector; @@ -33,14 +32,15 @@ public struct GlyphInfo public unsafe class GlyphAtlas : IDisposable { - private const uint DefaultUniformRingBufferSize = 64 * 1024; + public const uint DefaultUniformRingBufferSize = 64 * 1024; public const uint DefaultCoverageRingBufferSize = 256 * 1024; public const uint DefaultOutlineUploadChunkBytes = 256 * 1024; - public const uint DefaultInitialAtlasSize = 512; - public const uint DefaultInitialColorAtlasSize = 256; + // A 256x256 usable coverage area plus the packer's two-texel perimeter. + public const uint DefaultInitialAtlasSize = 260; + public const uint DefaultInitialColorAtlasSize = 64; public const uint DefaultColorAtlasSize = 512; - private const int InitialRecordCapacity = 64; - private const int InitialSegmentCapacity = 1024; + private const int InitialRecordCapacity = 48; + private const int InitialSegmentCapacity = 896; public const ulong DefaultGpuOutlineCacheBudgetBytes = 4UL * 1024UL * 1024UL; private readonly WgpuContext _context; @@ -160,9 +160,7 @@ private void TrimGpuOutlineCache() private void CreateBatchEncoder() { - var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Batch Encoder") }; - _batchEncoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDesc); - SilkMarshal.Free((nint)encoderDesc.Label); + _batchEncoder = CreateCommandEncoder("Glyph Rasterizer Batch Encoder\0"u8); if (_batchEncoder == null) { @@ -175,6 +173,26 @@ private void CreateBatchEncoder() _batchCoverageCopies.Clear(); } + private CommandEncoder* CreateCommandEncoder(ReadOnlySpan label) + { + fixed (byte* labelPointer = label) + { + var descriptor = new CommandEncoderDescriptor { Label = labelPointer }; + return _context.Api.DeviceCreateCommandEncoder(_context.Device, &descriptor); + } + } + + private CommandBuffer* FinishCommandEncoder( + CommandEncoder* encoder, + ReadOnlySpan label) + { + fixed (byte* labelPointer = label) + { + var descriptor = new CommandBufferDescriptor { Label = labelPointer }; + return _context.Api.CommandEncoderFinish(encoder, &descriptor); + } + } + public void EndBatch() { if (_isDisposed) return; @@ -236,9 +254,9 @@ private void FlushBatchEncoder() _uniformRingUpload.AsSpan(0, checked((int)_ringOffset))); UniformUploadWriteCount++; - var cmdDesc = new CommandBufferDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Batch Command Buffer") }; - var cmdBuffer = _context.Api.CommandEncoderFinish(_batchEncoder, &cmdDesc); - SilkMarshal.Free((nint)cmdDesc.Label); + var cmdBuffer = FinishCommandEncoder( + _batchEncoder, + "Glyph Rasterizer Batch Command Buffer\0"u8); _context.Api.QueueSubmit(_context.Queue, 1, &cmdBuffer); RasterBatchSubmissionCount++; @@ -319,6 +337,10 @@ private void EndBatchComputePass() public ulong CoverageStagingBytes => _coverageRingBuffer.Size; + public ulong UniformStagingBytes => _uniformRingBuffer.Size; + + public ulong UniformUploadBytes => (ulong)_uniformRingUpload.LongLength; + public int CachedGlyphCount => _glyphs.Count; public int CompiledGpuGlyphCount @@ -331,6 +353,10 @@ public int AllocatedGpuGlyphRecordCapacity get => _recordCapacity; } + public int CompiledGpuSegmentCount => _segmentCount; + + public int AllocatedGpuSegmentCapacity => _segmentCapacity; + public ulong AllocatedGpuOutlineBytes { get => _recordsBuffer.Size + _segmentsBuffer.Size; @@ -388,6 +414,7 @@ public GlyphAtlas(WgpuContext context, uint atlasSize = 2048) context, atlasSize, Math.Min(atlasSize, DefaultColorAtlasSize), + Math.Min(atlasSize, DefaultInitialColorAtlasSize), DefaultUniformRingBufferSize, DefaultCoverageRingBufferSize) { @@ -398,6 +425,7 @@ internal GlyphAtlas(WgpuContext context, uint atlasSize, uint uniformRingBufferS context, atlasSize, Math.Min(atlasSize, DefaultColorAtlasSize), + Math.Min(atlasSize, DefaultInitialColorAtlasSize), uniformRingBufferSize, DefaultCoverageRingBufferSize) { @@ -412,15 +440,36 @@ public GlyphAtlas( context, atlasSize, colorAtlasSize, + Math.Min(colorAtlasSize, DefaultInitialColorAtlasSize), DefaultUniformRingBufferSize, coverageRingBufferSize) { } - private GlyphAtlas( + public GlyphAtlas( WgpuContext context, uint atlasSize, uint colorAtlasSize, + uint initialColorAtlasSize, + uint uniformRingBufferSize, + uint coverageRingBufferSize) + : this( + context, + atlasSize, + Math.Min(atlasSize, DefaultInitialAtlasSize), + colorAtlasSize, + initialColorAtlasSize, + uniformRingBufferSize, + coverageRingBufferSize) + { + } + + public GlyphAtlas( + WgpuContext context, + uint atlasSize, + uint initialAtlasSize, + uint colorAtlasSize, + uint initialColorAtlasSize, uint uniformRingBufferSize, uint coverageRingBufferSize) { @@ -432,10 +481,18 @@ private GlyphAtlas( { throw new ArgumentOutOfRangeException(nameof(atlasSize)); } + if (initialAtlasSize <= 4 || initialAtlasSize > atlasSize) + { + throw new ArgumentOutOfRangeException(nameof(initialAtlasSize)); + } if (colorAtlasSize <= 4) { throw new ArgumentOutOfRangeException(nameof(colorAtlasSize)); } + if (initialColorAtlasSize <= 4 || initialColorAtlasSize > colorAtlasSize) + { + throw new ArgumentOutOfRangeException(nameof(initialColorAtlasSize)); + } if (coverageRingBufferSize < 256 || coverageRingBufferSize % 256 != 0) { throw new ArgumentOutOfRangeException(nameof(coverageRingBufferSize)); @@ -444,8 +501,8 @@ private GlyphAtlas( _context = context; _maxAtlasSize = atlasSize; _maxColorAtlasSize = colorAtlasSize; - _atlasSize = Math.Min(atlasSize, DefaultInitialAtlasSize); - _colorAtlasSize = Math.Min(colorAtlasSize, DefaultInitialColorAtlasSize); + _atlasSize = initialAtlasSize; + _colorAtlasSize = initialColorAtlasSize; // Monochrome coverage is sampled from one byte per texel. Compute writes a // packed storage buffer and the GPU copies it into this filterable texture. @@ -874,9 +931,7 @@ void ProcessPt(Vector2 pt) var bg = _context.Api.DeviceCreateBindGroup(_context.Device, &bgDesc); RasterBindGroupCreationCount++; - var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Encoder") }; - var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDesc); - SilkMarshal.Free((nint)encoderDesc.Label); + var encoder = CreateCommandEncoder("Glyph Rasterizer Encoder\0"u8); var passDesc = new ComputePassDescriptor(); var pass = _context.Api.CommandEncoderBeginComputePass(encoder, &passDesc); @@ -910,9 +965,9 @@ void ProcessPt(Vector2 pt) gH); // Submit to queue - var cmdDesc = new CommandBufferDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Command Buffer") }; - var cmdBuffer = _context.Api.CommandEncoderFinish(encoder, &cmdDesc); - SilkMarshal.Free((nint)cmdDesc.Label); + var cmdBuffer = FinishCommandEncoder( + encoder, + "Glyph Rasterizer Command Buffer\0"u8); _context.Api.QueueSubmit(_context.Queue, 1, &cmdBuffer); @@ -1268,7 +1323,7 @@ private bool TryGrowAtlas(bool colorBitmap, uint requiredWidth, uint requiredHei uint newSize = currentSize; do { - newSize = Math.Min(maximumSize, checked(newSize * 2)); + newSize = NextAtlasSize(newSize, maximumSize); } while (newSize < requiredSize && newSize < maximumSize); @@ -1317,6 +1372,14 @@ private bool TryGrowAtlas(bool colorBitmap, uint requiredWidth, uint requiredHei return true; } + private static uint NextAtlasSize(uint currentSize, uint maximumSize) + { + uint nextPowerOfTwo = BitOperations.RoundUpToPowerOf2(checked(currentSize + 1)); + return nextPowerOfTwo == 0 + ? maximumSize + : Math.Min(maximumSize, nextPowerOfTwo); + } + private void RefreshNormalizedTextureCoordinates(bool colorBitmap, uint atlasSize) { float texelSize = 1f / atlasSize; @@ -1536,7 +1599,10 @@ private uint EnsureGpuGlyph(TtfFont font, ushort glyphIndex, FontGpuData data) } _glyphSegmentScratch.Clear(); - ReleaseOversizedStagingCapacity(_glyphSegmentScratch, segmentSize, InitialSegmentCapacity); + ReleaseOversizedStagingCapacity( + _glyphSegmentScratch, + segmentSize, + InitialSegmentCapacity); _recordCount = requiredRecordCount; _segmentCount = requiredSegmentCount; diff --git a/src/ProGPU.Text/MappedFontData.cs b/src/ProGPU.Text/MappedFontData.cs new file mode 100644 index 00000000..8868e077 --- /dev/null +++ b/src/ProGPU.Text/MappedFontData.cs @@ -0,0 +1,131 @@ +using System.Buffers; +using System.IO.MemoryMappedFiles; + +namespace ProGPU.Text; + +/// +/// Provides a stable read-only view of a font file without copying the complete +/// payload into the managed heap. The operating system loads only touched pages, +/// and repeated faces over the same file remain shareable through the file cache. +/// +internal sealed unsafe class MappedFontData : MemoryManager +{ + private readonly MemoryMappedFile _mapping; + private readonly MemoryMappedViewAccessor _view; + private byte* _pointer; + private readonly int _length; + private bool _disposed; + + internal nint DataAddress => (nint)_pointer; + + private MappedFontData( + MemoryMappedFile mapping, + MemoryMappedViewAccessor view, + int length) + { + _mapping = mapping; + _view = view; + _length = length; + byte* pointer = null; + _view.SafeMemoryMappedViewHandle.AcquirePointer(ref pointer); + _pointer = pointer + checked((nint)_view.PointerOffset); + } + + public static bool TryOpen(string path, out MappedFontData? data) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + data = null; + + try + { + var file = new FileInfo(path); + if (file.Length <= 0 || file.Length > int.MaxValue) + { + return false; + } + + MemoryMappedFile? mapping = null; + MemoryMappedViewAccessor? view = null; + try + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 1, + FileOptions.RandomAccess); + mapping = MemoryMappedFile.CreateFromFile( + stream, + mapName: null, + capacity: file.Length, + MemoryMappedFileAccess.Read, + HandleInheritability.None, + leaveOpen: true); + view = mapping.CreateViewAccessor( + 0, + file.Length, + MemoryMappedFileAccess.Read); + data = new MappedFontData(mapping, view, checked((int)file.Length)); + mapping = null; + view = null; + return true; + } + finally + { + view?.Dispose(); + mapping?.Dispose(); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + ArgumentException or NotSupportedException) + { + return false; + } + } + + public override Span GetSpan() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return new Span(_pointer, _length); + } + + public override MemoryHandle Pin(int elementIndex = 0) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegative(elementIndex); + if (elementIndex > _length) + { + throw new ArgumentOutOfRangeException(nameof(elementIndex)); + } + + return new MemoryHandle(_pointer + elementIndex); + } + + public override void Unpin() + { + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_pointer != null) + { + _view.SafeMemoryMappedViewHandle.ReleasePointer(); + _pointer = null; + } + + _view.Dispose(); + _mapping.Dispose(); + } + + ~MappedFontData() + { + Dispose(disposing: false); + } +} diff --git a/src/ProGPU.Text/Properties/AssemblyInfo.cs b/src/ProGPU.Text/Properties/AssemblyInfo.cs index 72f02edb..9bbfe752 100644 --- a/src/ProGPU.Text/Properties/AssemblyInfo.cs +++ b/src/ProGPU.Text/Properties/AssemblyInfo.cs @@ -24,6 +24,14 @@ "fef44d6597f795ee9e33bf0b2a959915729166099862717525ec4bab3002d4d8f8a81d656b5c25" + "c891cb91")] +[assembly: InternalsVisibleTo( + "Avalonia.ProGpu, PublicKey=" + + "002400000480000094000000060200000024000052534131000400000100010045aa5f537c126e" + + "38b67a4e22a49d30ad6424dc66d3e82890c03de2caa8f421736dbe5170b054610320e4a436e808" + + "a38b05e02998b776d1ae9133d2bf7197e5eb39e9ba6bee9ed837236a90c151c6d6e154dc8debd4" + + "fef44d6597f795ee9e33bf0b2a959915729166099862717525ec4bab3002d4d8f8a81d656b5c25" + + "c891cb91")] + [assembly: InternalsVisibleTo( "UnicodeBidiConformance, PublicKey=" + "002400000480000094000000060200000024000052534131000400000100010045aa5f537c126e" + diff --git a/src/ProGPU.Text/SfntFontFace.cs b/src/ProGPU.Text/SfntFontFace.cs index 9d024040..e7010bc3 100644 --- a/src/ProGPU.Text/SfntFontFace.cs +++ b/src/ProGPU.Text/SfntFontFace.cs @@ -91,7 +91,7 @@ public SfntGlyphBounds(short xMin, short yMin, short xMax, short yMax) #endif sealed class SfntFontFace { - private readonly byte[] _data; + private readonly ReadOnlyMemory _data; private readonly Dictionary _tables; private readonly ReadOnlyMemory _cmapFormat4; private readonly ReadOnlyMemory _cmapFormat12; @@ -99,7 +99,11 @@ sealed class SfntFontFace private readonly bool _usesSymbolCharacterMap; private readonly ushort _legacyFontPage; - private SfntFontFace(byte[] data, int faceIndex, uint baseOffset, Dictionary tables) + private SfntFontFace( + ReadOnlyMemory data, + int faceIndex, + uint baseOffset, + Dictionary tables) { _data = data; FaceIndex = faceIndex; @@ -109,10 +113,16 @@ private SfntFontFace(byte[] data, int faceIndex, uint baseOffset, Dictionary cmapFormat4, + out ReadOnlyMemory cmapFormat12, + out ReadOnlyMemory cmapFormat13, out _usesSymbolCharacterMap); + // Character lookup is a shaping hot path. Keep only these compact selected + // subtables managed so mapped files do not pay a MemoryManager dispatch per + // scalar while the full font payload remains file-backed. + _cmapFormat4 = CopyTable(cmapFormat4); + _cmapFormat12 = CopyTable(cmapFormat12); + _cmapFormat13 = CopyTable(cmapFormat13); } _legacyFontPage = ReadLegacyFontPage(); } @@ -122,6 +132,9 @@ private SfntFontFace(byte[] data, int faceIndex, uint baseOffset, Dictionary Tables => _tables; public bool UsesSymbolCharacterMap => _usesSymbolCharacterMap; + private static ReadOnlyMemory CopyTable(ReadOnlyMemory table) => + table.IsEmpty ? default : table.ToArray(); + public static SfntFontFace Load(string filePath, int faceIndex = 0) { ArgumentNullException.ThrowIfNull(filePath); @@ -140,6 +153,17 @@ public static SfntFontFace Load(byte[] fontData, int faceIndex = 0) return faces[faceIndex]; } + internal static SfntFontFace Load(ReadOnlyMemory fontData, int faceIndex = 0) + { + IReadOnlyList faces = LoadFacesCore(fontData); + if ((uint)faceIndex >= (uint)faces.Count) + { + throw new ArgumentOutOfRangeException(nameof(faceIndex)); + } + + return faces[faceIndex]; + } + public static IReadOnlyList LoadFaces(string filePath) { ArgumentNullException.ThrowIfNull(filePath); @@ -150,8 +174,12 @@ public static IReadOnlyList LoadFaces(byte[] fontData) { ArgumentNullException.ThrowIfNull(fontData); fontData = SfntFontContainer.Normalize(fontData); + return LoadFacesCore(fontData); + } - uint[] faceOffsets = ReadFaceOffsets(fontData); + private static IReadOnlyList LoadFacesCore(ReadOnlyMemory fontData) + { + uint[] faceOffsets = ReadFaceOffsets(fontData.Span); var faces = new List(faceOffsets.Length); for (int i = 0; i < faceOffsets.Length; i++) { @@ -183,7 +211,7 @@ public bool TryGetTable(string tag, out ReadOnlyMemory table) record.Offset <= _data.Length && record.Length <= _data.Length - record.Offset) { - table = _data.AsMemory((int)record.Offset, (int)record.Length); + table = _data.Slice((int)record.Offset, (int)record.Length); return true; } @@ -216,7 +244,7 @@ internal byte[] CreateStandaloneFontData() } else { - _data.AsSpan(checked((int)BaseOffset), 4).CopyTo(result); + _data.Span.Slice(checked((int)BaseOffset), 4).CopyTo(result); } ushort tableCount = checked((ushort)records.Length); @@ -232,7 +260,7 @@ internal byte[] CreateStandaloneFontData() WriteUInt(result, recordOffset + 4, record.Checksum); WriteUInt(result, recordOffset + 8, checked((uint)targetOffset)); WriteUInt(result, recordOffset + 12, record.Length); - _data.AsSpan(checked((int)record.Offset), checked((int)record.Length)) + _data.Span.Slice(checked((int)record.Offset), checked((int)record.Length)) .CopyTo(result.AsSpan(targetOffset)); targetOffset = Align4(checked(targetOffset + checked((int)record.Length))); } @@ -601,7 +629,7 @@ public bool TryGetGlyphBounds(ushort glyphIndex, out SfntGlyphBounds bounds) return true; } - private static uint[] ReadFaceOffsets(byte[] data) + private static uint[] ReadFaceOffsets(ReadOnlySpan data) { if (data.Length < 12) { @@ -634,8 +662,9 @@ private static uint[] ReadFaceOffsets(byte[] data) return offsets; } - private static SfntFontFace ParseFace(byte[] data, int faceIndex, uint baseOffset) + private static SfntFontFace ParseFace(ReadOnlyMemory memory, int faceIndex, uint baseOffset) { + ReadOnlySpan data = memory.Span; if (baseOffset > data.Length || data.Length - baseOffset < 12) { throw new FormatException("SFNT face header is outside the font data."); @@ -666,7 +695,7 @@ private static SfntFontFace ParseFace(byte[] data, int faceIndex, uint baseOffse tables[tag] = new SfntTableRecord(tag, checksum, tableOffset, tableLength); } - return new SfntFontFace(data, faceIndex, baseOffset, tables); + return new SfntFontFace(memory, faceIndex, baseOffset, tables); } private bool TryFindCmapSubtables( @@ -996,7 +1025,7 @@ internal static int GetNameScore(ushort platformId, ushort languageId, string va return score + 10; } - private static string ReadTag(byte[] data, int offset) + private static string ReadTag(ReadOnlySpan data, int offset) { if (offset > data.Length || data.Length - offset < 4) { diff --git a/src/ProGPU.Text/SfntFontMetadataReader.cs b/src/ProGPU.Text/SfntFontMetadataReader.cs index f3dd7902..9bd8a97d 100644 --- a/src/ProGPU.Text/SfntFontMetadataReader.cs +++ b/src/ProGPU.Text/SfntFontMetadataReader.cs @@ -70,6 +70,64 @@ public static bool TryReadCharacterMap(string file, int faceIndex, out byte[] ch } } + public static bool TryReadGlyphSbix( + string file, + int faceIndex, + ushort glyphIndex, + out byte[] sbix) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentOutOfRangeException.ThrowIfNegative(faceIndex); + + try + { + using var stream = new FileStream( + file, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.RandomAccess); + uint[] faceOffsets = ReadFaceOffsets(stream); + if ((uint)faceIndex >= (uint)faceOffsets.Length) + { + sbix = Array.Empty(); + return false; + } + + List tables = ReadSourceTables(stream, faceOffsets[faceIndex], out _); + int sbixIndex = tables.FindIndex(static table => table.Tag == "sbix"); + int maxpIndex = tables.FindIndex(static table => table.Tag == "maxp"); + if (sbixIndex < 0 || maxpIndex < 0 || tables[sbixIndex].Length < 8) + { + sbix = Array.Empty(); + return false; + } + + byte[] maxp = ReadTable(stream, tables[maxpIndex]); + if (maxp.Length < 6) + { + sbix = Array.Empty(); + return false; + } + + ushort glyphCount = ReadUShort(maxp, 4); + if (glyphIndex >= glyphCount) + { + sbix = Array.Empty(); + return false; + } + + return TryBuildGlyphSbix(stream, tables[sbixIndex], glyphCount, glyphIndex, out sbix); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException or + ArgumentException or OverflowException) + { + sbix = Array.Empty(); + return false; + } + } + public static bool TryCreateGlyphResidentFont( string file, int faceIndex, diff --git a/src/ProGPU.Text/TtfFont.Variations.cs b/src/ProGPU.Text/TtfFont.Variations.cs index 538af127..903ed802 100644 --- a/src/ProGPU.Text/TtfFont.Variations.cs +++ b/src/ProGPU.Text/TtfFont.Variations.cs @@ -21,6 +21,7 @@ public partial class TtfFont : IEquatable private OpenTypeVariationData? _variationData; private bool _variationDataInitialized; + private bool _hasVariationTables; private OpenTypeVariationInstance? _variationInstance; private TtfFont? _variationRoot; private readonly object _variationCacheLock = new(); @@ -98,11 +99,14 @@ public TtfFont WithVariations(IReadOnlyList settings) { if (!_variationDataInitialized) { - _variationData = OpenTypeVariationData.TryCreate( - _data, - _tables, - nameId => _face.TryGetName(nameId, out string? name) ? name : null, - NumGlyphs); + if (_hasVariationTables) + { + _variationData = OpenTypeVariationData.TryCreate( + _ownedFontData ?? _data.ToArray(), + _tables, + nameId => _face.TryGetName(nameId, out string? name) ? name : null, + NumGlyphs); + } Volatile.Write(ref _variationDataInitialized, true); } return _variationData; @@ -115,7 +119,12 @@ private TtfFont( OpenTypeVariationInstance variationInstance) { _data = source._data; + _ownedFontData = source._ownedFontData; + _fontDataOwner = source._fontDataOwner; + _externalDataAddress = source._externalDataAddress; _face = source._face; + _glyphBitmapSource = source._glyphBitmapSource; + _residentSbixGlyphIndex = source._residentSbixGlyphIndex; _cffOutlineSource = source._cffOutlineSource; _cffTypeface = source._cffTypeface; _tables = source._tables; @@ -170,6 +179,7 @@ private TtfFont( _numLayerRecords = source._numLayerRecords; _variationRoot = source; + _hasVariationTables = source._hasVariationTables; _variationData = variationData; _variationDataInitialized = true; _variationInstance = variationInstance; diff --git a/src/ProGPU.Text/TtfFont.cs b/src/ProGPU.Text/TtfFont.cs index 91a20919..e7749b12 100644 --- a/src/ProGPU.Text/TtfFont.cs +++ b/src/ProGPU.Text/TtfFont.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Compression; using System.Numerics; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Threading; @@ -126,8 +127,37 @@ public partial class TtfFont public const uint PngBitmapGraphicType = 0x706E6720; public const uint TiffBitmapGraphicType = 0x74696666; - private readonly byte[] _data; + private sealed class FontDataStorage + { + public FontDataStorage( + ReadOnlyMemory data, + byte[]? ownedData, + object? externalOwner) + { + Data = data; + OwnedData = ownedData; + ExternalOwner = externalOwner; + ExternalDataAddress = externalOwner switch + { + MappedFontData mapped => mapped.DataAddress, + EmbeddedFontData embedded => embedded.DataAddress, + _ => 0 + }; + } + + public ReadOnlyMemory Data { get; } + public byte[]? OwnedData { get; } + public object? ExternalOwner { get; } + public nint ExternalDataAddress { get; } + } + + private readonly ReadOnlyMemory _data; + private readonly byte[]? _ownedFontData; + private readonly object? _fontDataOwner; + private readonly nint _externalDataAddress; private readonly SfntFontFace _face; + private readonly GlyphBitmapSource? _glyphBitmapSource; + private readonly ushort? _residentSbixGlyphIndex; private readonly Lazy? _cffOutlineSource; private readonly Lazy? _cffTypeface; private readonly object _asciiGlyphCacheLock = new(); @@ -169,6 +199,9 @@ public partial class TtfFont public short? CapHeight { get; private set; } public ushort NumGlyphs { get; private set; } public ReadOnlyMemory FontData => _data; + internal byte[]? OwnedFontData => _ownedFontData; + internal bool UsesMappedFileStorage => _fontDataOwner is MappedFontData; + internal bool UsesExternalStorage => _fontDataOwner is not null; private short _indexToLocFormat; // 0 = short (16-bit), 1 = long (32-bit) // hmtx metrics @@ -204,10 +237,24 @@ public TtfFont(byte[] fontData) } public TtfFont(byte[] fontData, int faceIndex) + : this(CreateOwnedStorage(fontData), faceIndex, null, null) { - ArgumentNullException.ThrowIfNull(fontData); + } + + private TtfFont( + FontDataStorage storage, + int faceIndex, + GlyphBitmapSource? glyphBitmapSource, + ushort? residentSbixGlyphIndex) + { + ArgumentNullException.ThrowIfNull(storage); ArgumentOutOfRangeException.ThrowIfNegative(faceIndex); - _data = SfntFontContainer.Normalize(fontData); + _data = storage.Data; + _ownedFontData = storage.OwnedData; + _fontDataOwner = storage.ExternalOwner; + _externalDataAddress = storage.ExternalDataAddress; + _glyphBitmapSource = glyphBitmapSource; + _residentSbixGlyphIndex = residentSbixGlyphIndex; FaceIndex = faceIndex; _face = SfntFontFace.Load(_data, faceIndex); FamilyName = GetName(SfntNameIds.PreferredFamilyName, SfntNameIds.FamilyName) ?? string.Empty; @@ -215,10 +262,12 @@ public TtfFont(byte[] fontData, int faceIndex) FullName = GetName(SfntNameIds.FullName) ?? FamilyName; PostScriptName = GetName(SfntNameIds.PostScriptName) ?? string.Empty; ParseTableDirectory(); + _hasVariationTables = _tables.ContainsKey("fvar"); HasBitmapGlyphs = _tables.ContainsKey("sbix") || (_tables.ContainsKey("CBLC") && _tables.ContainsKey("CBDT")) || - (_tables.ContainsKey("bloc") && _tables.ContainsKey("bdat")); + (_tables.ContainsKey("bloc") && _tables.ContainsKey("bdat")) || + _glyphBitmapSource is not null; HasColorGlyphs = (_tables.ContainsKey("COLR") && _tables.ContainsKey("CPAL")) || _tables.ContainsKey("SVG "); @@ -243,7 +292,9 @@ public TtfFont(byte[] fontData, int faceIndex) private Typeface? LoadCffTypeface() { - byte[] data = _face.BaseOffset == 0 ? _data : _face.CreateStandaloneFontData(); + byte[] data = _face.BaseOffset == 0 && _ownedFontData is not null + ? _ownedFontData + : _face.CreateStandaloneFontData(); return TryLoadCffTypeface(data); } @@ -256,40 +307,136 @@ public TtfFont(byte[] fontData, int faceIndex) public IReadOnlyList GetOpenTypeFeatureTags() => OpenTypeTextShaper.GetFeatureTags(this); - public TtfFont(string filePath) : this(File.ReadAllBytes(filePath)) + public TtfFont(string filePath) : this(CreateFileStorage(filePath), 0, null, null) { } - public TtfFont(string filePath, int faceIndex) : this(File.ReadAllBytes(filePath), faceIndex) + public TtfFont(string filePath, int faceIndex) : this(CreateFileStorage(filePath), faceIndex, null, null) { } + public static TtfFont LoadEmbeddedResource( + Assembly assembly, + string resourceName, + int faceIndex = 0) => + new(CreateEmbeddedStorage(assembly, resourceName), faceIndex, null, null); + internal static TtfFont LoadGlyphResidentFile(string filePath, int faceIndex, ushort glyphIndex) { ArgumentNullException.ThrowIfNull(filePath); ArgumentOutOfRangeException.ThrowIfNegative(faceIndex); return SfntFontMetadataReader.TryCreateGlyphResidentFont(filePath, faceIndex, glyphIndex, out byte[] fontData) - ? new TtfFont(fontData) + ? new TtfFont( + CreateOwnedStorage(fontData), + 0, + new GlyphBitmapSource(filePath, faceIndex), + glyphIndex) : new TtfFont(filePath, faceIndex); } + internal long GlyphResidentBitmapCacheBytes => _glyphBitmapSource?.CachedBytes ?? 0; + + private static FontDataStorage CreateOwnedStorage(byte[] fontData) + { + ArgumentNullException.ThrowIfNull(fontData); + byte[] normalized = SfntFontContainer.Normalize(fontData); + return new FontDataStorage(normalized, normalized, null); + } + + private static FontDataStorage CreateFileStorage(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + Span signature = stackalloc byte[4]; + using (var stream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 1, + FileOptions.SequentialScan)) + { + stream.ReadExactly(signature); + } + + bool requiresNormalization = + signature.SequenceEqual("wOFF"u8) || + signature.SequenceEqual("wOF2"u8); + if (!requiresNormalization && + MappedFontData.TryOpen(filePath, out MappedFontData? mappedData)) + { + return new FontDataStorage(mappedData!.Memory, null, mappedData); + } + + return CreateOwnedStorage(File.ReadAllBytes(filePath)); + } + + private static FontDataStorage CreateEmbeddedStorage( + Assembly assembly, + string resourceName) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + + if (EmbeddedFontData.TryOpen(assembly, resourceName, out EmbeddedFontData? embeddedData)) + { + ReadOnlySpan data = embeddedData!.Memory.Span; + bool requiresNormalization = + data.Length >= 4 && + (data[..4].SequenceEqual("wOFF"u8) || + data[..4].SequenceEqual("wOF2"u8)); + if (!requiresNormalization) + { + return new FontDataStorage(embeddedData.Memory, null, embeddedData); + } + + byte[] compressedData = embeddedData.Memory.ToArray(); + ((IDisposable)embeddedData).Dispose(); + return CreateOwnedStorage(compressedData); + } + + using Stream stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException( + $"The embedded font '{resourceName}' is missing from '{assembly.FullName}'."); + var ownedData = new byte[checked((int)stream.Length)]; + stream.ReadExactly(ownedData); + return CreateOwnedStorage(ownedData); + } + #region Big-Endian Readers - private ushort ReadUShort(uint offset) + private unsafe ushort ReadUShort(uint offset) { - return (ushort)((_data[offset] << 8) | _data[offset + 1]); + int index = checked((int)offset); + if (_ownedFontData is { } ownedData) + { + return (ushort)((ownedData[index] << 8) | ownedData[index + 1]); + } + + byte* data = (byte*)_externalDataAddress; + return (ushort)((data[index] << 8) | data[index + 1]); } - private short ReadShort(uint offset) + private unsafe short ReadShort(uint offset) { - return (short)((_data[offset] << 8) | _data[offset + 1]); + return unchecked((short)ReadUShort(offset)); } - private uint ReadUInt(uint offset) + private unsafe uint ReadUInt(uint offset) { - return (uint)((_data[offset] << 24) | - (_data[offset + 1] << 16) | - (_data[offset + 2] << 8) | - _data[offset + 3]); + int index = checked((int)offset); + if (_ownedFontData is { } ownedData) + { + return (uint)((ownedData[index] << 24) | + (ownedData[index + 1] << 16) | + (ownedData[index + 2] << 8) | + ownedData[index + 3]); + } + + byte* data = (byte*)_externalDataAddress; + return (uint)((data[index] << 24) | + (data[index + 1] << 16) | + (data[index + 2] << 8) | + data[index + 3]); } private static ushort ReadUShort(ReadOnlySpan data, int offset) @@ -610,15 +757,34 @@ private bool TryGetSbixBitmapGlyph( float targetPixelsPerEm, out BitmapGlyphData glyph) { - glyph = default; - if (!TryGetTable("sbix", out var sbixMemory)) + if ((!_residentSbixGlyphIndex.HasValue || _residentSbixGlyphIndex.Value == glyphIndex) && + TryGetTable("sbix", out var sbixMemory) && + TryGetSbixBitmapGlyph(sbixMemory, glyphIndex, targetPixelsPerEm, out glyph)) { - return false; + return true; + } + + if (_glyphBitmapSource is not null && + _glyphBitmapSource.TryGetGlyphData(glyphIndex, out sbixMemory) && + TryGetSbixBitmapGlyph(sbixMemory, glyphIndex, targetPixelsPerEm, out glyph)) + { + return true; } + glyph = default; + return false; + } + + private bool TryGetSbixBitmapGlyph( + ReadOnlyMemory sbixMemory, + ushort glyphIndex, + float targetPixelsPerEm, + out BitmapGlyphData glyph) + { var sbix = sbixMemory.Span; if (sbix.Length < 12 || ReadUShort(sbix, 0) != 1) { + glyph = default; return false; } @@ -626,6 +792,7 @@ private bool TryGetSbixBitmapGlyph( if (strikeCount == 0 || strikeCount > int.MaxValue || 8L + strikeCount * 4L > sbix.Length) { + glyph = default; return false; } @@ -662,6 +829,118 @@ private bool TryGetSbixBitmapGlyph( return found; } + private sealed class GlyphBitmapSource + { + private const long MaxCachedBytes = 16L * 1024L * 1024L; + + private sealed class CacheEntry + { + public CacheEntry(byte[] data, LinkedListNode node) + { + Data = data; + Node = node; + } + + public byte[] Data { get; } + public LinkedListNode Node { get; } + } + + private readonly object _sync = new(); + private readonly string _filePath; + private readonly int _faceIndex; + private readonly Dictionary _entries = new(); + private readonly LinkedList _recency = new(); + private long _cachedBytes; + private ushort _lastGlyphIndex; + private byte[]? _lastGlyphData; + + public GlyphBitmapSource(string filePath, int faceIndex) + { + _filePath = filePath; + _faceIndex = faceIndex; + } + + public long CachedBytes + { + get + { + lock (_sync) + { + return _cachedBytes; + } + } + } + + public bool TryGetGlyphData(ushort glyphIndex, out ReadOnlyMemory sbix) + { + var lastData = Volatile.Read(ref _lastGlyphData); + if (lastData is not null && _lastGlyphIndex == glyphIndex) + { + sbix = lastData; + return true; + } + + lock (_sync) + { + if (_entries.TryGetValue(glyphIndex, out var cached)) + { + _recency.Remove(cached.Node); + _recency.AddFirst(cached.Node); + _lastGlyphIndex = glyphIndex; + Volatile.Write(ref _lastGlyphData, cached.Data); + sbix = cached.Data; + return true; + } + } + + if (!SfntFontMetadataReader.TryReadGlyphSbix( + _filePath, + _faceIndex, + glyphIndex, + out byte[] loaded)) + { + sbix = default; + return false; + } + + if (loaded.LongLength <= MaxCachedBytes) + { + lock (_sync) + { + if (_entries.TryGetValue(glyphIndex, out var cached)) + { + _recency.Remove(cached.Node); + _recency.AddFirst(cached.Node); + _lastGlyphIndex = glyphIndex; + Volatile.Write(ref _lastGlyphData, cached.Data); + sbix = cached.Data; + return true; + } + + while (_cachedBytes + loaded.LongLength > MaxCachedBytes && + _recency.Last is { } oldest) + { + ushort oldestGlyph = oldest.Value; + _recency.RemoveLast(); + if (_entries.Remove(oldestGlyph, out var removed)) + { + _cachedBytes -= removed.Data.LongLength; + } + } + + var node = _recency.AddFirst(glyphIndex); + _entries.Add(glyphIndex, new CacheEntry(loaded, node)); + _cachedBytes += loaded.LongLength; + _lastGlyphIndex = glyphIndex; + Volatile.Write(ref _lastGlyphData, loaded); + } + } + + sbix = loaded; + return true; + } + } + private bool TryGetBitmapGlyphFromStrike( ReadOnlyMemory sbixMemory, uint strikeOffset, @@ -1671,6 +1950,7 @@ private static short ToInt16Ceiling(float value) => private ParsedGlyph? ParseGlyphOutline(ushort glyphIndex, HashSet ancestors, int depth) { + ReadOnlySpan data = _data.Span; uint startOffset = 0; uint endOffset = 0; @@ -1722,13 +2002,13 @@ private static short ToInt16Ceiling(float value) => // Read Flags for (int i = 0; i < totalPoints; i++) { - byte flag = _data[offset++]; + byte flag = data[(int)offset++]; flags[i] = flag; // Check if flag repeats if ((flag & 8) != 0) { - byte repeatCount = _data[offset++]; + byte repeatCount = data[(int)offset++]; for (int r = 0; r < repeatCount; r++) { flags[++i] = flag; @@ -1747,7 +2027,7 @@ private static short ToInt16Ceiling(float value) => if ((flag & 2) != 0) // X Short Vector { - byte val = _data[offset++]; + byte val = data[(int)offset++]; xValue = ((flag & 16) != 0) ? val : -val; } else @@ -1775,7 +2055,7 @@ private static short ToInt16Ceiling(float value) => if ((flag & 4) != 0) // Y Short Vector { - byte val = _data[offset++]; + byte val = data[(int)offset++]; yValue = ((flag & 32) != 0) ? val : -val; } else @@ -1833,6 +2113,7 @@ private static short ToInt16Ceiling(float value) => HashSet ancestors, int depth) { + ReadOnlySpan data = _data.Span; const ushort ArgumentsAreWords = 0x0001; const ushort ArgumentsAreXyValues = 0x0002; const ushort RoundXyToGrid = 0x0004; @@ -1886,13 +2167,13 @@ private static short ToInt16Ceiling(float value) => if ((flags & ArgumentsAreXyValues) != 0) { - argument1 = unchecked((sbyte)_data[offset]); - argument2 = unchecked((sbyte)_data[offset + 1]); + argument1 = unchecked((sbyte)data[(int)offset]); + argument2 = unchecked((sbyte)data[(int)offset + 1]); } else { - argument1 = _data[offset]; - argument2 = _data[offset + 1]; + argument1 = data[(int)offset]; + argument2 = data[(int)offset + 1]; } offset += 2; } @@ -2150,14 +2431,15 @@ private void ParseCpalTable() // Check first palette record index ushort firstPaletteRecordIndex = ReadUShort(_cpalOffset + 12); + ReadOnlySpan data = _data.Span; for (int i = 0; i < _numPaletteEntries; i++) { uint recordOffset = _colorRecordsOffset + (uint)((firstPaletteRecordIndex + i) * 4); - byte b = _data[recordOffset]; - byte g = _data[recordOffset + 1]; - byte r = _data[recordOffset + 2]; - byte a = _data[recordOffset + 3]; + byte b = data[(int)recordOffset]; + byte g = data[(int)recordOffset + 1]; + byte r = data[(int)recordOffset + 2]; + byte a = data[(int)recordOffset + 3]; _colorPalette[i] = new Vector4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); } diff --git a/src/ProGPU.Vector/PathAtlas.cs b/src/ProGPU.Vector/PathAtlas.cs index bbe40bba..25719f58 100644 --- a/src/ProGPU.Vector/PathAtlas.cs +++ b/src/ProGPU.Vector/PathAtlas.cs @@ -254,7 +254,8 @@ public unsafe class PathAtlas : IDisposable private readonly WgpuContext _context; private GpuTexture _atlasTexture; - private uint _atlasSize; + private uint _atlasWidth; + private uint _atlasHeight; private readonly uint _maxAtlasSize; private readonly long _compiledPathCacheBudgetBytes; @@ -370,7 +371,9 @@ private enum RecoveryPlacementHeuristic private bool _isDisposed; public GpuTexture AtlasTexture => _atlasTexture; - public uint AtlasSize => _atlasSize; + public uint AtlasWidth => _atlasWidth; + public uint AtlasHeight => _atlasHeight; + public uint AtlasSize => Math.Max(_atlasWidth, _atlasHeight); public uint MaxAtlasSize => _maxAtlasSize; public ulong TextureRevision { get; private set; } public int CachedPathCount => _paths.Count; @@ -380,9 +383,11 @@ private enum RecoveryPlacementHeuristic public long CompiledPathCacheBudgetBytes => _compiledPathCacheBudgetBytes; public ulong Generation { get; private set; } public bool CapacityExceeded { get; private set; } - public ulong PersistentTextureBytes => (ulong)_atlasSize * _atlasSize; + public ulong PersistentTextureBytes => (ulong)_atlasWidth * _atlasHeight; public uint LastRasterStagingBytes { get; private set; } public uint PeakRasterStagingBytes { get; private set; } + public uint PeakRasterWidth { get; private set; } + public uint PeakRasterHeight { get; private set; } public int LastExactRecoveryNodeCount { get; private set; } public int LastExactRecoveryCandidateCount { get; private set; } public bool LastExactRecoveryBudgetExceeded { get; private set; } @@ -403,13 +408,14 @@ public PathAtlas( _context = context; _maxAtlasSize = atlasSize; - _atlasSize = Math.Min(atlasSize, DefaultInitialAtlasSize); + _atlasWidth = Math.Min(atlasSize, DefaultInitialAtlasSize); + _atlasHeight = _atlasWidth; _compiledPathCacheBudgetBytes = compiledPathCacheBudgetBytes; _atlasTexture = new GpuTexture( _context, - _atlasSize, - _atlasSize, + _atlasWidth, + _atlasHeight, TextureFormat.R8Unorm, TextureUsage.TextureBinding | TextureUsage.CopyDst | TextureUsage.CopySrc | TextureUsage.RenderAttachment, @@ -1115,14 +1121,14 @@ private void RepackActivePaths() uint gW = info.Width; uint gH = info.Height; - if (_currentX + gW + 2 > _atlasSize) + if (_currentX + gW + 2 > _atlasWidth) { _currentX = 2; _currentY += _currentRowHeight + 2; _currentRowHeight = 0; } - if (_currentY + gH + 2 > _atlasSize) + if (_currentY + gH + 2 > _atlasHeight) { ProGpuVectorDiagnostics.WriteLine("[PathAtlas] Warning: Even active paths in the current frame exceed the atlas size during repack!"); break; @@ -1134,7 +1140,8 @@ private void RepackActivePaths() _currentX += gW + 2; _currentRowHeight = Math.Max(_currentRowHeight, gH); - float texelSize = 1.0f / _atlasSize; + float texelSizeX = 1.0f / _atlasWidth; + float texelSizeY = 1.0f / _atlasHeight; var newInfo = new PathInfo { Key = info.Key, @@ -1148,11 +1155,11 @@ private void RepackActivePaths() Width = gW, Height = gH, TexCoordMin = new Vector2( - (posX + info.Key.SubpixelX) * texelSize, - (posY + info.Key.SubpixelY) * texelSize), + (posX + info.Key.SubpixelX) * texelSizeX, + (posY + info.Key.SubpixelY) * texelSizeY), TexCoordMax = new Vector2( - (posX + gW + info.Key.SubpixelX) * texelSize, - (posY + gH + info.Key.SubpixelY) * texelSize), + (posX + gW + info.Key.SubpixelX) * texelSizeX, + (posY + gH + info.Key.SubpixelY) * texelSizeY), MinX = info.MinX, MinY = info.MinY, LastUsedFrame = info.LastUsedFrame @@ -1229,7 +1236,7 @@ public void ResetForRenderRetry() : string.Empty; throw new InvalidOperationException( $"PathAtlas could not deterministically pack the live path set in the configured " + - $"{_atlasSize}x{_atlasSize} atlas after multi-strategy retry packing " + + $"{_atlasWidth}x{_atlasHeight} atlas after multi-strategy retry packing " + $"({livePaths.Count} live paths{exactSearchStatus})."); } @@ -1273,7 +1280,8 @@ private bool TryPackRecoveryPaths( LastExactRecoveryNodeCount = 0; LastExactRecoveryCandidateCount = 0; LastExactRecoveryBudgetExceeded = false; - uint availableSize = _atlasSize > 2 ? _atlasSize - 2 : 0; + uint availableWidth = _atlasWidth > 2 ? _atlasWidth - 2 : 0; + uint availableHeight = _atlasHeight > 2 ? _atlasHeight - 2 : 0; var trialFreeRectangles = new List(Math.Max(4, livePaths.Count * 2)); var trialPlacements = new List(livePaths.Count); @@ -1290,7 +1298,11 @@ private bool TryPackRecoveryPaths( { RecoveryPlacementHeuristic heuristic = (RecoveryPlacementHeuristic)heuristicIndex; trialFreeRectangles.Clear(); - trialFreeRectangles.Add(new AtlasFreeRectangle(2, 2, availableSize, availableSize)); + trialFreeRectangles.Add(new AtlasFreeRectangle( + 2, + 2, + availableWidth, + availableHeight)); trialPlacements.Clear(); bool succeeded = true; @@ -1374,7 +1386,8 @@ private bool TryPackRecoveryPathsExactly( out List placements, out List freeRectangles) { - uint availableSize = _atlasSize > 2 ? _atlasSize - 2 : 0; + uint availableWidth = _atlasWidth > 2 ? _atlasWidth - 2 : 0; + uint availableHeight = _atlasHeight > 2 ? _atlasHeight - 2 : 0; var orderedPaths = new List(livePaths.Count); var emptyPaths = new List(); ulong packedArea = 0; @@ -1389,7 +1402,7 @@ private bool TryPackRecoveryPathsExactly( uint packedWidth = checked(path.Width + 2); uint packedHeight = checked(path.Height + 2); - if (packedWidth > availableSize || packedHeight > availableSize) + if (packedWidth > availableWidth || packedHeight > availableHeight) { placements = new List(); freeRectangles = new List(); @@ -1401,15 +1414,23 @@ private bool TryPackRecoveryPathsExactly( } if (orderedPaths.Count > ExactRecoveryPathLimit || - packedArea > (ulong)availableSize * availableSize) + packedArea > (ulong)availableWidth * availableHeight) { placements = new List(); freeRectangles = new List(); return false; } - if (ExceedsExactRecoveryIncompatibilityBound(orderedPaths, availableSize, useWidths: true) || - ExceedsExactRecoveryIncompatibilityBound(orderedPaths, availableSize, useWidths: false)) + if (ExceedsExactRecoveryIncompatibilityBound( + orderedPaths, + availableWidth, + availableHeight, + useWidths: true) || + ExceedsExactRecoveryIncompatibilityBound( + orderedPaths, + availableHeight, + availableWidth, + useWidths: false)) { placements = new List(); freeRectangles = new List(); @@ -1418,8 +1439,14 @@ private bool TryPackRecoveryPathsExactly( orderedPaths.Sort(static (left, right) => CompareRetryPaths(left, right, RetryPathOrdering.AreaDescending)); - uint[] xCoordinates = BuildExactRecoveryCoordinates(orderedPaths, availableSize, useWidth: true); - uint[] yCoordinates = BuildExactRecoveryCoordinates(orderedPaths, availableSize, useWidth: false); + uint[] xCoordinates = BuildExactRecoveryCoordinates( + orderedPaths, + availableWidth, + useWidth: true); + uint[] yCoordinates = BuildExactRecoveryCoordinates( + orderedPaths, + availableHeight, + useWidth: false); var placedRectangles = new AtlasFreeRectangle[orderedPaths.Count]; // Algorithm: every integral orthogonal packing can be translated into a @@ -1435,7 +1462,8 @@ private bool TryPackRecoveryPathsExactly( orderedPaths, xCoordinates, yCoordinates, - availableSize, + availableWidth, + availableHeight, placedRectangles, pathIndex: 0, ref searchState); @@ -1452,7 +1480,7 @@ private bool TryPackRecoveryPathsExactly( placements = new List(livePaths.Count); freeRectangles = new List(Math.Max(4, orderedPaths.Count * 2)) { - new AtlasFreeRectangle(2, 2, availableSize, availableSize) + new AtlasFreeRectangle(2, 2, availableWidth, availableHeight) }; for (int pathIndex = 0; pathIndex < orderedPaths.Count; pathIndex++) { @@ -1476,7 +1504,8 @@ private bool TryPackRecoveryPathsExactly( private static bool ExceedsExactRecoveryIncompatibilityBound( List paths, - uint extent, + uint parallelExtent, + uint perpendicularExtentLimit, bool useWidths) { int subsetCount = 1 << paths.Count; @@ -1503,7 +1532,7 @@ private static bool ExceedsExactRecoveryIncompatibilityBound( RetryPath right = paths[rightIndex]; ulong rightDimension = (useWidths ? right.Width : right.Height) + 2UL; - if (leftDimension + rightDimension <= extent) + if (leftDimension + rightDimension <= parallelExtent) { pairwiseIncompatible = false; break; @@ -1511,7 +1540,7 @@ private static bool ExceedsExactRecoveryIncompatibilityBound( } } - if (pairwiseIncompatible && perpendicularExtent > extent) + if (pairwiseIncompatible && perpendicularExtent > perpendicularExtentLimit) { return true; } @@ -1551,7 +1580,8 @@ private static bool TryPlaceExactRecoveryPath( List paths, uint[] xCoordinates, uint[] yCoordinates, - uint extent, + uint widthExtent, + uint heightExtent, AtlasFreeRectangle[] placedRectangles, int pathIndex, ref ExactRecoverySearchState searchState) @@ -1572,7 +1602,7 @@ private static bool TryPlaceExactRecoveryPath( for (int yIndex = 0; yIndex < yCoordinates.Length; yIndex++) { uint y = yCoordinates[yIndex]; - if (y > extent - height) + if (y > heightExtent - height) { break; } @@ -1585,7 +1615,7 @@ private static bool TryPlaceExactRecoveryPath( } uint x = xCoordinates[xIndex]; - if (x > extent - width) + if (x > widthExtent - width) { break; } @@ -1601,7 +1631,8 @@ private static bool TryPlaceExactRecoveryPath( paths, xCoordinates, yCoordinates, - extent, + widthExtent, + heightExtent, placedRectangles, pathIndex + 1, ref searchState)) @@ -1731,17 +1762,18 @@ private PathInfo CreatePlacedPathInfo( uint atlasX, uint atlasY) { - float texelSize = 1.0f / _atlasSize; + float texelSizeX = 1.0f / _atlasWidth; + float texelSizeY = 1.0f / _atlasHeight; source.X = atlasX; source.Y = atlasY; source.Width = width; source.Height = height; source.TexCoordMin = new Vector2( - (atlasX + source.Key.SubpixelX) * texelSize, - (atlasY + source.Key.SubpixelY) * texelSize); + (atlasX + source.Key.SubpixelX) * texelSizeX, + (atlasY + source.Key.SubpixelY) * texelSizeY); source.TexCoordMax = new Vector2( - (atlasX + width + source.Key.SubpixelX) * texelSize, - (atlasY + height + source.Key.SubpixelY) * texelSize); + (atlasX + width + source.Key.SubpixelX) * texelSizeX, + (atlasY + height + source.Key.SubpixelY) * texelSizeY); source.MinX = xStart; source.MinY = yStart; source.LastUsedFrame = _frameNumber; @@ -2211,16 +2243,19 @@ public PathInfo GetOrCreatePath( uint gW = (uint)width; uint gH = (uint)height; + PeakRasterWidth = Math.Max(PeakRasterWidth, gW); + PeakRasterHeight = Math.Max(PeakRasterHeight, gH); - while ((gW + 4 > _atlasSize || gH + 4 > _atlasSize) && TryGrowAtlas(gW, gH)) + while ((gW + 4 > _atlasWidth || gH + 4 > _atlasHeight) && + TryGrowAtlas(gW, gH)) { } - if (gW + 4 > _atlasSize || gH + 4 > _atlasSize) + if (gW + 4 > _atlasWidth || gH + 4 > _atlasHeight) { PathFigure? firstFigure = path.Figures.Count > 0 ? path.Figures[0] : null; ProGpuVectorDiagnostics.WriteLine( - $"[PathAtlas] Warning: Path raster {gW}x{gH} cannot fit in the {_atlasSize}x{_atlasSize} atlas " + + $"[PathAtlas] Warning: Path raster {gW}x{gH} cannot fit in the {_atlasWidth}x{_atlasHeight} atlas " + $"(combined={path.IsCombined}, figures={path.Figures.Count}, firstClosed={firstFigure?.IsClosed}, " + $"firstFilled={firstFigure?.IsFilled}, firstSegments={firstFigure?.Segments.Count})."); CapacityExceeded = true; @@ -2297,14 +2332,14 @@ public PathInfo GetOrCreatePath( return info; } - if (_currentX + gW + 2 > _atlasSize) + if (_currentX + gW + 2 > _atlasWidth) { _currentX = 2; _currentY += _currentRowHeight + 2; _currentRowHeight = 0; } - if (_currentY + gH + 2 > _atlasSize) + if (_currentY + gH + 2 > _atlasHeight) { if (TryGrowAtlas(gW, gH)) { @@ -2325,14 +2360,14 @@ public PathInfo GetOrCreatePath( RepackActivePaths(); } - if (_currentX + gW + 2 > _atlasSize) + if (_currentX + gW + 2 > _atlasWidth) { _currentX = 2; _currentY += _currentRowHeight + 2; _currentRowHeight = 0; } - if (_currentY + gH + 2 > _atlasSize) + if (_currentY + gH + 2 > _atlasHeight) { ProGpuVectorDiagnostics.WriteLine("[PathAtlas] Warning: The current frame exceeds the atlas size; preserving existing path coordinates."); CapacityExceeded = true; @@ -2365,7 +2400,8 @@ public PathInfo GetOrCreatePath( _currentX += gW + 2; _currentRowHeight = Math.Max(_currentRowHeight, gH); - float texelSize = 1.0f / _atlasSize; + float texelSizeX = 1.0f / _atlasWidth; + float texelSizeY = 1.0f / _atlasHeight; info = new PathInfo { Key = key, @@ -2379,11 +2415,11 @@ public PathInfo GetOrCreatePath( Width = gW, Height = gH, TexCoordMin = new Vector2( - (posX + key.SubpixelX) * texelSize, - (posY + key.SubpixelY) * texelSize), + (posX + key.SubpixelX) * texelSizeX, + (posY + key.SubpixelY) * texelSizeY), TexCoordMax = new Vector2( - (posX + gW + key.SubpixelX) * texelSize, - (posY + gH + key.SubpixelY) * texelSize), + (posX + gW + key.SubpixelX) * texelSizeX, + (posY + gH + key.SubpixelY) * texelSizeY), MinX = xStart, MinY = yStart, LastUsedFrame = _frameNumber @@ -2397,62 +2433,118 @@ public PathInfo GetOrCreatePath( private bool TryGrowAtlas(uint requiredWidth, uint requiredHeight) { - if (_atlasSize >= _maxAtlasSize) + if (_atlasWidth >= _maxAtlasSize && _atlasHeight >= _maxAtlasSize) { return false; } - uint requiredSize = checked(Math.Max(requiredWidth, requiredHeight) + 4); - uint oldSize = _atlasSize; - uint newSize = oldSize; - do - { - newSize = Math.Min(_maxAtlasSize, checked(newSize * 2)); + uint requiredAtlasWidth = checked(requiredWidth + 4); + uint requiredAtlasHeight = checked(requiredHeight + 4); + uint oldWidth = _atlasWidth; + uint oldHeight = _atlasHeight; + uint newWidth = GrowAtlasDimension( + oldWidth, + requiredAtlasWidth, + _maxAtlasSize); + uint newHeight = GrowAtlasDimension( + oldHeight, + requiredAtlasHeight, + _maxAtlasSize); + bool requiredWidthGrowth = requiredAtlasWidth > oldWidth; + bool requiredHeightGrowth = requiredAtlasHeight > oldHeight; + + // Calls made after the requested raster already fits mean the shelf packer + // exhausted vertical capacity. Prefer extending that axis. Once height is + // capped, expose a new right-hand strip without moving existing texels. + bool enterRightHandStrip = false; + if (newWidth == oldWidth && newHeight == oldHeight) + { + if (oldHeight < _maxAtlasSize) + { + newHeight = Math.Min(_maxAtlasSize, checked(oldHeight * 2)); + } + else if (oldWidth < _maxAtlasSize) + { + newWidth = Math.Min(_maxAtlasSize, checked(oldWidth * 2)); + enterRightHandStrip = true; + } } - while (newSize < requiredSize && newSize < _maxAtlasSize); - if (newSize < requiredSize) + + if (newWidth < requiredAtlasWidth || + newHeight < requiredAtlasHeight || + (newWidth == oldWidth && newHeight == oldHeight)) { return false; } var newTexture = new GpuTexture( _context, - newSize, - newSize, + newWidth, + newHeight, TextureFormat.R8Unorm, TextureUsage.TextureBinding | TextureUsage.CopyDst | TextureUsage.CopySrc | TextureUsage.RenderAttachment, "Dynamic Path Coverage Atlas"); newTexture.ClearRenderTarget(); - newTexture.CopyBaseLevelRegionFrom(_atlasTexture, oldSize, oldSize); + newTexture.CopyBaseLevelRegionFrom(_atlasTexture, oldWidth, oldHeight); GpuTexture oldTexture = _atlasTexture; _atlasTexture = newTexture; - _atlasSize = newSize; + _atlasWidth = newWidth; + _atlasHeight = newHeight; oldTexture.Dispose(); if (_recoveryFreeRectangles != null) { - _recoveryFreeRectangles.Add(new AtlasFreeRectangle( - oldSize, - 2, - newSize - oldSize, - oldSize - 2)); - _recoveryFreeRectangles.Add(new AtlasFreeRectangle( - 2, - oldSize, - newSize - 2, - newSize - oldSize)); + if (newWidth > oldWidth) + { + _recoveryFreeRectangles.Add(new AtlasFreeRectangle( + oldWidth, + 2, + newWidth - oldWidth, + oldHeight - 2)); + } + if (newHeight > oldHeight) + { + _recoveryFreeRectangles.Add(new AtlasFreeRectangle( + 2, + oldHeight, + newWidth - 2, + newHeight - oldHeight)); + } + } + else if (enterRightHandStrip && + !requiredWidthGrowth && + !requiredHeightGrowth) + { + _currentX = oldWidth; + _currentY = 2; + _currentRowHeight = 0; } RefreshNormalizedTextureCoordinates(); TextureRevision++; + Generation++; CapacityExceeded = false; return true; } + private static uint GrowAtlasDimension( + uint current, + uint required, + uint maximum) + { + uint grown = current; + while (grown < required && grown < maximum) + { + grown = Math.Min(maximum, checked(grown * 2)); + } + return grown; + } + private void RefreshNormalizedTextureCoordinates() { - float texelSize = 1f / _atlasSize; + float texelSizeX = 1f / _atlasWidth; + float texelSizeY = 1f / _atlasHeight; foreach (KeyValuePair pair in _paths) { ref PathInfo info = ref CollectionsMarshal.GetValueRefOrNullRef( @@ -2464,22 +2556,22 @@ private void RefreshNormalizedTextureCoordinates() } info.TexCoordMin = new Vector2( - (info.X + info.Key.SubpixelX) * texelSize, - (info.Y + info.Key.SubpixelY) * texelSize); + (info.X + info.Key.SubpixelX) * texelSizeX, + (info.Y + info.Key.SubpixelY) * texelSizeY); info.TexCoordMax = new Vector2( - (info.X + info.Width + info.Key.SubpixelX) * texelSize, - (info.Y + info.Height + info.Key.SubpixelY) * texelSize); + (info.X + info.Width + info.Key.SubpixelX) * texelSizeX, + (info.Y + info.Height + info.Key.SubpixelY) * texelSizeY); } for (int pendingIndex = 0; pendingIndex < _pendingPaths.Count; pendingIndex++) { PathInfo info = _pendingPaths[pendingIndex]; info.TexCoordMin = new Vector2( - (info.X + info.Key.SubpixelX) * texelSize, - (info.Y + info.Key.SubpixelY) * texelSize); + (info.X + info.Key.SubpixelX) * texelSizeX, + (info.Y + info.Key.SubpixelY) * texelSizeY); info.TexCoordMax = new Vector2( - (info.X + info.Width + info.Key.SubpixelX) * texelSize, - (info.Y + info.Height + info.Key.SubpixelY) * texelSize); + (info.X + info.Width + info.Key.SubpixelX) * texelSizeX, + (info.Y + info.Height + info.Key.SubpixelY) * texelSizeY); _pendingPaths[pendingIndex] = info; } } diff --git a/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs b/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs index 5f068faf..d799aed4 100644 --- a/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs +++ b/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs @@ -24,7 +24,6 @@ public static class TextLayoutEngine CreateCommonShapingOptions(ShapingDirection.LeftToRight); private static readonly TextShapingOptions[] CommonRightToLeftShapingOptions = CreateCommonShapingOptions(ShapingDirection.RightToLeft); - public static void AccumulateInlines( Inline inline, List list, @@ -256,16 +255,18 @@ public static float LayoutSingleColumn( defaultIsItalic); } - private static int GetInlinesLength(IEnumerable inlines) + private static int GetInlinesLength(RichElementCollection inlines) { - int len = 0; - foreach (var inline in inlines) + int length = 0; + for (int index = 0; index < inlines.Count; index++) { - if (inline is Run run) len += run.Text.Length; - else if (inline is Span span) len += GetInlinesLength(span.Inlines); - else if (inline is LineBreak) len += 1; + Inline inline = inlines[index]; + if (inline is Run run) length += run.Text.Length; + else if (inline is Span span) length += GetInlinesLength(span.Inlines); + else if (inline is LineBreak) length++; } - return len; + + return length; } private static int GetBlockTextLength(Block block) => block switch @@ -292,14 +293,16 @@ private static void RebaseBlockCharacters(RichBlockLayoutCache cache, int logica cache.RebaseTextPositions(logicalTextOffset); } - private static int CountLineBreaks(IEnumerable inlines) + private static int CountLineBreaks(RichElementCollection inlines) { int count = 0; - foreach (var inline in inlines) + for (int index = 0; index < inlines.Count; index++) { + Inline inline = inlines[index]; if (inline is LineBreak) count++; else if (inline is Span span) count += CountLineBreaks(span.Inlines); } + return count; } @@ -691,12 +694,19 @@ private static ParagraphShapingMetrics MeasureShapedParagraphAdvances( return metrics; } - private static sbyte[] GetLineBidiLevels( + private static void GetLineBidiLevels( IReadOnlyList line, ParagraphShapingMetrics metrics, + Span levels, out sbyte paragraphLevel) { - var levels = new sbyte[line.Count]; + if (levels.Length != line.Count) + { + throw new ArgumentException( + "Line-level storage must match the positioned character count.", + nameof(levels)); + } + paragraphLevel = 0; for (int index = 0; index < line.Count; index++) { @@ -708,7 +718,6 @@ private static sbyte[] GetLineBidiLevels( // UAX #9 L1 resets trailing whitespace on each already-broken line. for (int index = line.Count - 1; index >= 0 && char.IsWhiteSpace(line[index].Info.Character); index--) levels[index] = paragraphLevel; - return levels; } private static bool IsClusterBoundaryBefore(ParagraphShapingMetrics metrics, int textPosition) @@ -721,28 +730,39 @@ private static bool IsClusterBoundaryBefore(ParagraphShapingMetrics metrics, int return metrics.BreakSafety[textPosition] != 0; } - private static float EstimateBlockHeight(Block block, float availableWidth, float baseFontSize, TtfFont activeFont) + private static float EstimateBlockHeight( + Block block, + float availableWidth, + float baseFontSize, + TtfFont activeFont) { float scale = baseFontSize / activeFont.UnitsPerEm; - float lineSpacing = (activeFont.Ascender - activeFont.Descender + activeFont.LineGap) * scale; + float lineSpacing = + (activeFont.Ascender - activeFont.Descender + activeFont.LineGap) * scale; if (block is Paragraph paragraph) { int charCount = GetInlinesLength(paragraph.Inlines); if (charCount == 0) return block.MarginBottom; - // Detect the maximum font size in children runs/spans + // Preserve the existing one-level font-size scan used by the + // virtualization estimate while avoiding boxed collection enumerators. float maxFontSize = baseFontSize; - foreach (var inline in paragraph.Inlines) + for (int inlineIndex = 0; + inlineIndex < paragraph.Inlines.Count; + inlineIndex++) { + Inline inline = paragraph.Inlines[inlineIndex]; if (inline.FontSize.HasValue) { maxFontSize = Math.Max(maxFontSize, inline.FontSize.Value); } + if (inline is Span span) { - foreach (var sub in span.Inlines) + for (int subIndex = 0; subIndex < span.Inlines.Count; subIndex++) { + Inline sub = span.Inlines[subIndex]; if (sub.FontSize.HasValue) { maxFontSize = Math.Max(maxFontSize, sub.FontSize.Value); @@ -758,39 +778,53 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa int lineBreaks = CountLineBreaks(paragraph.Inlines); estimatedLines = Math.Max(estimatedLines, lineBreaks + 1); - float blockLineSpacing = (activeFont.Ascender - activeFont.Descender + activeFont.LineGap) * (maxFontSize / activeFont.UnitsPerEm); + float blockLineSpacing = + (activeFont.Ascender - activeFont.Descender + activeFont.LineGap) * + (maxFontSize / activeFont.UnitsPerEm); float embeddedHeight = 0f; - foreach (var inline in paragraph.Inlines) + for (int inlineIndex = 0; + inlineIndex < paragraph.Inlines.Count; + inlineIndex++) { - if (inline is InlineUIContainer uic && uic.Child != null) + Inline inline = paragraph.Inlines[inlineIndex]; + if (inline is not InlineUIContainer { Child: not null } uic) { - if (uic.Child.HeightConstraint.HasValue) - { - embeddedHeight += uic.Child.HeightConstraint.Value; - } - else if (uic.Child is Border border && border.Child is RichTextBlock rtb) - { - int codeChars = GetInlinesLength(rtb.Inlines); - embeddedHeight += Math.Max(40f, (codeChars / 50f) * blockLineSpacing + 20f); - } - else if (uic.Child is Border border2 && border2.Child is StackPanel quoteStack) + continue; + } + + if (uic.Child.HeightConstraint.HasValue) + { + embeddedHeight += uic.Child.HeightConstraint.Value; + } + else if (uic.Child is Border border && + border.Child is RichTextBlock rtb) + { + int codeChars = GetInlinesLength(rtb.Inlines); + embeddedHeight += + Math.Max(40f, (codeChars / 50f) * blockLineSpacing + 20f); + } + else if (uic.Child is Border border2 && + border2.Child is StackPanel quoteStack) + { + float quoteHeight = 10f; + foreach (var quoteChild in quoteStack.Children) { - float quoteHeight = 10f; - foreach (var quoteChild in quoteStack.Children) + if (quoteChild is RichTextBlock qRtb) { - if (quoteChild is RichTextBlock qRtb) - { - int quoteChars = GetInlinesLength(qRtb.Inlines); - quoteHeight += Math.Max(20f, (quoteChars / 60f) * blockLineSpacing + 6f); - } + int quoteChars = GetInlinesLength(qRtb.Inlines); + quoteHeight += + Math.Max( + 20f, + (quoteChars / 60f) * blockLineSpacing + 6f); } - embeddedHeight += quoteHeight; - } - else - { - embeddedHeight += 100f; } + + embeddedHeight += quoteHeight; + } + else + { + embeddedHeight += 100f; } } @@ -801,37 +835,55 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa return estimatedLines * blockLineSpacing + block.MarginBottom; } - else if (block is ListBlock listBlock) + + if (block is ListBlock listBlock) { float listHeight = 0f; - foreach (var item in listBlock.Items) + for (int itemIndex = 0; itemIndex < listBlock.Items.Count; itemIndex++) { + ListItem item = listBlock.Items[itemIndex]; int itemCharCount = GetInlinesLength(item.Inlines); float avgCharWidth = baseFontSize * 0.49f; - float charsPerLine = Math.Max(10f, (availableWidth - listBlock.Indentation) / avgCharWidth); - int estimatedLines = (int)Math.Ceiling(itemCharCount / charsPerLine); + float charsPerLine = + Math.Max( + 10f, + (availableWidth - listBlock.Indentation) / avgCharWidth); + int estimatedLines = + (int)Math.Ceiling(itemCharCount / charsPerLine); listHeight += Math.Max(1, estimatedLines) * lineSpacing; } + return listHeight + block.MarginBottom; } - else if (block is Table table) + + if (block is Table table) { float tableHeight = 10f; - foreach (var row in table.Rows) + for (int rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) { + TableRow row = table.Rows[rowIndex]; float rowHeight = baseFontSize + table.CellPadding * 2f; int maxCellChars = 0; - foreach (var cell in row.Cells) + for (int cellIndex = 0; cellIndex < row.Cells.Count; cellIndex++) { - maxCellChars = Math.Max(maxCellChars, GetInlinesLength(cell.Inlines)); + TableCell cell = row.Cells[cellIndex]; + maxCellChars = + Math.Max(maxCellChars, GetInlinesLength(cell.Inlines)); } + float cellWidth = availableWidth / Math.Max(1, row.Cells.Count); - float charsPerLine = Math.Max(5f, cellWidth / (baseFontSize * 0.49f)); + float charsPerLine = + Math.Max(5f, cellWidth / (baseFontSize * 0.49f)); int cellLines = (int)Math.Ceiling(maxCellChars / charsPerLine); - rowHeight = Math.Max(rowHeight, Math.Max(1, cellLines) * lineSpacing + table.CellPadding * 2f); + rowHeight = + Math.Max( + rowHeight, + Math.Max(1, cellLines) * lineSpacing + + table.CellPadding * 2f); tableHeight += rowHeight; } + return tableHeight + block.MarginBottom; } @@ -1055,7 +1107,13 @@ void CommitLine(List line, bool isLastLine) pc.Position.Y = cursorY + (completedLineHeight - h) / 2f - pc.Info.BaselineOffset; } - sbyte[] lineLevels = GetLineBidiLevels(line, shapingMetrics, out sbyte paragraphLevel); + sbyte[]? heapLineLevels = line.Count > 256 + ? new sbyte[line.Count] + : null; + Span lineLevels = heapLineLevels is not null + ? heapLineLevels + : stackalloc sbyte[line.Count]; + GetLineBidiLevels(line, shapingMetrics, lineLevels, out sbyte paragraphLevel); ApplyShapedClusterMetrics(line, lineLevels, activeFont, ignoreTrailingCharacterSpacing); float logicalCursor = line.Min(static character => character.Position.X); for (int logicalIndex = 0; logicalIndex < line.Count; logicalIndex++) @@ -1073,12 +1131,12 @@ void CommitLine(List line, bool isLastLine) } logicalCursor += logical.ShapedAdvance; } - int[] visualOrder = BidiParagraph.GetVisualOrder(lineLevels); + int[]? visualOrder = BidiParagraph.GetVisualOrderIfNeeded(lineLevels); float visualCursorX = line.Min(static character => character.Position.X); float visualLineOrigin = visualCursorX; - for (int visualIndex = 0; visualIndex < visualOrder.Length; visualIndex++) + for (int visualIndex = 0; visualIndex < line.Count; visualIndex++) { - int logicalIndex = visualOrder[visualIndex]; + int logicalIndex = visualOrder?[visualIndex] ?? visualIndex; PositionedRichChar character = line[logicalIndex]; character.Position.X = visualCursorX; visualCursorX += character.ShapedAdvance; @@ -1127,9 +1185,10 @@ void CommitLine(List line, bool isLastLine) float extraW = availableWidth - lineW; float spaceAddition = extraW / spaceCount; float runningAddition = 0f; - for (int k = 0; k < visualOrder.Length; k++) + for (int k = 0; k < line.Count; k++) { - var pc = line[visualOrder[k]]; + int logicalIndex = visualOrder?[k] ?? k; + var pc = line[logicalIndex]; pc.Position.X += runningAddition; if (pc.Info.Character == ' ') { @@ -2507,13 +2566,20 @@ void CommitCellLine(List line) pc.Position.Y = cursorY + (completedLineHeight - h) / 2f - pc.Info.BaselineOffset; } - sbyte[] lineLevels = GetLineBidiLevels(line, shapingMetrics, out _); + sbyte[]? heapLineLevels = line.Count > 256 + ? new sbyte[line.Count] + : null; + Span lineLevels = heapLineLevels is not null + ? heapLineLevels + : stackalloc sbyte[line.Count]; + GetLineBidiLevels(line, shapingMetrics, lineLevels, out _); ApplyShapedClusterMetrics(line, lineLevels, activeFont, ignoreTrailingCharacterSpacing); - int[] visualOrder = BidiParagraph.GetVisualOrder(lineLevels); + int[]? visualOrder = BidiParagraph.GetVisualOrderIfNeeded(lineLevels); float visualCursorX = cellPadding; - for (int visualIndex = 0; visualIndex < visualOrder.Length; visualIndex++) + for (int visualIndex = 0; visualIndex < line.Count; visualIndex++) { - PositionedRichChar character = line[visualOrder[visualIndex]]; + int logicalIndex = visualOrder?[visualIndex] ?? visualIndex; + PositionedRichChar character = line[logicalIndex]; character.Position.X = visualCursorX; visualCursorX += character.ShapedAdvance; } @@ -2764,7 +2830,7 @@ public static void LayoutMultiColumn( } float[] shapedAdvances = shapingMetrics.Advances; - var paragraphLines = new List<(List Chars, float ColumnX, int[] VisualOrder, sbyte ParagraphLevel)>(); + var paragraphLines = new List<(List Chars, float ColumnX, int[]? VisualOrder, sbyte ParagraphLevel)>(); int i = 0; bool hasResetLineIndent = false; @@ -2894,7 +2960,17 @@ public static void LayoutMultiColumn( runningX += advance; } - sbyte[] lineLevels = GetLineBidiLevels(currentLine, shapingMetrics, out sbyte paragraphLevel); + sbyte[]? heapLineLevels = currentLine.Count > 256 + ? new sbyte[currentLine.Count] + : null; + Span lineLevels = heapLineLevels is not null + ? heapLineLevels + : stackalloc sbyte[currentLine.Count]; + GetLineBidiLevels( + currentLine, + shapingMetrics, + lineLevels, + out sbyte paragraphLevel); ApplyShapedClusterMetrics(currentLine, lineLevels, activeFont); float logicalCursor = 0f; for (int logicalIndex = 0; logicalIndex < currentLine.Count; logicalIndex++) @@ -2912,11 +2988,12 @@ public static void LayoutMultiColumn( } logicalCursor += logical.ShapedAdvance; } - int[] visualOrder = BidiParagraph.GetVisualOrder(lineLevels); + int[]? visualOrder = BidiParagraph.GetVisualOrderIfNeeded(lineLevels); float visualCursorX = currentLine.Min(static character => character.Position.X); - for (int visualIndex = 0; visualIndex < visualOrder.Length; visualIndex++) + for (int visualIndex = 0; visualIndex < currentLine.Count; visualIndex++) { - PositionedRichChar character = currentLine[visualOrder[visualIndex]]; + int logicalIndex = visualOrder?[visualIndex] ?? visualIndex; + PositionedRichChar character = currentLine[logicalIndex]; character.Position.X = visualCursorX; visualCursorX += character.ShapedAdvance; } @@ -2974,9 +3051,10 @@ public static void LayoutMultiColumn( float extraW = colWidth - lineW; float spaceAddition = extraW / spaceCount; float runningAddition = 0f; - for (int k = 0; k < visualOrder.Length; k++) + for (int k = 0; k < line.Count; k++) { - var pc = line[visualOrder[k]]; + int logicalIndex = visualOrder?[k] ?? k; + var pc = line[logicalIndex]; pc.Position.X += runningAddition; if (pc.Info.Character == ' ') { diff --git a/tests/Avalonia.ProGpu.UnitTests/DrawingContextImplTests.cs b/tests/Avalonia.ProGpu.UnitTests/DrawingContextImplTests.cs index 0279f647..30e4d9bb 100644 --- a/tests/Avalonia.ProGpu.UnitTests/DrawingContextImplTests.cs +++ b/tests/Avalonia.ProGpu.UnitTests/DrawingContextImplTests.cs @@ -10,12 +10,29 @@ using Avalonia.Platform.Surfaces; using ProGPU.Backend; using ProGPU.Scene; +using ProGPU.Text; using Xunit; namespace Avalonia.ProGpu.UnitTests { public class DrawingContextImplTests { + [Fact] + public void Backend_Compositor_Uses_Compact_Core_Reservations_Without_Gpu_Hit_Testing() + { + CompositorOptions options = DrawingContextImpl.BackendCompositorOptions; + + Assert.Equal(1024u, options.InitialVertexCount); + Assert.Equal(1536u, options.InitialIndexCount); + Assert.Equal(64u, options.InitialBrushCount); + Assert.Equal(512u, options.InitialGradientStopCount); + Assert.Equal(260u, options.InitialGlyphAtlasSize); + Assert.Equal(64u, options.InitialColorGlyphAtlasSize); + Assert.Equal(16u * 1024u, options.GlyphUniformStagingBytes); + Assert.Equal(GlyphAtlas.DefaultCoverageRingBufferSize, options.GlyphCoverageStagingBytes); + Assert.False(options.EnableGpuHitTesting); + } + [Fact] public void Framebuffer_Render_Target_Supports_Direct_Rendering() { @@ -44,6 +61,18 @@ public void DrawLine_With_Zero_Thickness_Pen_Does_Not_Throw() target.DrawLine(new Pen(Brushes.Black, 0), new Point(0, 0), new Point(10, 10)); } + [Fact] + public void DrawLine_With_Solid_Pen_Does_Not_Create_A_General_Path_Cache() + { + using var target = CreateTarget(); + + target.DrawLine(new Pen(Brushes.Black, 3), new Point(0, 0), new Point(10, 10)); + + var command = Assert.Single(target.DrawingContext.Commands); + Assert.Equal(RenderCommandType.DrawLine, command.Type); + Assert.Null(command.GeometryCache); + } + [Fact] public void DrawLine_Preserves_Pen_Stroke_Style() { @@ -68,6 +97,45 @@ public void DrawLine_Preserves_Pen_Stroke_Style() Assert.Equal(7, nativePen.MiterLimit); Assert.Equal(new[] { 2.0, 4.0 }, nativePen.DashArray); Assert.Equal(1.5, nativePen.DashOffset); + Assert.NotNull(command.GeometryCache); + } + + [Fact] + public void DrawGeometry_Reuses_Cache_Until_Stream_Geometry_Changes() + { + var geometry = new StreamGeometryImpl(); + using (var geometryContext = geometry.Open()) + { + geometryContext.BeginFigure(new Point(0, 0)); + geometryContext.LineTo(new Point(10, 0)); + geometryContext.LineTo(new Point(10, 10)); + geometryContext.EndFigure(isClosed: false); + } + + using var firstTarget = CreateTarget(); + firstTarget.DrawGeometry(null, new Pen(Brushes.Black, 2), geometry); + var firstCache = Assert.Single(firstTarget.DrawingContext.Commands).GeometryCache; + + using var secondTarget = CreateTarget(); + secondTarget.DrawGeometry(null, new Pen(Brushes.Black, 2), geometry); + var secondCache = Assert.Single(secondTarget.DrawingContext.Commands).GeometryCache; + + Assert.NotNull(firstCache); + Assert.Same(firstCache, secondCache); + + using (var geometryContext = geometry.Open()) + { + geometryContext.BeginFigure(new Point(0, 0)); + geometryContext.LineTo(new Point(20, 0)); + geometryContext.EndFigure(isClosed: false); + } + + using var changedTarget = CreateTarget(); + changedTarget.DrawGeometry(null, new Pen(Brushes.Black, 2), geometry); + var changedCache = Assert.Single(changedTarget.DrawingContext.Commands).GeometryCache; + + Assert.NotNull(changedCache); + Assert.NotSame(firstCache, changedCache); } [Fact] @@ -266,6 +334,72 @@ public void DrawRectangle_Records_Local_Rect_And_Full_Transform() Assert.Equal((float)transform.M32, command.Transform.M42); } + [Fact] + public void Solid_Primitive_Draws_Do_Not_Allocate_Temporary_Path_Graphs() + { + using var target = CreateTarget(); + const int drawCount = 256; + target.DrawingContext.EnsureCommandCapacity(drawCount * 2); + target.DrawRectangle( + Brushes.Red, + null, + new RoundedRect(new Rect(0, 0, 30, 40), new CornerRadius(4))); + target.DrawingContext.Clear(); + + long allocationStart = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < drawCount; index++) + { + target.DrawRectangle( + Brushes.Red, + null, + new RoundedRect(new Rect(index, 2, 30, 40), new CornerRadius(4))); + target.DrawEllipse(Brushes.Blue, null, new Rect(index, 4, 20, 12)); + } + long allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - allocationStart; + + Assert.True( + allocatedBytes < 48 * 1024, + $"Primitive recording allocated {allocatedBytes:N0} bytes."); + Assert.All( + target.DrawingContext.Commands, + command => Assert.NotEqual(RenderCommandType.DrawPath, command.Type)); + } + + [Fact] + public void Repeated_Solid_Styles_Reuse_Converted_Brushes_And_Pens() + { + using var target = CreateTarget(); + var brush = new SolidColorBrush(Colors.CornflowerBlue) { Opacity = 0.75 }; + var pen = new Pen(brush, 2, lineCap: PenLineCap.Round); + + target.DrawRectangle(brush, pen, new RoundedRect(new Rect(0, 0, 20, 10))); + target.DrawRectangle(brush, pen, new RoundedRect(new Rect(30, 0, 20, 10))); + + Assert.Equal(2, target.DrawingContext.Commands.Count); + Assert.Same(target.DrawingContext.Commands[0].Brush, target.DrawingContext.Commands[1].Brush); + Assert.Same(target.DrawingContext.Commands[0].Pen, target.DrawingContext.Commands[1].Pen); + } + + [Fact] + public void Mutated_Solid_Style_Uses_A_New_Converted_Value() + { + using var target = CreateTarget(); + var brush = new SolidColorBrush(Colors.Red); + + target.DrawRectangle(brush, null, new RoundedRect(new Rect(0, 0, 20, 10))); + brush.Color = Colors.Blue; + target.DrawRectangle(brush, null, new RoundedRect(new Rect(30, 0, 20, 10))); + + Assert.Equal(2, target.DrawingContext.Commands.Count); + Assert.NotSame(target.DrawingContext.Commands[0].Brush, target.DrawingContext.Commands[1].Brush); + Assert.Equal( + new System.Numerics.Vector4(1, 0, 0, 1), + Assert.IsType(target.DrawingContext.Commands[0].Brush).Color); + Assert.Equal( + new System.Numerics.Vector4(0, 0, 1, 1), + Assert.IsType(target.DrawingContext.Commands[1].Brush).Color); + } + [Fact] public void Rotated_Rectangle_Clip_Uses_All_Four_Corners() { @@ -459,6 +593,30 @@ public void ProGpu_Api_Lease_Must_Be_Disposed_On_Acquiring_Thread() target.Clear(Colors.Transparent); } + [Fact] + public void Offscreen_Cache_Reuses_Cleared_Recording_Context_And_Its_Capacity() + { + using var cache = new OffscreenTextureCache(); + var first = cache.RentRecordingContext(); + var retainedResource = new TrackingDisposable(); + first.EnsureCommandCapacity(512); + first.Commands.Add(default); + first.RetainResource(retainedResource); + + cache.ReturnRecordingContext(first); + + Assert.True(retainedResource.IsDisposed); + Assert.Empty(first.Commands); + Assert.True(first.Commands.Capacity >= 512); + + var second = cache.RentRecordingContext(); + + Assert.Same(first, second); + Assert.Empty(second.Commands); + Assert.True(second.Commands.Capacity >= 512); + cache.ReturnRecordingContext(second); + } + private static DrawingContextImpl CreateTarget() { return CreateTarget(new Vector(96, 96), scaleDrawingToDpi: false); @@ -516,5 +674,15 @@ public IFramebufferRenderTarget CreateFramebufferRenderTarget() () => throw new InvalidOperationException("The retention capability test does not lock the surface.")); } } + + private sealed class TrackingDisposable : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } } } diff --git a/tests/Avalonia.ProGpu.UnitTests/Media/ProGpuFontManagerImplTests.cs b/tests/Avalonia.ProGpu.UnitTests/Media/ProGpuFontManagerImplTests.cs index 53452610..3a0fddac 100644 --- a/tests/Avalonia.ProGpu.UnitTests/Media/ProGpuFontManagerImplTests.cs +++ b/tests/Avalonia.ProGpu.UnitTests/Media/ProGpuFontManagerImplTests.cs @@ -115,6 +115,86 @@ public void MatchCharacterFallsBackByCoverageAndCachesResult() } } + [Fact] + public void MatchCharacterChecksCompactCoverageBeforeLoadingLargeCandidate() + { + byte[] digitsData = ReadFont("DF7segHMI.ttf"); + byte[] fallbackData = ReadFont("DejaVuSans.ttf"); + var digitsFont = new TtfFont(digitsData); + var fallbackFont = new TtfFont(fallbackData); + string digitsPath = WriteTemporaryFont(digitsData, ".ttf"); + string fallbackPath = WriteTemporaryFont(fallbackData, ".ttf"); + try + { + using (var stream = new FileStream(digitsPath, FileMode.Open, FileAccess.Write, FileShare.Read)) + { + stream.SetLength(32L * 1024L * 1024L); + } + + var manager = new FontManagerImpl(() => + [ + CreateFontInfo(digitsFont, digitsPath), + CreateFontInfo(fallbackFont, fallbackPath) + ]); + + Assert.True(manager.TryMatchCharacter( + 'Ω', + FontStyle.Normal, + FontWeight.Normal, + FontStretch.Normal, + digitsFont.FamilyName, + null, + out var match)); + + Assert.Equal(fallbackFont.FamilyName, match.FamilyName); + Assert.Equal(0, manager.CachedFontDataBytes); + } + finally + { + File.Delete(digitsPath); + File.Delete(fallbackPath); + } + } + + [Fact] + public void AppleColorEmojiFallbackKeepsCompactFaceAndLoadsOtherGlyphsOnDemand() + { + const string fontPath = "/System/Library/Fonts/Apple Color Emoji.ttc"; + if (!OperatingSystem.IsMacOS() || !File.Exists(fontPath)) + { + return; + } + + var manager = new FontManagerImpl(() => + [ + new FontInfo + { + Name = "Apple Color Emoji", + FamilyName = "Apple Color Emoji", + FilePath = fontPath, + FaceIndex = 0 + } + ]); + + Assert.True(manager.TryMatchCharacter( + 0x1F600, + FontStyle.Normal, + FontWeight.Normal, + FontStretch.Normal, + null, + null, + out var platformTypeface)); + + var typeface = Assert.IsType(platformTypeface); + Assert.InRange(typeface.Font.FontData.Length, 1, 16 * 1024 * 1024); + Assert.InRange(manager.CachedFontDataBytes, 1, 16L * 1024L * 1024L); + + ushort secondGlyph = typeface.Font.GetGlyphIndex(0x1F44D); + Assert.NotEqual(0, secondGlyph); + Assert.True(typeface.Font.TryGetBitmapGlyph(secondGlyph, 64, out var bitmap)); + Assert.NotEmpty(bitmap.Data.ToArray()); + } + [Fact] public void SystemFontCatalogCanBePreloadedOnTheThreadPool() {