diff --git a/docs/PATH_ATLAS_EMOJI_CACHE_RESEARCH.md b/docs/PATH_ATLAS_EMOJI_CACHE_RESEARCH.md new file mode 100644 index 00000000..f796bd01 --- /dev/null +++ b/docs/PATH_ATLAS_EMOJI_CACHE_RESEARCH.md @@ -0,0 +1,227 @@ +# Path atlas and color-emoji cache research + +## Scope + +This change reduces CPU memory retained by path-atlas compilation and prevents +color-font layers from creating position-dependent path entries. It does not +change shaping, line layout, fallback selection, atlas texture dimensions, +coverage sampling, subpixel phase bounds, or final unsnapped quad placement. +The implementation is clean-room: the sources below informed cache contracts +and observable architecture only; no foreign implementation text, naming, data +layout, or control flow was copied. + +## Primary sources + +- [Skia `SkStrikeCache`](https://github.com/google/skia/blob/main/src/core/SkStrikeCache.cpp) + and [Skia graphics cache controls](https://github.com/google/skia/blob/main/include/core/SkGraphics.h) + treat glyph resources as reusable strike data with explicit byte/count limits + and purge controls. [SkParagraph's cache contract](https://skia.googlesource.com/skia/+/main/modules/skparagraph/src/ParagraphCache.h) + separately retains reusable paragraph results. +- [DirectWrite color-font support](https://learn.microsoft.com/en-us/windows/win32/directwrite/color-fonts) + keeps layout glyph IDs and positions monochrome/position-independent, then + expands them into ordered color glyph runs at rendering time. The + [`DWRITE_COLOR_GLYPH_RUN` contract](https://learn.microsoft.com/en-us/windows/win32/api/dwrite_2/ns-dwrite_2-dwrite_color_glyph_run) + carries baseline origin separately from the layer glyph run. +- [DirectWrite glyph-run analysis](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwriteglyphrunanalysis) + derives bounded device-pixel alpha texture extents from a reusable glyph run. + [Direct2D/DirectWrite text rendering](https://learn.microsoft.com/en-us/windows/win32/direct2d/direct2d-and-directwrite) + preserves reusable layout independently of device-dependent rendering. +- [Win2D `CanvasTextLayout`](https://microsoft.github.io/Win2D/WinUI2/html/T_Microsoft_Graphics_Canvas_Text_CanvasTextLayout.htm) + exposes reusable layout/cluster results; Win2D color fonts opt into the same + DirectWrite color-run model rather than embedding destination positions into + outline identity. +- [WebRender glyph rasterizer source](https://searchfox.org/mozilla-central/source/gfx/wr/wr_glyph_rasterizer/src/lib.rs) + separates requested glyph identity, worker rasterization, texture upload, and + platform font backends. [WebRender's rendering overview](https://searchfox.org/mozilla-central/source/gfx/docs/RenderingOverview.rst) + keeps retained display lists and visibility decisions ahead of resource upload. +- [Vello's renderer architecture](https://github.com/linebender/vello) retains + scenes and moves parallel path work to GPU compute. Its maintainer-authored + [glyph-rendering plan](https://github.com/linebender/vello/issues/204) treats + glyph caching, hinting, and transform policy as distinct concerns. +- [Parley](https://docs.rs/parley/latest/parley/) shares font/layout contexts and + retains shaped layout, leaving glyph rendering and raster residency to the + renderer. +- [HarfBuzz shape-plan caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) + keys reusable shaping work by face, segment properties, features, and shaper. + It does not make destination position part of shaping identity. + +## Cross-engine comparison and ProGPU decisions + +| Concern | Observed architecture | ProGPU decision | +| --- | --- | --- | +| Startup and lazy initialization | Skia, DirectWrite, WebRender, and Parley initialize reusable font/raster contexts independently of each draw. | Keep the existing compositor-owned atlas and lazy per-path compilation. No new startup work or font enumeration is introduced. | +| Shaping and layout reuse | HarfBuzz caches shape plans; SkParagraph, DirectWrite/Win2D, and Parley retain positioned layout. | Leave `TextLayout`, glyph arrays, clusters, fallback faces, and variation selection unchanged. | +| Retained scene and visibility | WebRender and Vello retain scenes and request raster resources only for visible work. | Preserve existing pre-atlas vector-glyph culling and compiled-scene generation checks. | +| Cache identity and eviction | DirectWrite separates layer baseline origin from glyph identity; Skia exposes byte budgets; WebRender separates glyph keys from texture placement. | Adapt color layers to cache only the bounded local fractional phase and carry the remaining translation in the draw transform. Replace count-only compiled-path caches with one shared byte-budgeted LRU. | +| Demand-driven upload | WebRender requests/rasterizes visible glyphs; DirectWrite analysis produces only the requested texture bounds. | Keep `RasterizePendingPaths` demand-driven and batched; repeated emoji layers reuse atlas coordinates instead of scheduling duplicate rasterizations. | +| Worker preparation | WebRender owns a glyph raster worker subsystem; Parley reuses CPU scratch contexts. | Keep path compilation on the existing compilation boundary, but allow fixed-size `TextVisual` shaping to prepare on one CPU worker and publish by revision. GPU atlas allocation stays demand-driven on the compositor thread. | +| GPU batching/compute | Vello performs parallel path work on GPU; WebRender batches resource upload; Direct2D submits glyph runs/layers. | Preserve ProGPU's batched compute rasterizer and vector draw batching. | +| DPI, subpixel, and hinting | Platform engines include rendering mode/transform state in raster identity; Vello distinguishes dynamic vector text from cache-friendly hinted UI text. | Preserve the 128 local phases, four device phases per axis, ten-bit scale quantization, 8x8 high-precision coverage, DPI scaling, and unsnapped final placement. | +| Fallback and color formats | DirectWrite expands a base run into COLR, SVG, or bitmap color runs after layout. | Preserve existing font fallback, COLR/SVG layer order, brush coordinates, bitmap-glyph path, and monochrome fallback. | +| Variable fonts | HarfBuzz and platform engines include variation state in face/shape identity. | No key is broadened across font instances or variation-specific source outlines; outline object identity remains part of the transformed-path key. | +| Device loss and atlas generation | Direct2D separates device-independent layout from device resources; WebRender rebuilds GPU resources after device reset. | Preserve atlas ownership, reset/retry, and `Generation` invalidation. CPU cache eviction never moves atlas UVs and therefore does not increment `Generation`. | + +## Adopted, adapted, and rejected + +Adopted: + +- a strict byte budget for variable-size compiled path records and segments; +- least-recently-used eviction across fill and hit-test compilation data; +- position-independent color-layer outline identity with translation carried by + the draw transform; +- explicit byte/count diagnostics and focused bounded-memory regressions. +- sequential background preparation for deferred fixed-size text layouts on + threaded hosts, with atomic revision-checked publication. + +Adapted: + +- DirectWrite's separate color-run baseline becomes ProGPU's residual placement + transform, while the local 128-phase lattice remains baked for coverage; +- Skia-style byte budgeting is applied to ProGPU's original typed record/segment + arrays and shares one budget across both compilation consumers. + +Rejected: + +- caching exact absolute emoji positions; +- lowering coverage precision, shrinking the GPU atlas, or snapping final quads; +- moving Unicode/OpenType shaping to the GPU; +- creating path-atlas entries or GPU textures from the text-layout worker; +- copying an external atlas, strike-cache, or eviction implementation; +- evicting atlas coordinates independently of `Generation`, which would make + retained UVs stale. + +## Complexity and validation contract + +Compiled-path lookup and recency update are average `O(1)`. Insertion evicts `E` +old entries in `O(E)` time and retains at most the configured byte budget plus no +oversize entry; storage is `O(B)` for budget `B`. Color-layer lookup remains +average `O(1)`, path construction is `O(S)` only on a cache miss for `S` outline +segments, and repeated integer-position emoji add no new path-atlas entries. + +Validation covers repeated COLR layers at many positions, a deliberately tiny +shared CPU budget, existing fractional/parent-transform phase bounds, atlas +capacity recovery, mixed color/monochrome batching, and rendered pixel presence. + +The focused headless measurement renders 48 instances of a two-layer COLR glyph: +96 positioned layer uses collapse to 2 retained path-atlas entries, 97.9% fewer +than one entry per use for that representative integer-position run. A separate +stress case holds combined fill and hit-test compilation payload at or below a +configured 1,024-byte budget throughout 24 distinct paths. + +## Measured comparison with `main` + +The complex color-emoji comparison used a clean worktree at `origin/main` +(`fc4d27a`) and this branch, .NET SDK 10.0.201, and the same macOS arm64 host. +Each isolated process rendered 256 instances of one synthetic two-layer COLR +glyph whose layers each contain 128 outline points. A full collection immediately +before and after the first render measured retained managed memory. Three runs +produced the following medians: + +| Measurement | `main` | This branch | Change | +| --- | ---: | ---: | ---: | +| Retained path-atlas entries | 512 | 2 | -99.6% | +| Retained managed bytes | 19,946,608 | 546,752 | -97.3% | +| First-render time | 55.040 ms | 43.243 ms | -21.4% | + +The retained-byte result was stable within 0.05% on `main` and 1.5% on this +branch. One `main` timing sample was an outlier, so the table reports medians and +the regression asserts deterministic atlas residency rather than an absolute +time or process-memory threshold. + +The broader `Font Glyph Browser` benchmark was also run for 180 warmup and 600 +measured scrolling frames with presentation throttling disabled. Warmed matched +runs were effectively neutral (372.07 versus 372.02 wall FPS, 1.3912 versus +1.3976 ms compositor time, and 192,353 versus 191,440 allocated bytes per frame +for `main` and this branch respectively). Separate process runs showed substantial +system-level timing variance, so these figures are treated as a regression +screen rather than a precise speedup claim. The branch reported 172 resident +path entries and 353,232 compiled-cache bytes in that workload, well below its +8 MiB default budget, with no path-atlas reset. + +## Text-page navigation and DXF architecture follow-up + +A navigation-sequence benchmark now opens and scrolls `Font Glyph Browser`, +`Text & Documents`, `Markdown Playground`, and `Inter Typeface` before switching +to `Data Virtualization`. The sequence exposed a separate amplification path: +the earlier pages filled the glyph atlas, while ordinary `DrawText` commands did +not opt into its existing LRU region reuse. Cached capacity misses therefore +kept visible DataGrid glyphs on vector fallback indefinitely. That fallback +expanded the final frame from about 70 to more than 600 draw calls and routed +hundreds of glyphs through `PathAtlas`. + +The fix marks ordinary `DrawText` as a preferred glyph-atlas consumer. When a +new visible glyph needs space, the atlas may reuse a compatible region that was +not used in the current frame. `Generation` advances so retained UV consumers +recompile, while current-frame regions remain protected. Explicit vector text, +CFF outlines, transformed large text, and color-glyph paths keep their existing +quality-specific branches. + +The same sequence was measured before and after the fix: + +| Measurement | Before | After | Change | +| --- | ---: | ---: | ---: | +| Draw calls | 618 | 70 | -88.7% | +| Path-atlas entries | 1,556 | 257 | -83.5% | +| Visual-tree compile time | 0.8035 ms | 0.5383 ms | -33.0% | +| Compositor frame time | 1.3424 ms | 0.7827 ms | -41.7% | +| Allocated bytes per frame | 23,436 | 18,944 | -19.2% | + +The pre-fix result on current `main` was materially identical (615 draw calls, +1,557 path entries, 0.8205 ms compile time, and 1.3851 ms compositor time), so +the root saturation behavior predates this branch. This PR fixes it because the +bounded path cache makes the fallback amplification and its memory cost directly +observable. + +ProGPU's DXF lane was evaluated as a possible atlas replacement. It compiles an +immutable document once into owned vector, text, retained-glyph record/segment, +and instance buffers; camera changes update only a viewport uniform. Its retained +glyph shader evaluates analytic winding directly and draws all instances in one +call, so it correctly avoids per-position path-atlas residency for static CAD +content. + +That lane is not a drop-in replacement for ordinary UI text. Direct per-fragment +segment evaluation has a different cost and antialiasing model from the glyph +atlas, and moving the explicit CFF/vector fallback to it would violate the +existing 128 local phases, 16 device phases, and 8x8 coverage contract unless +the shader and batching model were extended. The adopted hybrid is therefore: + +- use glyph-atlas LRU residency for ordinary small UI text; +- keep the byte-bounded path atlas for general filled paths and quality-sensitive + vector/color glyph fallbacks; +- keep DXF-style immutable GPU buffers for static documents and retained scenes; +- consider a future dynamic retained-glyph batch only after it preserves command + order, clips, masks, blend modes, subpixel coverage, and device-loss behavior. + +## Inter specimen cold-scroll follow-up + +The Inter specimen exposed a CPU preparation issue independent of `PathAtlas`. +It contains 112 fixed-height retained `TextVisual` specimens. The original page +deferred those layouts for fast activation, then shaped them in four-millisecond +chunks posted to the UI dispatcher. A per-layout trace found ordinary specimens +at roughly 0.3–3 ms, the first italic variable-font shaping plan at 34.7 ms, and +later complex specimens at 7.5–9.8 ms. Those chunks ran in the host update that +also advances scrolling. The completed Inter sweep retained only 162 path-atlas +entries and 157,264 bytes of compiled path data, so replacing or enlarging the +path atlas could not address this hitch. + +`TextVisual.WarmDeferredLayout` now constructs the CPU-only layout and publishes +the completed reference atomically only when its text/font/width revision is +still current. Inter starts one sequential worker after the first presentation; +it stops when navigation detaches the page. The render thread still performs +visible glyph-atlas allocation and upload. Single-threaded browser hosts retain +the bounded dispatcher slices instead of pretending `Task.Run` provides a +worker. + +A matched unrestricted-presentation run with two warmup and 220 scrolling frames +reduced the worst host-update interval from 17.6304 ms to 0.6410 ms (-96.4%). +The worker shifts the same eventual retained-layout allocations off the UI +thread, so short-process total-allocation and unrestricted-throughput figures +remain sensitive to scheduling and are not used as correctness thresholds. + +The full same-process sequence then scrolled Inter, Text & Documents, and Font +Glyph Browser for 180 frames each before Data Virtualization. Its 600 measured +Data Virtualization frames completed at 514.18 wall FPS with 0.5176 ms average +compile time, 2.0647 ms maximum compile time, 0.7577 ms compositor time, 69 draw +calls, 117 path entries, and no glyph-atlas eviction or clear. This verifies that +the Inter worker stops on detach and does not transfer contention or cache +pressure to the virtualized page. diff --git a/src/ProGPU.Samples/Pages/InterShowcasePage.cs b/src/ProGPU.Samples/Pages/InterShowcasePage.cs index cf98878f..2999c672 100644 --- a/src/ProGPU.Samples/Pages/InterShowcasePage.cs +++ b/src/ProGPU.Samples/Pages/InterShowcasePage.cs @@ -175,14 +175,27 @@ private static void StageInvisibleSpecimenLayouts(FrameworkElement page) return; } + void WarmInBackground() + { + for (var index = 0; index < specimens.Count; index++) + { + if (page.Parent == null) + { + // Navigation may cache a detached page. Do not keep warming content + // that cannot become visible; a later activation shapes on demand. + return; + } + + specimens[index].WarmDeferredLayout(); + } + } + var next = 0; var retriesBeforeLayout = 0; - void WarmNextFrame() + void WarmBrowserSlice() { if (page.Parent == null) { - // Navigation may cache a detached page. Do not keep warming content that - // cannot become visible; a later activation still shapes on demand. return; } @@ -193,7 +206,7 @@ void WarmNextFrame() { if (++retriesBeforeLayout < 4) { - UIThread.Post(WarmNextFrame); + UIThread.Post(WarmBrowserSlice); } return; } @@ -205,13 +218,25 @@ void WarmNextFrame() if (next < specimens.Count) { - UIThread.Post(WarmNextFrame); + UIThread.Post(WarmBrowserSlice); } } // Let the lightweight visible page measure, arrange, and present before starting - // bounded CPU-only shaping work for content below the viewport. - UIThread.Post(() => UIThread.Post(WarmNextFrame)); + // one sequential CPU-only worker for content below the viewport. Atlas allocation + // remains demand-driven on the compositor thread when a specimen becomes visible. + UIThread.Post(() => UIThread.Post(() => + { + if (OperatingSystem.IsBrowser()) + { + // Browser builds without worker-thread support preserve a bounded UI slice. + WarmBrowserSlice(); + } + else + { + _ = Task.Run(WarmInBackground); + } + })); } private static void CollectFixedHeightSpecimens(Visual visual, List specimens) @@ -263,6 +288,22 @@ internal static void AdvanceBenchmarkScroll(float step) _benchmarkScrollViewer.VerticalOffset = nextOffset; } + internal static bool TryGetBenchmarkScrollState(out float verticalOffset, out float maximumOffset) + { + if (_benchmarkScrollViewer == null) + { + verticalOffset = 0f; + maximumOffset = 0f; + return false; + } + + verticalOffset = _benchmarkScrollViewer.VerticalOffset; + maximumOffset = Math.Max( + 0f, + _benchmarkScrollViewer.ContentHeight - _benchmarkScrollViewer.Size.Y); + return true; + } + private static FrameworkElement CreateTopNavigation() { var grid = new Grid { HeightConstraint = 28f }; diff --git a/src/ProGPU.Samples/SamplePerformanceBenchmark.cs b/src/ProGPU.Samples/SamplePerformanceBenchmark.cs index b7dcc073..fe0eab25 100644 --- a/src/ProGPU.Samples/SamplePerformanceBenchmark.cs +++ b/src/ProGPU.Samples/SamplePerformanceBenchmark.cs @@ -11,6 +11,8 @@ internal static class SamplePerformanceBenchmark private const string VSyncVariable = "PROGPU_SAMPLE_BENCHMARK_VSYNC"; private const string ScrollVariable = "PROGPU_SAMPLE_BENCHMARK_SCROLL"; private const string ScrollStepVariable = "PROGPU_SAMPLE_BENCHMARK_SCROLL_STEP"; + private const string PreconditionPagesVariable = "PROGPU_SAMPLE_BENCHMARK_PRECONDITION_PAGES"; + private const string PreconditionFramesVariable = "PROGPU_SAMPLE_BENCHMARK_PRECONDITION_FRAMES"; private static readonly int s_warmupFrames = ReadPositiveInt(WarmupFramesVariable, 180); private static readonly int s_measureFrames = ReadPositiveInt(MeasureFramesVariable, 600); @@ -19,11 +21,15 @@ internal static class SamplePerformanceBenchmark private static double s_deltaSeconds; private static double s_compileMilliseconds; private static double s_maxCompileMilliseconds; + private static int s_maxCompileFrame; + private static float s_maxCompileScrollOffset; + private static float s_maxCompileScrollExtent; private static int s_compileFramesOverBudget; private static double s_uploadMilliseconds; private static double s_renderMilliseconds; private static double s_compositorMilliseconds; private static double s_hostUpdateMilliseconds; + private static double s_maxHostUpdateMilliseconds; private static double s_layoutMilliseconds; private static double s_animationMilliseconds; private static double s_surfaceAcquireMilliseconds; @@ -54,6 +60,11 @@ internal static class SamplePerformanceBenchmark private static int s_lastVisibleRichCharacters; private static readonly bool s_scrollWorkload = ReadOptionalBool(ScrollVariable) == true; private static readonly float s_scrollStep = ReadPositiveFloat(ScrollStepVariable, 40f); + private static readonly string[] s_preconditionPages = ReadPageList(PreconditionPagesVariable); + private static readonly int s_preconditionFrames = ReadPositiveInt(PreconditionFramesVariable, 180); + private static int s_preconditionPageIndex; + private static int s_preconditionFrame; + private static bool s_isPreconditioning; public static string? RequestedPage { get; } = ReadRequestedPage(); @@ -78,10 +89,18 @@ public static void StartRequestedWorkload(string selectedPage) } } + if (s_preconditionPages.Length != 0) + { + s_isPreconditioning = true; + SelectNavigationPage(s_preconditionPages[0]); + } + Console.WriteLine( $"[SampleBenchmark] page={selectedPage} warmupFrames={s_warmupFrames}" + $" measureFrames={s_measureFrames} vsync={AppState._wgpuContext?.VSync}" + - $" scroll={s_scrollWorkload} scrollStep={s_scrollStep:F0}"); + $" scroll={s_scrollWorkload} scrollStep={s_scrollStep:F0}" + + $" preconditionPages={string.Join(';', s_preconditionPages)}" + + $" preconditionFrames={s_preconditionFrames}"); } public static void ObserveFrame(double deltaSeconds) @@ -106,37 +125,45 @@ public static void ObserveFrame(double deltaSeconds) s_workloadStarted = true; } - if (s_scrollWorkload) + if (s_isPreconditioning) { - if (string.Equals(RequestedPage, "Font Glyph Browser", StringComparison.OrdinalIgnoreCase)) + AdvancePageScroll(s_preconditionPages[s_preconditionPageIndex]); + s_preconditionFrame++; + if (s_preconditionFrame >= s_preconditionFrames) { - FontGlyphBrowserPage.AdvanceBenchmarkScroll(s_scrollStep); - if (s_frame > s_warmupFrames && s_frame % 60 == 0) + RecordPreconditionState(s_preconditionPages[s_preconditionPageIndex]); + s_preconditionFrame = 0; + s_preconditionPageIndex++; + if (s_preconditionPageIndex < s_preconditionPages.Length) { - RecordGlyphBrowserState(); + SelectNavigationPage(s_preconditionPages[s_preconditionPageIndex]); } - } - else if (string.Equals(RequestedPage, "Markdown Playground", StringComparison.OrdinalIgnoreCase)) - { - MarkdownPage.AdvanceBenchmarkScroll(); - if (s_frame > s_warmupFrames && s_frame % 60 == 0) + else { - RecordMarkdownState(); + s_isPreconditioning = false; + SelectNavigationPage(RequestedPage!); } } - else if (string.Equals(RequestedPage, "Inter Typeface", StringComparison.OrdinalIgnoreCase)) + return; + } + + if (s_scrollWorkload) + { + AdvancePageScroll(RequestedPage); + if (string.Equals(RequestedPage, "Font Glyph Browser", StringComparison.OrdinalIgnoreCase) && + s_frame > s_warmupFrames && s_frame % 60 == 0) { - InterShowcasePage.AdvanceBenchmarkScroll(s_scrollStep); + RecordGlyphBrowserState(); } - else if (string.Equals(RequestedPage, "Data Virtualization", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(RequestedPage, "Markdown Playground", StringComparison.OrdinalIgnoreCase) && + s_frame > s_warmupFrames && s_frame % 60 == 0) { - DataVirtualizationPage.AdvanceBenchmarkScroll(s_scrollStep); + RecordMarkdownState(); } - else if (string.Equals(RequestedPage, "Text & Documents", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(RequestedPage, "Text & Documents", StringComparison.OrdinalIgnoreCase) && + s_frame > s_warmupFrames && s_frame % 60 == 0) { - TextDocumentsPage.AdvanceBenchmarkScroll(s_scrollStep); - if (s_frame > s_warmupFrames && s_frame % 60 == 0) - RecordRichTextState(); + RecordRichTextState(); } } @@ -170,7 +197,17 @@ public static void ObserveFrame(double deltaSeconds) { var metrics = compositor.Metrics; s_compileMilliseconds += metrics.VisualTreeCompileTimeMs; - s_maxCompileMilliseconds = Math.Max(s_maxCompileMilliseconds, metrics.VisualTreeCompileTimeMs); + if (metrics.VisualTreeCompileTimeMs > s_maxCompileMilliseconds) + { + s_maxCompileMilliseconds = metrics.VisualTreeCompileTimeMs; + s_maxCompileFrame = s_frame - s_warmupFrames; + if (string.Equals(RequestedPage, "Inter Typeface", StringComparison.OrdinalIgnoreCase)) + { + InterShowcasePage.TryGetBenchmarkScrollState( + out s_maxCompileScrollOffset, + out s_maxCompileScrollExtent); + } + } if (metrics.VisualTreeCompileTimeMs > 16.667d) { s_compileFramesOverBudget++; @@ -253,6 +290,9 @@ public static void ObserveFrame(double deltaSeconds) workloadDetails = $" cachedTextLayouts={dataCompositor.CachedTextLayoutCount}" + $" cachedTextLayoutGlyphs={dataCompositor.CachedTextLayoutGlyphCount}" + + $" glyphAtlasEntries={dataCompositor.Atlas.CachedGlyphCount}" + + $" glyphAtlasEvictionsTotal={dataCompositor.Atlas.EvictionCount}" + + $" glyphAtlasCapacityExceeded={dataCompositor.Atlas.CapacityExceeded}" + $" glyphAtlasGenerationChanges={dataCompositor.Atlas.Generation - s_glyphAtlasGenerationAtStart}" + $" glyphAtlasEvictions={dataCompositor.Atlas.EvictionCount - s_glyphAtlasEvictionsAtStart}" + $" glyphAtlasClears={dataCompositor.Atlas.ClearCount - s_glyphAtlasClearsAtStart}"; @@ -273,11 +313,16 @@ public static void ObserveFrame(double deltaSeconds) $" deltaFps={deltaFps:F2} wallFps={wallFps:F2}" + $" compileMs={s_compileMilliseconds / divisor:F4}" + $" maxCompileMs={s_maxCompileMilliseconds:F4}" + + $" maxCompileFrame={s_maxCompileFrame}" + + (string.Equals(RequestedPage, "Inter Typeface", StringComparison.OrdinalIgnoreCase) + ? $" maxCompileScroll={s_maxCompileScrollOffset:F0}/{s_maxCompileScrollExtent:F0}" + : string.Empty) + $" compileFramesOverBudget={s_compileFramesOverBudget}" + $" uploadMs={s_uploadMilliseconds / divisor:F4}" + $" renderMs={s_renderMilliseconds / divisor:F4}" + $" compositorMs={s_compositorMilliseconds / divisor:F4}" + $" hostUpdateMs={s_hostUpdateMilliseconds / divisor:F4}" + + $" maxHostUpdateMs={s_maxHostUpdateMilliseconds:F4}" + $" layoutMs={s_layoutMilliseconds / divisor:F4}" + $" animationMs={s_animationMilliseconds / divisor:F4}" + $" acquireMs={s_surfaceAcquireMilliseconds / divisor:F4}" + @@ -288,7 +333,11 @@ public static void ObserveFrame(double deltaSeconds) workloadDetails + $" draws={finalMetrics?.DrawCallsCount ?? 0}" + $" vectorVertices={finalMetrics?.VectorVerticesCount ?? 0}" + - $" textVertices={finalMetrics?.TextVerticesCount ?? 0}"); + $" textVertices={finalMetrics?.TextVerticesCount ?? 0}" + + $" pathAtlasEntries={finalMetrics?.PathAtlasCachedCount ?? 0}" + + $" pathAtlasCpuCacheBytes={finalMetrics?.PathAtlasCpuCacheBytes ?? 0}" + + $" pathAtlasFillCacheEntries={AppState._screenCompositor?.PathAtlas.CachedFillPathCount ?? 0}" + + $" pathAtlasHitTestCacheEntries={AppState._screenCompositor?.PathAtlas.CachedHitTestPathCount ?? 0}"); AppState._window?.Close(); } @@ -298,7 +347,52 @@ public static void RecordHostUpdate(TimeSpan elapsed) if (RequestedPage is not null && !s_finished && s_frame > s_warmupFrames) { s_hostUpdateMilliseconds += elapsed.TotalMilliseconds; + s_maxHostUpdateMilliseconds = Math.Max(s_maxHostUpdateMilliseconds, elapsed.TotalMilliseconds); + } + } + + private static void AdvancePageScroll(string? page) + { + if (string.Equals(page, "Font Glyph Browser", StringComparison.OrdinalIgnoreCase)) + FontGlyphBrowserPage.AdvanceBenchmarkScroll(s_scrollStep); + else if (string.Equals(page, "Markdown Playground", StringComparison.OrdinalIgnoreCase)) + MarkdownPage.AdvanceBenchmarkScroll(); + else if (string.Equals(page, "Inter Typeface", StringComparison.OrdinalIgnoreCase)) + InterShowcasePage.AdvanceBenchmarkScroll(s_scrollStep); + else if (string.Equals(page, "Data Virtualization", StringComparison.OrdinalIgnoreCase)) + DataVirtualizationPage.AdvanceBenchmarkScroll(s_scrollStep); + else if (string.Equals(page, "Text & Documents", StringComparison.OrdinalIgnoreCase)) + TextDocumentsPage.AdvanceBenchmarkScroll(s_scrollStep); + } + + private static void SelectNavigationPage(string page) + { + var navigationView = AppState._navigationView ?? + throw new InvalidOperationException("Benchmark navigation is not initialized."); + foreach (var menuItem in navigationView.MenuItems) + { + if (string.Equals(menuItem.Text, page, StringComparison.OrdinalIgnoreCase)) + { + navigationView.SelectedItem = menuItem; + return; + } } + + throw new InvalidOperationException($"Benchmark navigation page '{page}' was not found."); + } + + private static void RecordPreconditionState(string page) + { + var compositor = AppState._screenCompositor; + Console.WriteLine( + $"[SampleBenchmark] PRECONDITION page=\"{page}\" frames={s_preconditionFrames}" + + $" cachedTextLayouts={compositor?.CachedTextLayoutCount ?? 0}" + + $" cachedTextLayoutGlyphs={compositor?.CachedTextLayoutGlyphCount ?? 0}" + + $" glyphAtlasEntries={compositor?.Atlas.CachedGlyphCount ?? 0}" + + $" glyphAtlasGeneration={compositor?.Atlas.Generation ?? 0}" + + $" glyphAtlasEvictions={compositor?.Atlas.EvictionCount ?? 0}" + + $" glyphAtlasCapacityExceeded={compositor?.Atlas.CapacityExceeded ?? false}" + + $" pathAtlasEntries={compositor?.PathAtlas.CachedPathCount ?? 0}"); } private static void RecordGlyphBrowserState() @@ -360,6 +454,14 @@ private static void RecordMarkdownState() return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } + private static string[] ReadPageList(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) + ? Array.Empty() + : value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + private static int ReadPositiveInt(string name, int fallback) { string? value = Environment.GetEnvironmentVariable(name); diff --git a/src/ProGPU.Scene/Compositor.cs b/src/ProGPU.Scene/Compositor.cs index b8d76212..1a8d0389 100644 --- a/src/ProGPU.Scene/Compositor.cs +++ b/src/ProGPU.Scene/Compositor.cs @@ -117,6 +117,7 @@ public struct CompositorMetrics public int VectorVerticesCount; public int TextVerticesCount; public int PathAtlasCachedCount; + public long PathAtlasCpuCacheBytes; public bool SceneCacheHit; public string? SceneCacheMissReason; } @@ -1149,7 +1150,10 @@ public Compositor(WgpuContext context, TextureFormat? renderFormat, CompositorOp // 1. Initialize GPU atlases. _atlas = new GlyphAtlas(_context, options.GlyphAtlasSize); - _pathAtlas = new PathAtlas(_context, options.PathAtlasSize); + _pathAtlas = new PathAtlas( + _context, + options.PathAtlasSize, + options.PathAtlasCpuCacheBudgetBytes); _hitTestCacheBuilder = new GpuRenderCommandHitTestCacheBuilder(_pathAtlas); // 2. Uniform Buffer allocation (Projection Matrix + MVP - 128 bytes) @@ -2932,6 +2936,7 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView* VectorVerticesCount = _vectorVerticesList.Count, TextVerticesCount = _textVerticesList.Count, PathAtlasCachedCount = _pathAtlas.CachedPathCount, + PathAtlasCpuCacheBytes = _pathAtlas.CompiledPathCacheBytes, SceneCacheHit = reuseCompiledScene, SceneCacheMissReason = reuseCompiledScene ? null : _currentSceneCacheMissReason }; @@ -9478,31 +9483,17 @@ private void CompileTextCommand(RenderCommand cmd, ITextLayoutProvider? textNode var layerPosition = runGlyph.Position + cmd.Position; var layerScale = cmd.FontSize / glyphFont.UnitsPerEm; - var transformedOutline = CreatePositionedGlyphOutline( + CompileColorGlyphLayerPath( layerOutline, + layer, + cmd, layerScale, layerPosition, - italicSkew: glyphItalicSkew, - scaleX: fontScaleX, - usesSvgCoordinates: layer.UsesSvgCoordinates); - - var pathCmd = new RenderCommand - { - Type = RenderCommandType.DrawPath, - Path = transformedOutline, - Brush = CreatePositionedColorLayerBrush(layer, layerScale, layerPosition), - IsEdgeAliased = cmd.TextRenderingMode == TextRenderingMode.Aliased, - PathSampleGrid = cmd.TextRenderingMode == TextRenderingMode.Aliased - ? PathAtlas.StandardCoverageSampleGrid - : PathAtlas.HighPrecisionCoverageSampleGrid, - PathCoverageGamma = textPathCoverageGamma - }; - CompilePathCommand( - pathCmd, + glyphItalicSkew, + textPathCoverageGamma, activeTransform, - subpixelPhaseGrid: VectorGlyphDeviceSubpixelPhaseGrid, - quantizeScale: true, - rasterScale: vectorGlyphRasterScale); + fontScaleX, + vectorGlyphRasterScale); } continue; @@ -9739,31 +9730,17 @@ private void CompileGlyphRunCommand(RenderCommand cmd, Matrix4x4 transform) var layerPosition = position + cmd.Position; var layerScale = cmd.FontSize / font.UnitsPerEm; - var transformedOutline = CreatePositionedGlyphOutline( + CompileColorGlyphLayerPath( layerOutline, + layer, + cmd, layerScale, layerPosition, - italicSkew: glyphItalicSkew, - scaleX: fontScaleX, - usesSvgCoordinates: layer.UsesSvgCoordinates); - - var pathCmd = new RenderCommand - { - Type = RenderCommandType.DrawPath, - Path = transformedOutline, - Brush = CreatePositionedColorLayerBrush(layer, layerScale, layerPosition), - IsEdgeAliased = cmd.TextRenderingMode == TextRenderingMode.Aliased, - PathSampleGrid = cmd.TextRenderingMode == TextRenderingMode.Aliased - ? PathAtlas.StandardCoverageSampleGrid - : PathAtlas.HighPrecisionCoverageSampleGrid, - PathCoverageGamma = textPathCoverageGamma - }; - CompilePathCommand( - pathCmd, + glyphItalicSkew, + textPathCoverageGamma, activeTransform, - subpixelPhaseGrid: VectorGlyphDeviceSubpixelPhaseGrid, - quantizeScale: true, - rasterScale: vectorGlyphRasterScale); + fontScaleX, + vectorGlyphRasterScale); } continue; @@ -10089,6 +10066,91 @@ private void CompileVectorGlyphPath( rasterScale: rasterScale); } + private void CompileColorGlyphLayerPath( + PathGeometry outline, + FontColorLayer layer, + RenderCommand textCommand, + float emScale, + Vector2 position, + float italicSkew, + float pathCoverageGamma, + Matrix4x4 activeTransform, + float scaleX, + float rasterScale) + { + PathGeometry positionedOutline; + Matrix4x4 placementTransform; + Vector2 brushPosition; + + if (IsFinite(position)) + { + var integralPosition = new Vector2(MathF.Floor(position.X), MathF.Floor(position.Y)); + var fractionalPosition = position - integralPosition; + var rasterPhase = new Vector2( + QuantizeVectorGlyphPhase(fractionalPosition.X), + QuantizeVectorGlyphPhase(fractionalPosition.Y)); + var key = new VectorGlyphPathCacheKey( + outline, + emScale, + rasterPhase, + italicSkew, + scaleX, + layer.UsesSvgCoordinates); + + if (!_vectorGlyphPathCache.TryGetValue(key, out positionedOutline!)) + { + if (_vectorGlyphPathCache.Count >= MaxCachedVectorGlyphPaths) + { + _vectorGlyphPathCache.Clear(); + } + + positionedOutline = CreatePositionedGlyphOutline( + outline, + emScale, + rasterPhase, + italicSkew, + usesSvgCoordinates: layer.UsesSvgCoordinates, + scaleX: scaleX); + _vectorGlyphPathCache[key] = positionedOutline; + } + + brushPosition = rasterPhase; + placementTransform = Matrix4x4.CreateTranslation( + position.X - rasterPhase.X, + position.Y - rasterPhase.Y, + 0f) * activeTransform; + } + else + { + positionedOutline = CreatePositionedGlyphOutline( + outline, + emScale, + position, + italicSkew, + usesSvgCoordinates: layer.UsesSvgCoordinates, + scaleX: scaleX); + brushPosition = position; + placementTransform = activeTransform; + } + + CompilePathCommand( + new RenderCommand + { + Type = RenderCommandType.DrawPath, + Path = positionedOutline, + Brush = CreatePositionedColorLayerBrush(layer, emScale, brushPosition), + IsEdgeAliased = textCommand.TextRenderingMode == TextRenderingMode.Aliased, + PathSampleGrid = textCommand.TextRenderingMode == TextRenderingMode.Aliased + ? PathAtlas.StandardCoverageSampleGrid + : PathAtlas.HighPrecisionCoverageSampleGrid, + PathCoverageGamma = pathCoverageGamma + }, + placementTransform, + subpixelPhaseGrid: VectorGlyphDeviceSubpixelPhaseGrid, + quantizeScale: true, + rasterScale: rasterScale); + } + private static float GetTextPathCoverageGamma( float fontSize, Matrix4x4 transform, diff --git a/src/ProGPU.Scene/CompositorOptions.cs b/src/ProGPU.Scene/CompositorOptions.cs index 69e5216f..72ca1bd6 100644 --- a/src/ProGPU.Scene/CompositorOptions.cs +++ b/src/ProGPU.Scene/CompositorOptions.cs @@ -1,3 +1,5 @@ +using ProGPU.Vector; + namespace ProGPU.Scene; public sealed record CompositorOptions @@ -8,6 +10,9 @@ public sealed record CompositorOptions public uint PathAtlasSize { get; init; } = 2048; + public long PathAtlasCpuCacheBudgetBytes { get; init; } = + PathAtlas.DefaultCompiledPathCacheBudgetBytes; + public uint InitialVertexCount { get; init; } = 16384; public uint InitialIndexCount { get; init; } = 24576; @@ -28,6 +33,10 @@ internal void Validate() { throw new ArgumentOutOfRangeException(nameof(PathAtlasSize)); } + if (PathAtlasCpuCacheBudgetBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(PathAtlasCpuCacheBudgetBytes)); + } if (InitialVertexCount == 0) { throw new ArgumentOutOfRangeException(nameof(InitialVertexCount)); diff --git a/src/ProGPU.Scene/RenderCommand.cs b/src/ProGPU.Scene/RenderCommand.cs index 8943e9c5..4eec5cc6 100644 --- a/src/ProGPU.Scene/RenderCommand.cs +++ b/src/ProGPU.Scene/RenderCommand.cs @@ -1164,6 +1164,7 @@ public void DrawText( TextRenderingMode = textRenderingMode, TextHintingMode = textHintingMode, UseVectorGlyphRendering = useVectorGlyphRendering, + PreferGlyphAtlas = true, TextShapingOptions = textShapingOptions, TextAlignment = textAlignment }); @@ -1202,6 +1203,7 @@ public void DrawText( TextRenderingMode = textRenderingMode, TextHintingMode = textHintingMode, UseVectorGlyphRendering = useVectorGlyphRendering, + PreferGlyphAtlas = true, TextShapingOptions = textShapingOptions, TextAlignment = textAlignment }); @@ -1238,6 +1240,7 @@ public void DrawText( TextRenderingMode = textRenderingMode, TextHintingMode = textHintingMode, UseVectorGlyphRendering = useVectorGlyphRendering, + PreferGlyphAtlas = true, TextShapingOptions = textShapingOptions, TextAlignment = textAlignment }); diff --git a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs index c271f974..6b3f7097 100644 --- a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs +++ b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; @@ -176,6 +177,98 @@ public void RepeatedVectorGlyphsReuseSinglePathAtlasEntry() } } + [Fact] + public void RepeatedColorEmojiLayersReusePositionIndependentPathAtlasEntries() + { + const int glyphCount = 48; + var font = new TtfFont(BuildColorLayerFont()); + using var window = new HeadlessWindow(1024, 64); + window.Content = new RepeatedColorGlyphVisual(font, glyphCount); + + window.Render(); + + Assert.InRange(window.Compositor.PathAtlas.CachedPathCount, 1, 2); + Assert.False(window.Compositor.PathAtlas.CapacityExceeded); + + byte[] pixels = window.ReadPixels(); + for (var glyphIndex = 0; glyphIndex < glyphCount; glyphIndex += 8) + { + var alphaOffset = (38 * 1024 + glyphIndex * 20 + 8) * 4 + 3; + Assert.True( + pixels[alphaOffset] > 0, + $"Color glyph {glyphIndex} was not rendered at its translated position."); + } + } + + [Fact] + public void RepeatedComplexColorEmojiReusesAtlasEntries() + { + const int glyphCount = 256; + const int segmentCount = 128; + var font = new TtfFont(BuildComplexColorLayerFont(segmentCount)); + using var window = new HeadlessWindow( + 1024, + 1024, + CompositorOptions.Default with + { + EnableGpuHitTesting = false, + PathAtlasSize = 2048 + }); + window.Content = new RepeatedComplexColorGlyphVisual(font, glyphCount); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + long retainedBytesBefore = GC.GetTotalMemory(forceFullCollection: true); + var stopwatch = Stopwatch.StartNew(); + + window.Render(); + + stopwatch.Stop(); + long retainedBytes = GC.GetTotalMemory(forceFullCollection: true) - retainedBytesBefore; + int cachedPathCount = window.Compositor.PathAtlas.CachedPathCount; + string metrics = + $"cachedPaths={cachedPathCount}, retainedBytes={retainedBytes}, " + + $"renderMilliseconds={stopwatch.Elapsed.TotalMilliseconds:F3}"; + Console.WriteLine($"[PathAtlasComparison] {metrics}"); + + Assert.False(window.Compositor.PathAtlas.CapacityExceeded); + Assert.True(cachedPathCount <= 2, metrics); + } + + [Fact] + public void CompiledPathCachesStayWithinSharedByteBudget() + { + const long cacheBudgetBytes = 1024; + using var atlas = new PathAtlas( + HeadlessWindow.Shared.Context, + atlasSize: 256, + compiledPathCacheBudgetBytes: cacheBudgetBytes); + + for (var index = 0; index < 24; index++) + { + PathGeometry path = PrimitivePathGeometry.CreateRectangle( + index, + index % 3, + 6f + index, + 7f + index); + _ = atlas.GetOrCreatePath(path, scale: 1f); + _ = atlas.TryGetCompiledHitTestPath( + path, + out _, + out _, + out _, + out _, + out _, + out _); + + Assert.InRange(atlas.CompiledPathCacheBytes, 0, cacheBudgetBytes); + } + + Assert.Equal(cacheBudgetBytes, atlas.CompiledPathCacheBudgetBytes); + Assert.InRange(atlas.CachedFillPathCount + atlas.CachedHitTestPathCount, 1, 2); + } + [Fact] public void OffscreenVectorGlyphsAreCulledBeforePathAtlasCompilation() { @@ -1969,6 +2062,34 @@ CompositorOptions.Default with } } + [Fact] + public void DrawTextRecoversGlyphAtlasResidencyAfterEarlierCapacityFallbacks() + { + var font = new TtfFont(BuildMissingGlyphOutlineFont()); + using var window = new HeadlessWindow( + 96, + 64, + CompositorOptions.Default with + { + GlyphAtlasSize = 32, + PathAtlasSize = 256 + }); + window.Content = new AtlasOverflowGlyphRunVisual(font); + window.Render(); + Assert.True(window.Compositor.Atlas.CapacityExceeded); + + window.Content = new PreferredDrawTextVisual(font); + window.Render(); + + Assert.True(window.Compositor.Atlas.EvictionCount > 0); + Assert.Contains( + GetDrawCalls(window.Compositor), + drawCall => drawCall.Type == Compositor.DrawCallType.Text && drawCall.IndexCount > 0); + Assert.DoesNotContain( + GetDrawCalls(window.Compositor), + drawCall => drawCall.Type == Compositor.DrawCallType.Vector && drawCall.IndexCount > 0); + } + [Fact] public void GlyphRunBrushOpacityComposesWithVisualOpacity() { @@ -2916,6 +3037,7 @@ public void CompositorOptionsControlEagerGpuReservations() { GlyphAtlasSize = 256, PathAtlasSize = 512, + PathAtlasCpuCacheBudgetBytes = 2048, InitialVertexCount = 1024, InitialIndexCount = 1536 }; @@ -2924,6 +3046,7 @@ public void CompositorOptionsControlEagerGpuReservations() Assert.Same(options, compositor.Options); Assert.Equal(256u, compositor.Atlas.AtlasSize); Assert.Equal(512u, compositor.PathAtlas.AtlasSize); + Assert.Equal(2048, compositor.PathAtlas.CompiledPathCacheBudgetBytes); Assert.Equal( options.InitialVertexCount * (uint)Marshal.SizeOf(), GetCompositorField(compositor, "_vectorVertexBuffer").Size); @@ -4314,6 +4437,29 @@ private static byte[] BuildColorLayerFont() ("CPAL", BuildCpalTable())); } + private static byte[] BuildComplexColorLayerFont(int pointCount) + { + byte[][] glyphs = + { + Array.Empty(), + Array.Empty(), + BuildZigZagGlyph(pointCount, xOffset: 0), + BuildZigZagGlyph(pointCount, xOffset: 100), + }; + + byte[] glyf = BuildGlyfTable(glyphs, out uint[] glyphOffsets); + return BuildSfntWithTables( + ("head", BuildHeadTable()), + ("hhea", BuildHheaTable(glyphs.Length)), + ("maxp", BuildMaxpTable(glyphs.Length)), + ("hmtx", BuildHmtxTable(glyphs.Length)), + ("cmap", BuildCmapFormat12Table()), + ("loca", BuildLongLoca(glyphOffsets)), + ("glyf", glyf), + ("COLR", BuildColrTable()), + ("CPAL", BuildCpalTable())); + } + private static byte[] BuildMissingGlyphOutlineFont() { byte[][] glyphs = @@ -4434,6 +4580,46 @@ private static byte[] BuildRectangleGlyph(short xMin, short yMin, short xMax, sh return stream.ToArray(); } + private static byte[] BuildZigZagGlyph(int pointCount, short xOffset) + { + Assert.InRange(pointCount, 2, 1024); + const short width = 700; + const short yStep = 6; + short yMax = checked((short)((pointCount - 1) * yStep)); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + WriteShort(writer, 1); + WriteShort(writer, xOffset); + WriteShort(writer, 0); + WriteShort(writer, checked((short)(xOffset + width))); + WriteShort(writer, yMax); + WriteUShort(writer, checked((ushort)(pointCount - 1))); + WriteUShort(writer, 0); + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) + { + writer.Write((byte)1); + } + + short previousX = 0; + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) + { + short x = checked((short)(xOffset + (pointIndex % 2 == 0 ? 0 : width))); + WriteShort(writer, checked((short)(x - previousX))); + previousX = x; + } + + short previousY = 0; + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) + { + short y = checked((short)(pointIndex * yStep)); + WriteShort(writer, checked((short)(y - previousY))); + previousY = y; + } + + return stream.ToArray(); + } + private static byte[] BuildGlyfTable(byte[][] glyphs, out uint[] glyphOffsets) { glyphOffsets = new uint[glyphs.Length + 1]; @@ -5117,6 +5303,74 @@ public override void OnRender(DrawingContext context) } } + private sealed class RepeatedColorGlyphVisual : FrameworkElement + { + private readonly TtfFont _font; + private readonly int _glyphCount; + + public RepeatedColorGlyphVisual(TtfFont font, int glyphCount) + { + _font = font; + _glyphCount = glyphCount; + Width = 1024f; + Height = 64f; + } + + public override void OnRender(DrawingContext context) + { + var glyphIndices = new ushort[_glyphCount]; + var positions = new Vector2[_glyphCount]; + Array.Fill(glyphIndices, (ushort)1); + for (var index = 0; index < _glyphCount; index++) + { + positions[index] = new Vector2(index * 20f, 42f); + } + + context.DrawGlyphRun( + glyphIndices, + positions, + _font, + 24f, + new SolidColorBrush(Vector4.One), + Vector2.Zero); + } + } + + private sealed class RepeatedComplexColorGlyphVisual : FrameworkElement + { + private readonly TtfFont _font; + private readonly int _glyphCount; + + public RepeatedComplexColorGlyphVisual(TtfFont font, int glyphCount) + { + _font = font; + _glyphCount = glyphCount; + Width = 1024f; + Height = 1024f; + } + + public override void OnRender(DrawingContext context) + { + var glyphIndices = new ushort[_glyphCount]; + var positions = new Vector2[_glyphCount]; + Array.Fill(glyphIndices, (ushort)1); + for (int index = 0; index < _glyphCount; index++) + { + positions[index] = new Vector2( + 8f + index % 16 * 62f, + 50f + index / 16 * 62f); + } + + context.DrawGlyphRun( + glyphIndices, + positions, + _font, + 40f, + new SolidColorBrush(Vector4.One), + Vector2.Zero); + } + } + private sealed class FractionalVectorGlyphVisual : FrameworkElement { private readonly TtfFont _font; @@ -5568,6 +5822,28 @@ public override void OnRender(DrawingContext context) } } + private sealed class PreferredDrawTextVisual : FrameworkElement + { + private readonly TtfFont _font; + + public PreferredDrawTextVisual(TtfFont font) + { + _font = font; + Width = 96f; + Height = 64f; + } + + public override void OnRender(DrawingContext context) + { + context.DrawText( + "A", + _font, + 24f, + new SolidColorBrush(Vector4.One), + new Vector2(0.25f, 40f)); + } + } + private sealed class ClippedGlyphRunVisual : FrameworkElement { private readonly TtfFont _font; diff --git a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs index bce6faec..5f1220e8 100644 --- a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs +++ b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs @@ -150,6 +150,34 @@ public void DeferredTextVisualWarmsAfterLayoutWithoutChangingArrangedBounds() Assert.Equal(new Vector2(240f, 48f), text.Size); } + [Fact] + public async Task DeferredTextVisualCanPublishBackgroundShaping() + { + var text = new TextVisual + { + Text = "Background retained shaping", + Font = LoadTestFont(), + FontSize = 18f, + WidthConstraint = 240f, + HeightConstraint = 48f, + DeferLayoutUntilRender = true + }; + + text.Measure(new Vector2(240f, 48f)); + text.Arrange(new Rect(0f, 0f, 240f, 48f)); + + bool[] prepared = await Task.WhenAll( + Task.Run(text.WarmDeferredLayout), + Task.Run(text.WarmDeferredLayout)); + + Assert.All(prepared, Assert.True); + using var atlas = new GlyphAtlas(HeadlessWindow.Shared.Context, atlasSize: 256); + TextLayout? layout = text.GetOrUpdateLayout(atlas); + Assert.NotNull(layout); + Assert.Equal(text.Text, layout.Text); + Assert.Equal(240f, layout.MaxWidth); + } + [Fact] public void ClippedBoundsSkipOnlyLocalCommandsAndStillTraverseOverflowDescendants() { @@ -186,6 +214,26 @@ public void FontIconUsesBoundedGlyphAtlasByDefault() Assert.DoesNotContain(context.Commands, static command => command.Type == RenderCommandType.DrawPath); } + [Fact] + public void DrawTextUsesBoundedGlyphAtlasByDefault() + { + var font = LoadTestFont(); + var context = new DrawingContext(); + + context.DrawText( + "Visible virtualized text", + font, + 13f, + new SolidColorBrush(Vector4.One), + Vector2.Zero); + + var command = Assert.Single( + context.Commands, + static command => command.Type == RenderCommandType.DrawText); + Assert.True(command.PreferGlyphAtlas); + Assert.False(command.UseVectorGlyphRendering); + } + [Fact] public void GlyphAtlasRemembersCapacityFallbacksInsteadOfRetryingEveryFrame() { diff --git a/src/ProGPU.Vector/PathAtlas.cs b/src/ProGPU.Vector/PathAtlas.cs index 048a04a0..b666a25a 100644 --- a/src/ProGPU.Vector/PathAtlas.cs +++ b/src/ProGPU.Vector/PathAtlas.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Silk.NET.Core.Native; using Silk.NET.WebGPU; @@ -241,8 +242,9 @@ public unsafe class PathAtlas : IDisposable public const uint HighPrecisionCoverageSampleGrid = 8; public const uint DefaultSubpixelPhaseGrid = 64; - private const int MaxCompiledFillPathCount = 4096; - private const int MaxCompiledHitTestPathCount = 4096; + public const long DefaultCompiledPathCacheBudgetBytes = 8L * 1024L * 1024L; + + private const int MaxCompiledPathCount = 4096; private const int RasterizationStorageOffsetAlignment = 256; private const int ExactRecoveryPathLimit = 10; private const int ExactRecoveryNodeBudget = 25_000; @@ -251,6 +253,7 @@ public unsafe class PathAtlas : IDisposable private readonly WgpuContext _context; private readonly GpuTexture _atlasTexture; private readonly uint _atlasSize; + private readonly long _compiledPathCacheBudgetBytes; private uint _currentX = 2; private uint _currentY = 2; @@ -279,8 +282,10 @@ public struct PathInfo } private readonly Dictionary _paths = new(); - private readonly Dictionary _compiledFillPaths = new(); - private readonly Dictionary _compiledHitTestPaths = new(); + private readonly Dictionary _compiledFillPaths = new(); + private readonly Dictionary _compiledHitTestPaths = new(); + private readonly LinkedList _compiledPathCacheLru = new(); + private long _compiledPathCacheBytes; private readonly List _tempBuffers = new(); private readonly List _pendingPaths = new(); @@ -364,17 +369,29 @@ private enum RecoveryPlacementHeuristic public GpuTexture AtlasTexture => _atlasTexture; public uint AtlasSize => _atlasSize; public int CachedPathCount => _paths.Count; + public int CachedFillPathCount => _compiledFillPaths.Count; public int CachedHitTestPathCount => _compiledHitTestPaths.Count; + public long CompiledPathCacheBytes => _compiledPathCacheBytes; + public long CompiledPathCacheBudgetBytes => _compiledPathCacheBudgetBytes; public ulong Generation { get; private set; } public bool CapacityExceeded { get; private set; } public int LastExactRecoveryNodeCount { get; private set; } public int LastExactRecoveryCandidateCount { get; private set; } public bool LastExactRecoveryBudgetExceeded { get; private set; } - public PathAtlas(WgpuContext context, uint atlasSize = 2048) + public PathAtlas( + WgpuContext context, + uint atlasSize = 2048, + long compiledPathCacheBudgetBytes = DefaultCompiledPathCacheBudgetBytes) { + if (compiledPathCacheBudgetBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(compiledPathCacheBudgetBytes)); + } + _context = context; _atlasSize = atlasSize; + _compiledPathCacheBudgetBytes = compiledPathCacheBudgetBytes; _atlasTexture = new GpuTexture( _context, @@ -820,7 +837,10 @@ public bool TryGetCompiledHitTestPath( ArgumentNullException.ThrowIfNull(path); int contentHash = ComputeHash(path); - if (_compiledHitTestPaths.TryGetValue(contentHash, out var cached)) + if (TryGetCachedCompiledPath( + _compiledHitTestPaths, + contentHash, + out var cached)) { records = cached.Records; segments = cached.Segments; @@ -845,18 +865,17 @@ public bool TryGetCompiledHitTestPath( localMaxY = 0f; } - if (_compiledHitTestPaths.Count >= MaxCompiledHitTestPathCount) - { - _compiledHitTestPaths.Clear(); - } - - _compiledHitTestPaths[contentHash] = new CompiledPathData( - records, - segments, - localMinX, - localMinY, - localMaxX, - localMaxY); + CacheCompiledPath( + _compiledHitTestPaths, + CompiledPathCacheKind.HitTest, + contentHash, + new CompiledPathData( + records, + segments, + localMinX, + localMinY, + localMaxX, + localMaxY)); return segments.Length != 0; } @@ -873,7 +892,10 @@ private bool TryGetCompiledFillPath( ArgumentNullException.ThrowIfNull(path); int contentHash = ComputeHash(path); - if (_compiledFillPaths.TryGetValue(contentHash, out var cached)) + if (TryGetCachedCompiledPath( + _compiledFillPaths, + contentHash, + out var cached)) { records = cached.Records; segments = cached.Segments; @@ -903,21 +925,108 @@ private bool TryGetCompiledFillPath( localMaxY = 0f; } - if (_compiledFillPaths.Count >= MaxCompiledFillPathCount) + CacheCompiledPath( + _compiledFillPaths, + CompiledPathCacheKind.Fill, + contentHash, + new CompiledPathData( + records, + segments, + localMinX, + localMinY, + localMaxX, + localMaxY)); + return segments.Length != 0; + } + + private bool TryGetCachedCompiledPath( + Dictionary cache, + int contentHash, + out CompiledPathData data) + { + if (!cache.TryGetValue(contentHash, out CompiledPathCacheEntry? entry)) { - _compiledFillPaths.Clear(); + data = default; + return false; } - _compiledFillPaths[contentHash] = new CompiledPathData( - records, - segments, - localMinX, - localMinY, - localMaxX, - localMaxY); - return segments.Length != 0; + _compiledPathCacheLru.Remove(entry.Node); + _compiledPathCacheLru.AddFirst(entry.Node); + data = entry.Data; + return true; } + private void CacheCompiledPath( + Dictionary cache, + CompiledPathCacheKind kind, + int contentHash, + CompiledPathData data) + { + // Average lookup and recency updates are O(1). A miss evicts E entries + // in O(E), retains O(B) payload for byte budget B, and never keeps an + // oversize entry. This bounds complex emoji independently of path count. + long sizeBytes = EstimateCompiledPathBytes(data); + if (sizeBytes > _compiledPathCacheBudgetBytes) + { + return; + } + + while (_compiledPathCacheBytes + sizeBytes > _compiledPathCacheBudgetBytes || + _compiledPathCacheLru.Count >= MaxCompiledPathCount) + { + EvictLeastRecentlyUsedCompiledPath(); + } + + var node = new LinkedListNode( + new CompiledPathCacheToken(kind, contentHash)); + cache[contentHash] = new CompiledPathCacheEntry(data, sizeBytes, node); + _compiledPathCacheLru.AddFirst(node); + _compiledPathCacheBytes += sizeBytes; + } + + private void EvictLeastRecentlyUsedCompiledPath() + { + LinkedListNode? node = _compiledPathCacheLru.Last; + if (node == null) + { + return; + } + + _compiledPathCacheLru.Remove(node); + CompiledPathCacheToken token = node.Value; + Dictionary cache = token.Kind == CompiledPathCacheKind.Fill + ? _compiledFillPaths + : _compiledHitTestPaths; + if (cache.Remove(token.ContentHash, out CompiledPathCacheEntry? entry)) + { + _compiledPathCacheBytes -= entry.SizeBytes; + } + } + + private static long EstimateCompiledPathBytes(CompiledPathData data) + { + const int arrayAndEntryOverhead = 128; + return checked( + (long)data.Records.Length * Unsafe.SizeOf() + + (long)data.Segments.Length * Unsafe.SizeOf() + + arrayAndEntryOverhead); + } + + private enum CompiledPathCacheKind : byte + { + Fill, + HitTest + } + + private readonly record struct CompiledPathCacheToken( + CompiledPathCacheKind Kind, + int ContentHash); + + private sealed record CompiledPathCacheEntry( + CompiledPathData Data, + long SizeBytes, + LinkedListNode Node); + private readonly record struct CompiledPathData( GpuPathRecord[] Records, GpuPathSegment[] Segments, @@ -2500,6 +2609,8 @@ public void Dispose() _paths.Clear(); _compiledFillPaths.Clear(); _compiledHitTestPaths.Clear(); + _compiledPathCacheLru.Clear(); + _compiledPathCacheBytes = 0; _pendingPaths.Clear(); _isDisposed = true; diff --git a/src/ProGPU.WinUI/Controls/TextVisual.cs b/src/ProGPU.WinUI/Controls/TextVisual.cs index 1e26561d..2648a799 100644 --- a/src/ProGPU.WinUI/Controls/TextVisual.cs +++ b/src/ProGPU.WinUI/Controls/TextVisual.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Threading; using ProGPU.Backend; using ProGPU.Text; using ProGPU.Text.Shaping; @@ -24,6 +25,7 @@ public class TextVisual : FrameworkElement, ITextLayoutProvider private TextShapingOptions _textShapingOptions = TextShapingOptions.Default; private TextReadingOrder _textReadingOrder = TextReadingOrder.DetectFromContent; private bool _deferLayoutUntilRender; + private int _layoutRevision; public string Text { @@ -33,7 +35,7 @@ public string Text if (_text != value) { _text = value; - _layout = null; + ClearLayout(); Invalidate(); } } @@ -44,7 +46,7 @@ protected override void OnPropertyChanged(Microsoft.UI.Xaml.DependencyProperty d base.OnPropertyChanged(dp, oldValue, newValue); if (dp == FontProperty || dp == FlowDirectionProperty) { - _layout = null; + ClearLayout(); InvalidateMeasure(); Invalidate(); } @@ -58,7 +60,7 @@ public float FontSize if (_fontSize != value) { _fontSize = value; - _layout = null; + ClearLayout(); Invalidate(); } } @@ -85,7 +87,7 @@ public ProGPU.Text.TextAlignment Alignment if (_alignment != value) { _alignment = value; - _layout = null; + ClearLayout(); Invalidate(); } } @@ -100,7 +102,7 @@ public TextShapingOptions TextShapingOptions if (!_textShapingOptions.Equals(value)) { _textShapingOptions = value; - _layout = null; + ClearLayout(); Invalidate(); } } @@ -113,7 +115,7 @@ public TextReadingOrder TextReadingOrder { if (_textReadingOrder == value) return; _textReadingOrder = value; - _layout = null; + ClearLayout(); InvalidateMeasure(); Invalidate(); } @@ -149,7 +151,7 @@ public bool DeferLayoutUntilRender if (_deferLayoutUntilRender != value) { _deferLayoutUntilRender = value; - _layout = null; + ClearLayout(); InvalidateMeasure(); Invalidate(); } @@ -161,6 +163,12 @@ public bool DeferLayoutUntilRender return Font ?? PopupService.DefaultFont; } + private void ClearLayout() + { + Interlocked.Increment(ref _layoutRevision); + Volatile.Write(ref _layout, null); + } + public override Rect? LocalRenderBounds { get @@ -176,28 +184,33 @@ public override Rect? LocalRenderBounds if (resolvedFont == null) return null; float maxWidth = Size.X; - if (_layout == null || !HasCompatibleLayoutWidth(_layout, maxWidth)) + TextLayout? layout = Volatile.Read(ref _layout); + if (layout == null || !HasCompatibleLayoutWidth(layout, maxWidth)) { - _layout = new TextLayout(Text, resolvedFont, FontSize, maxWidth, Alignment, atlas, EffectiveShapingOptions); + layout = new TextLayout(Text, resolvedFont, FontSize, maxWidth, Alignment, atlas, EffectiveShapingOptions); + Volatile.Write(ref _layout, layout); } - else if (!_layout.HasTextures) + else if (!layout.HasTextures) { - _layout.GenerateLayout(atlas); + layout.GenerateLayout(atlas); } - return _layout; + return layout; } /// - /// Shapes a deferred retained layout without allocating atlas textures. Returns false - /// until layout has supplied a finite width. + /// Shapes a deferred retained layout without allocating atlas textures. The completed + /// layout is published atomically, so this may run on a background worker + /// after arrange. Returns false until layout has supplied a finite width or when the + /// text state changed while shaping. /// public bool WarmDeferredLayout() { - if (_layout != null) + if (Volatile.Read(ref _layout) != null) { return true; } + int revision = Volatile.Read(ref _layoutRevision); var resolvedFont = ResolveFont(); float maxWidth = Size.X; if (string.IsNullOrEmpty(Text) || resolvedFont == null) @@ -209,8 +222,18 @@ public bool WarmDeferredLayout() return false; } - _layout = new TextLayout(Text, resolvedFont, FontSize, maxWidth, Alignment, null, EffectiveShapingOptions); - return true; + string text = Text; + float fontSize = FontSize; + ProGPU.Text.TextAlignment alignment = Alignment; + TextShapingOptions shapingOptions = EffectiveShapingOptions; + var prepared = new TextLayout(text, resolvedFont, fontSize, maxWidth, alignment, null, shapingOptions); + if (revision != Volatile.Read(ref _layoutRevision)) + { + return false; + } + + Interlocked.CompareExchange(ref _layout, prepared, null); + return revision == Volatile.Read(ref _layoutRevision); } protected override Vector2 MeasureOverride(Vector2 availableSize) @@ -228,20 +251,23 @@ protected override Vector2 MeasureOverride(Vector2 availableSize) return new Vector2(maxWidth, HeightConstraint.Value); } - if (_layout == null || !HasCompatibleLayoutWidth(_layout, maxWidth)) + TextLayout? layout = Volatile.Read(ref _layout); + if (layout == null || !HasCompatibleLayoutWidth(layout, maxWidth)) { - _layout = new TextLayout(Text, resolvedFont, FontSize, maxWidth, Alignment, null, EffectiveShapingOptions); + layout = new TextLayout(Text, resolvedFont, FontSize, maxWidth, Alignment, null, EffectiveShapingOptions); + Volatile.Write(ref _layout, layout); } - return _layout.MeasuredSize; + return layout.MeasuredSize; } protected override void ArrangeOverride(Rect arrangeRect) { Size = new Vector2(arrangeRect.Width, arrangeRect.Height); float maxWidth = arrangeRect.Width; - if (_layout != null && !HasCompatibleLayoutWidth(_layout, maxWidth)) + TextLayout? layout = Volatile.Read(ref _layout); + if (layout != null && !HasCompatibleLayoutWidth(layout, maxWidth)) { - _layout = null; + ClearLayout(); } }