diff --git a/.github/workflows/svg-skia-parity.yml b/.github/workflows/svg-skia-parity.yml
index 1b5f635e..5f9faa5d 100644
--- a/.github/workflows/svg-skia-parity.yml
+++ b/.github/workflows/svg-skia-parity.yml
@@ -36,7 +36,7 @@ jobs:
image-parity:
name: Native and ProGPU image parity
runs-on: macos-26
- timeout-minutes: 45
+ timeout-minutes: 15
steps:
- name: Checkout ProGPU
@@ -102,7 +102,7 @@ jobs:
--logger "trx;LogFileName=progpu-resvg.trx"
--results-directory "${{ github.workspace }}/artifacts/svg-skia/progpu"
- - name: Run remaining ProGPU Svg.Skia tests
+ - name: Run remaining ProGPU Svg.Skia tests serially
working-directory: external/Svg.Skia-progpu
run: >-
dotnet test ${{ env.SVG_TEST_PROJECT }}
@@ -115,6 +115,9 @@ jobs:
--filter "FullyQualifiedName!~W3CTestSuiteTests&FullyQualifiedName!~resvgTests"
--logger "trx;LogFileName=progpu-remaining.trx"
--results-directory "${{ github.workspace }}/artifacts/svg-skia/progpu"
+ --blame-hang-dump-type none
+ --blame-hang-timeout 2m
+ -- xUnit.ParallelizeTestCollections=false
- name: Run ProGPU W3C comparison
id: progpu-w3c
@@ -147,6 +150,7 @@ jobs:
name: svg-skia-parity-${{ github.sha }}
path: |
artifacts/svg-skia/**/*.trx
+ artifacts/svg-skia/**/*.xml
external/Svg.Skia-native/tests/Tests/**/* (Actual).png
external/Svg.Skia-progpu/tests/Tests/**/* (Actual).png
if-no-files-found: warn
diff --git a/Directory.Packages.props b/Directory.Packages.props
index efede4cd..ccd801fd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,12 +10,14 @@
+
+
diff --git a/docs/GPU_TEXT_COVERAGE_CACHE_ARCHITECTURE.md b/docs/GPU_TEXT_COVERAGE_CACHE_ARCHITECTURE.md
new file mode 100644
index 00000000..39376611
--- /dev/null
+++ b/docs/GPU_TEXT_COVERAGE_CACHE_ARCHITECTURE.md
@@ -0,0 +1,177 @@
+# GPU text coverage-cache architecture
+
+Date: 2026-07-22
+
+## Objective and constraints
+
+This design reduces persistent GPU memory for ProGPU text and vector coverage without changing the retained-scene, shaping, fallback-font, variable-font, DPI, subpixel, hinting, or winding contracts. Rasterization remains GPU-only: no coverage image is rasterized on, or read back through, the CPU.
+
+The implementation is clean-room. The sources below informed public behavior, architecture, and tradeoffs only; no foreign source code, helper organization, names, or encoded tables were copied or translated.
+
+## Primary-source research
+
+| Engine | Relevant architecture | ProGPU decision |
+| --- | --- | --- |
+| [Skia strike cache](https://skia.googlesource.com/skia/+/main/src/core/SkStrikeCache.h), [SkParagraph API](https://skia.googlesource.com/skia/+/refs/heads/main/modules/skparagraph/include/Paragraph.h), and [SkParagraph LRU cache](https://skia.googlesource.com/skia/+/a1f873e79a50/modules/skparagraph/include/ParagraphCache.h) | Separates reusable font/strike data, bounds caches by bytes/count, retains shaped paragraph state, and treats glyph representations as cacheable resources rather than one unbounded global image. | Adopt byte-accounted resource limits, representation separation, and retained layout reuse. Keep ProGPU's typed font/outline ownership and GPU rasterizer instead of adopting Skia's CPU strike implementation. |
+| [Skia `GrDrawOpAtlas`](https://skia.googlesource.com/skia/+/main/src/gpu/ganesh/GrDrawOpAtlas.cpp) and [atlas contract](https://skia.googlesource.com/skia/+/main/src/gpu/ganesh/GrDrawOpAtlas.h) | Activates backing pages lazily, manages independently reusable plots with generation/LRU state, and uses deferred upload/use tokens so multiple pending plot changes can piggyback on one upload. | Adapt lazy physical residency, separate texture revision from content generation, and coalesce first-use transfers at the compositor batch boundary. Keep ProGPU's single-texture-per-representation contract and original shelf/recovery allocators rather than copying Skia's page/plot organization. |
+| [DirectWrite glyph-run analysis](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwriteglyphrunanalysis) and [Direct2D text performance guidance](https://learn.microsoft.com/en-us/windows/win32/direct2d/improving-direct2d-performance) | Exposes bounded glyph-run alpha bounds and uses one byte per pixel for grayscale alpha, three for ClearType; encourages reuse of text layouts and rendering resources. | Adopt compact grayscale coverage and retained shaped-run reuse. Preserve ProGPU's existing shader reconstruction of grayscale/ClearType output and four-way physical-pixel snapping. |
+| [DirectWrite color fonts](https://learn.microsoft.com/en-us/windows/win32/directwrite/color-fonts), [Win2D overview](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/), and [Win2D `CanvasTextLayout`](https://microsoft.github.io/Win2D/WinUI3/html/T_Microsoft_Graphics_Canvas_Text_CanvasTextLayout.htm) | Color glyph layers are a distinct representation, while reusable text layouts retain shaping and line-layout work. Win2D exposes Direct2D/DirectWrite through a GPU-accelerated API. | Split RGBA bitmap/color glyphs from monochrome coverage. Keep shaping/layout above the atlas and preserve fallback/variable-font state in existing typed cache keys. |
+| [WebRender rendering overview](https://searchfox.org/firefox-main/source/gfx/docs/RenderingOverview.rst), [glyph rasterizer](https://searchfox.org/mozilla-central/source/gfx/wr/wr_glyph_rasterizer/src/lib.rs), and [asynchronous blob preparation](https://searchfox.org/firefox-main/source/gfx/wr/webrender/doc/blob.md) | Culls a retained scene before preparing visible resources, prepares glyphs during scene building before final GPU rendering, and separates eager preparation from visibility-driven texture-cache upload. Glyph raster preparation can run on workers. | Adopt alpha/color format separation, demand-driven population after visibility, and a prepare-then-submit glyph batch. Retain ProGPU's analytic GPU compute preparation rather than adding a second CPU rasterizer or speculative whole-page atlas population. |
+| [glyphon text atlas](https://github.com/grovesNL/glyphon/blob/main/src/text_atlas.rs) | Starts mask/color atlases small, grows them geometrically, and applies per-frame usage/LRU policy. | Adapt small initial mask/color textures and bounded geometric growth. ProGPU preserves resident texels by GPU copy and keeps texel-space vertices, an original solution required by its retained-scene contract. |
+| [Vello](https://github.com/linebender/vello) and its [glyph rendering design discussion](https://github.com/linebender/vello/issues/204) | Uses GPU compute for vector rendering and evaluates dynamic outlines versus cached glyph images; transform-specific hinting makes one universal cached representation unsuitable. | Keep analytic GPU rasterization and bounded cached coverage. Reject an all-vector-per-frame design because stable UI text benefits from retained coverage, and reject SDF/MSDF substitution because it would change small-text hinting and coverage quality. |
+| [Parley](https://docs.rs/parley/latest/parley/) | Shares font and layout contexts and keeps shaping/layout reusable and independent of the final renderer. | Preserve ProGPU's reusable CPU shaping/layout results and glyph indices; do not move Unicode/OpenType shaping into the coverage cache. |
+| [HarfBuzz shaping-plan caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | Reuses shaping plans for matching face, segment properties, and features. | Preserve current shaped results, OpenType feature state, fallback selection, and variable-font instance identity; atlas changes start only after glyph identity and position are known. |
+| [WebGPU specification](https://gpuweb.github.io/gpuweb/) | Defines `r8unorm`, storage buffers, and buffer-to-texture copies with aligned row layout. Storage-texture format capabilities vary by implementation tier. | Use a universally writable storage buffer as compute output, pack four coverage bytes per `u32`, then issue GPU buffer-to-`r8unorm` copies. This avoids depending on optional R8 storage-texture support. |
+| [wgpu `StagingBelt`](https://wgpu.rs/doc/wgpu/util/belt/struct.StagingBelt.html) | Suballocates reusable chunks, recommends sizing below a whole submission, and allocates an exceptional buffer when one operation exceeds a normal chunk. | Adapt bounded 64 KiB uniform and 256 KiB coverage rings, exact temporary storage for a single oversized glyph, and 256 KiB path dispatch chunks. No wgpu implementation source was ported. |
+
+The required comparison areas map as follows:
+
+- Startup and lazy initialization: maximum atlas dimensions are capacity limits, not startup allocations. Coverage/path begin at 512 square and color at 256 square; no font enumeration, outline upload, or raster work is added at startup.
+- Shaping and layout reuse: unchanged. Positioned glyph indices and advances remain reusable CPU results, following the separation used by DirectWrite, Parley, HarfBuzz, and SkParagraph-style stacks.
+- Retained scene and visibility: unchanged. Only glyphs and paths demanded by compiled visible commands enter the atlas, comparable to WebRender's retained-scene resource preparation.
+- Cache keys and eviction: all existing glyph style, physical size, DPI/subpixel, local-transform, variable-font, path-phase, and generation keys remain intact. Alpha and color entries cannot reuse one another's storage.
+- Demand-driven upload: compute output is copied only for pending glyph/path rectangles. There is no full-atlas upload or CPU readback.
+- Worker preparation: font parsing/shaping remains reusable CPU work. Coverage preparation is GPU compute, batched before rendering; adding CPU raster workers was rejected.
+- GPU batching: glyphs share small persistent rings, stage uniforms/outlines contiguously, reuse one dynamic-offset bind group and compute pass per ring submission, and use an exact exceptional buffer only for one oversized glyph. Paths reuse a buffer per bounded dispatch chunk rather than allocating the sum of all pending output. One invocation produces four adjacent coverage texels.
+- DPI, subpixel, and hinting: raster dimensions, physical-space phase keys, final unsnapped placement, sample grids, gamma, and ClearType reconstruction are unchanged.
+- Fallback and variable fonts: existing font identity, shaped glyph index, and outline selection remain the authoritative inputs. The storage format does not merge font instances.
+- Device loss and atlas generations: resources remain owned by the compositor/context; atlas `Generation` changes only on real clear/repack. A format split does not weaken retained-scene invalidation or capacity-retry behavior.
+
+## Implemented design
+
+### Compact GPU coverage pipeline
+
+The glyph and path compute shaders keep their existing analytic winding and sample grids. Each invocation now rasterizes four adjacent output pixels and packs the four normalized coverage bytes into one `u32` in a storage buffer. A command-encoder buffer-to-texture copy places the result in an `R8Unorm` atlas. Row pitch is aligned to WebGPU's 256-byte copy requirement.
+
+For a raster rectangle of width `W`, height `H`, segment count `S`, and sample grid `A`, raster work remains `O(W * H * A * S)` in the worst case. Output storage is `O(align256(W) * H)` bytes rather than `O(4 * atlasWidth * atlasHeight)` writable RGBA storage. The output never crosses the CPU boundary.
+
+### Representation-separated atlases
+
+- Monochrome glyph coverage: starts at `512 x 512 x 1` byte R8 and doubles on demand up to the configured maximum (`2560` in the sample).
+- Bitmap/color glyphs: starts at `256 x 256 x 4` byte RGBA and grows independently up to `512`.
+- Paths and vector-glyph fallback: starts at `512 x 512 x 1` byte R8 and grows up to `2048`.
+- Glyph compute staging: 64 KiB uniform ring plus 256 KiB coverage ring. A single glyph larger than the normal ring is processed with exact temporary GPU storage rather than failing or permanently enlarging the ring.
+- Path compute staging: one buffer bounded to a 256 KiB dispatch chunk in the ordinary case. A single larger path receives the exact space it requires, preserving coverage quality.
+
+Growth clears a new texture, copies the old top-left texels entirely on the GPU, swaps texture ownership, and increments a texture revision. Atlas `Generation` does not change because no entry moved and no content became invalid. Retained text/path vertices carry texel coordinates; the shaders normalize against the currently bound texture dimensions. Consequently, a texture can grow during compilation without invalidating already compiled vertices or normalized public metadata. The compositor rebuilds only the affected bind groups when a texture revision changes.
+
+The text shader selects the R8 or RGBA binding from the retained glyph representation flag. This avoids scaling the color allocation to the dimensions required by Latin/CJK/vector coverage while preserving premultiplied bitmap-glyph sampling.
+
+### Bounded outline storage
+
+GPU glyph outline records and segments now use one global pair of buffers across all fonts. Per-font state contains only the glyph-to-global-slot map, eliminating per-font minimum allocations and slack. Capacity grows by 1.5x through GPU-to-GPU copy instead of doubling. A reusable CPU scratch list is used only while parsing a newly demanded outline. Allocated GPU outline capacity is measured and bounded to 4 MiB by default; the cache is rebuilt lazily after a frame-boundary trim. Coverage already resident in the atlas remains valid.
+
+### First-interaction upload batching
+
+The retained scene compiler still discovers only glyphs required by the visible page, but newly discovered glyphs no longer issue three queue writes, create a bind group, and open a compute pass apiece. During a glyph batch:
+
+- 256-byte-aligned uniforms are written into a reusable 64 KiB CPU shadow and transferred to the GPU ring with one queue write per ring submission;
+- new global outline records and segments are appended to contiguous staging lists and uploaded with at most two writes per ordinary 256 KiB outline chunk; an exceptional large-glyph backing array is released after upload rather than becoming permanent managed residency;
+- one bind group binds the uniform/record/segment/coverage buffers, and each glyph selects its uniform slice with a WebGPU dynamic offset;
+- disjoint coverage dispatches share one compute pass; buffer-to-texture copies are recorded after that pass and before the single queue submission;
+- a batch that discovers no new coverage releases its empty encoder without submitting GPU work.
+
+For `G` newly visible glyphs with `S` outline segments, parsing and transferred bytes remain `O(G + S)` and analytic raster work is unchanged. Ordinary queue-write and pass setup count changes from `O(G)` to `O(ceil(B / C))`, where `B` is staged bytes and `C` is the bounded ring/chunk size. The staging lists are capped at the ordinary 256 KiB outline chunk per representation, except for one exact oversized outline. This follows the deferred/coalesced preparation pattern without speculatively populating hidden pages, which would conflict with the residency goal.
+
+### Managed retained-command ownership
+
+Managed heap profiling found that `RichTextBlock`, `MarkdownTextBlock`, and `FlowDocument` already owned immutable-until-invalidated `DrawingContext` command caches, while the compositor copied the same large value-type command arrays into a pooled recording context and then into a generic retained cache. These controls now implement the typed `IOwnedRenderCommandCache` contract. The compositor requests and compiles their existing cache directly; it neither calls the copying `OnRender` adapter nor creates a second retained stream. Public/manual `OnRender` behavior is unchanged.
+
+### Bounded fallback and rich-document metadata reuse
+
+Allocation-stack profiling of a 5,000-paragraph multilingual editor isolated repeated fallback selection as the dominant managed cost on platforms with system fonts. The layout path copied the complete system-font catalog for each miss and recreated the same variable-font instance for repeated characters. `FontManager` now keeps positive and negative no-language fallback matches in a catalog-versioned FIFO cache capped at 4,096 entries. Registered-font changes advance the catalog version and clear the cache. Variable-font style instances use a separate 256-entry bounded cache, while the public `FontApi.GetSystemFonts()` API retains its copy-on-return ownership contract and internal matching reads an immutable catalog view.
+
+Rich-document layout sessions also retain each block's logical UTF-16 length until that block is invalidated. Viewport refresh remains `O(B + V)` in the current offset-assignment architecture for `B` blocks and `V` realized blocks, but no longer recursively enumerates every block's inline tree twice per refresh. Editor caret, hit-test, and scroll paths use the arranged presenter width so an input query cannot introduce a second layout key.
+
+The native far-jump probe fell from 20,971,771 to 6,240,269 managed bytes per scroll (70.2% lower). This deliberately harsh probe moves among distant regions, forcing new visible paragraphs to shape; it therefore measures bounded fallback/metadata reuse without claiming allocation-free Unicode shaping.
+
+### Protocol and diagnostics
+
+The typed native and browser WebGPU APIs expose command-encoder buffer-to-texture and texture-to-texture copies. Compositor metrics report current and maximum atlas dimensions, coverage/color/path texture bytes, glyph staging bytes, outline count/capacity/GPU bytes, and current/peak path staging. The sample benchmark additionally reports managed heap and fragmented bytes.
+
+## Memory and performance evidence
+
+The sample configuration previously reserved two RGBA atlases:
+
+| Persistent/bounded GPU allocation | Before | After |
+| --- | ---: | ---: |
+| Glyph coverage/color textures | 26,214,400 B | 7,602,176 B |
+| Path coverage texture | 16,777,216 B | 4,194,304 B |
+| Bounded glyph staging | 0 B | 2,097,152 B |
+| **Fixed atlas + glyph staging total** | **42,991,616 B** | **13,893,632 B** |
+
+This is a 67.7% reduction (29,097,984 bytes) in the exact fixed/bounded allocation represented by those resources. The new system additionally reports, and bounds, outline and transient path staging rather than hiding them in process-level memory figures.
+
+The second pass changes configured dimensions into maxima. For the same sample configuration, exact startup residency is:
+
+| GPU allocation | First compact pass | Demand-grown pass at startup |
+| --- | ---: | ---: |
+| Glyph coverage texture | 6,553,600 B | 262,144 B |
+| Color glyph texture | 1,048,576 B | 262,144 B |
+| Path coverage texture | 4,194,304 B | 262,144 B |
+| Glyph coverage staging | 2,097,152 B | 262,144 B |
+| Glyph uniform staging | 262,144 B | 65,536 B |
+| Initial global outline buffers | per-font | 51,200 B total |
+| **Known startup total above** | **14,155,776 B plus per-font outlines** | **1,165,312 B** |
+
+The new known startup total is 91.8% below the first compact pass and 97.3% below the original 42,991,616-byte RGBA-atlas allocation, while retaining the same configured maximum coverage capacity. At maximum texture size, the smaller rings still reduce the fixed atlas/staging portion from 13,893,632 to 12,124,160 bytes. Path staging is no longer proportional to the total pending raster area: the ordinary peak is 256 KiB, with only a single quality-preserving oversized path allowed to exceed it.
+
+The upload-batching regression requests 13 unique first-use glyphs in one compilation batch. The old structure required 39 CPU-to-GPU queue writes, 13 bind groups, and 13 compute passes. The new structure performs 3 queue writes (uniforms, records, segments), 1 reusable bind group, 1 compute pass, and 1 submission. An immediately following empty batch performs no submission. This is an exact API-operation count; no wall-time claim is inferred from it.
+
+A release headless profile of all 195 sample-page tests used induced-GC heap dumps plus root analysis. The dominant necessary residency was font payload/parser data and visible rich-document layout. The actionable duplicate was a 2,228,248-byte `RenderCommand[]` for the active Markdown workload: before direct replay, the same-sized array appeared twice (owned cache plus copied compositor context); after direct replay, only the owned array remained. The full induced-GC checkpoint reported 87,062,289 live managed bytes and 711,902 objects after the change. Whole-heap totals are workload-phase sensitive, so the precise claim is the eliminated duplicate command array and ownership path, not a process-RSS percentage.
+
+A release-build sweep visited all text/font sample pages (Text & Documents, Rich Document Editor, Markdown Playground, Glyph Run Showcase, Text Shaping Lab, Typography & Scripts, Inter Typeface, Interactive Input, Font Glyph Browser, WPF Shim Showcase, and SkiaSharp Shim) before measuring the same Data Virtualization scene for 60 warm-up and 180 measured frames:
+
+| Metric | Baseline | New design | Change |
+| --- | ---: | ---: | ---: |
+| Compiled-scene CPU time | 0.9577 ms | 0.5308 ms | -44.6% |
+| Compositor CPU time | 1.2040 ms | 0.7687 ms | -36.2% |
+| Managed allocation/frame | 26,953 B | 25,964 B | -3.7% |
+| Wall throughput | 480.26 FPS | 472.54 FPS | -1.6% |
+| Glyph/path capacity failures | 0 / 0 | 0 / 0 | unchanged |
+| Glyph evictions / atlas clears / path resets | 0 / 0 / 0 | 0 / 0 / 0 | unchanged |
+
+Wall throughput varied by 1.6%, so it is treated as neutral noise rather than an improvement claim. Process RSS and OS memory-footprint counters were also noisy and contradictory between isolated runs; the memory claim therefore uses exact resource sizes exposed by compositor metrics. The populated new run held 2,712 glyph entries and 141 path entries, allocated 1,775,616 bytes of GPU outline capacity, used 196,080 bytes of path CPU cache, and peaked at 1,178,624 bytes of transient path staging.
+
+An isolated Font Glyph Browser run also reduced compilation from 3.5679 to 1.7468 ms and compositor CPU time from 3.9711 to 2.0189 ms, while allocations fell from 117,561 to 102,670 bytes/frame. These timings support the architectural result but are not substituted for the all-feature sweep.
+
+Matched browser-AOT WebGPU scroll profiles against `main` additionally covered the pages that apply the greatest atlas or document pressure:
+
+| Workload | Main compile | New compile | Main managed heap | New managed heap | Atlas result |
+| --- | ---: | ---: | ---: | ---: | --- |
+| Font Glyph Browser, 600 frames | 4.0360 ms | 0.9193 ms | 28,964,208 B | 27,280,832 B | 240 glyph evictions, zero clears; 512 path atlas |
+| Markdown Playground, 600 frames | 9.5631 ms | 0.6763 ms | 51,170,064 B | 48,248,392 B | 32 paths; 512 path atlas |
+| Inter Typeface, 600 frames | 2.4310 ms | 0.3643 ms | 51,587,912 B | 44,215,248 B | 224 paths; 2048 path atlas |
+| Data Virtualization, 600 frames | 3.6888 ms | 1.7267 ms | 30,684,896 B | 26,224,792 B | zero evictions/clears; 512 path atlas |
+| Text & Documents, 180 frames | 45.1189 ms | 39.3411 ms | 290,469,064 B | 276,480,952 B | paths reduced 2,930 to 505; 2048 path atlas |
+
+All runs completed with zero WebGPU console errors after moving path-atlas derivatives to uniform fragment control flow. The rich-editor sequential allocation rate was neutral (9,584,391 versus 9,580,493 bytes/frame); the measurable wins there are 13,988,112 fewer retained managed bytes, fewer vector fallback paths, 12.8% lower compile time, and 9.4% higher wall throughput. The 2048 path texture is retained after Inter/rich-document pressure because its exact R8 cost is 4 MiB and retaining it avoids page-switch repacks and renewed GPU uploads; shrinking it would trade a small bounded high-water mark for the interaction delay this design is intended to remove.
+
+A matched page-switch run scrolled Font Glyph Browser, Text & Documents, Markdown Playground, and Inter Typeface for 60 frames each, then measured Data Virtualization for 300 frames. Browser logs confirmed every transition in order. Against `main`, allocation fell from 31,957 to 13,588 bytes/frame (-57.5%), managed heap from 82,614,376 to 78,255,336 bytes (-5.3%), compile time from 3.6043 to 1.8240 ms (-49.4%), and compositor time from 3.6850 to 1.9197 ms (-47.9%). It completed with zero evictions, clears, capacity failures, generation changes, over-budget compile frames, or console errors.
+
+## Quality and regression evidence
+
+- The glyph shader retains 8x8 high-precision coverage and the direction-aware half-open quadratic/cubic winding rules.
+- The path shader retains each command's selected sample grid, fill rule, transform phase, scale quantization, and recovery behavior.
+- Color bitmap glyphs retain RGBA data and filtered sampling in a dedicated texture.
+- The text shader retains aliased, grayscale, gamma/contrast, mask, and ClearType paths; only the sampled texture binding changes.
+- Atlas generations still change only when cached UV contents are cleared, moved, or repacked.
+- Texture growth is covered by a GPU readback regression proving that resident coverage survives growth at unchanged texel coordinates while `Generation` remains stable and texture revision advances.
+- Owned rich-text/Markdown/flow command caches are compiled directly and remain compatible with explicit `OnRender` replay.
+- Regression tests cover exact configured residency, R8 readback, color/coverage separation, shader resource contracts, atlas recovery, phase bounds, and existing rendering behavior.
+
+Validation commands:
+
+- `dotnet test src/ProGPU.Tests/ProGPU.Tests.csproj -c Release --no-restore`: 2,264 passed, 0 failed.
+- `dotnet test src/ProGPU.Tests.Headless/ProGPU.Tests.Headless.csproj -c Release --no-restore`: 195 passed, 0 failed.
+- `dotnet build src/ProGPU.slnx -c Release --no-restore`: succeeded with 0 errors.
+- `dotnet publish src/ProGPU.Samples.Browser/ProGPU.Samples.Browser.csproj -c Release --no-restore`: AOT-compiled all 68 eligible assemblies and produced the native WebAssembly artifact.
+
+## Rejected alternatives
+
+- RGBA storage textures for coverage: portable and simple, but waste three channels for every monochrome texel.
+- R8 storage-texture writes: direct, but not a sufficiently portable baseline across the native and browser targets.
+- CPU rasterization or CPU repacking: lowers GPU requirements but violates the GPU-only goal and adds transfer/synchronization cost.
+- SDF/MSDF atlases: attractive for scale reuse, but change small-text coverage, hinting, stroke, and transformed vector quality.
+- Per-frame uncached vector text: bounds residency but repeats analytic raster work for stable UI text.
+- One universal color/coverage atlas: recreates the RGBA tax and couples unrelated capacity/eviction pressure.
diff --git a/docs/SAMPLE_PAGE_MEMORY_PROFILE.md b/docs/SAMPLE_PAGE_MEMORY_PROFILE.md
new file mode 100644
index 00000000..559a5607
--- /dev/null
+++ b/docs/SAMPLE_PAGE_MEMORY_PROFILE.md
@@ -0,0 +1,223 @@
+# Release sample-page memory profile
+
+## Outcome
+
+The complete desktop gallery was measured page-by-page in fresh Release processes before
+and after the managed-memory work. Across all 47 pages, average retained managed memory
+fell from 80.51 MiB to 30.29 MiB (**62.4% lower**), average managed allocation fell from
+154.10 KiB/frame to 12.04 KiB/frame (**92.2% lower**), and average compositor compilation
+fell from 1.223 ms to 0.250 ms (**79.6% lower**). The sum of the 47 isolated retained-heap
+measurements fell from 3.70 GiB to 1.39 GiB. This sum is useful for comparing the complete
+gallery but is not the memory of one process because every page was deliberately isolated.
+
+The worst retained managed heap changed from Text Shaping Lab at 513.91 MiB to Text &
+Documents at 189.27 MiB. Average macOS physical footprint, which also includes the runtime,
+native WebGPU/Metal objects, driver caches, and mapped resources, fell from 403.90 MiB to
+369.69 MiB (**8.5% lower**). Compile frames over the 16.67 ms budget fell from 428 to zero;
+426 of the baseline frames were from Text & Documents. Across the isolated runs, Gen0
+collections fell from 503 to 33 and summed GC pause time fell from 868.3 ms to 34.3 ms.
+
+Relative to the previous optimized checkpoint, this final pass reduced allocation by a
+further **72.2%**, compilation by **31.0%**, physical footprint by **0.3%**, and summed GC
+pause time by **89.5%**. Average retained managed memory moved by +0.29 MiB (+1.0%) between
+the two fresh-process sweeps; this is reported rather than hidden, while the physical and
+collection measurements improved.
+
+| Representative page | Managed before | Managed after | Change | Allocation/frame before | Allocation/frame after | Compile before | Compile after |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| Text Shaping Lab | 513.91 MiB | 70.07 MiB | **-86.4%** | 22.8 KiB | 0.9 KiB | 0.082 ms | 0.036 ms |
+| Typography & Scripts | 345.18 MiB | 82.16 MiB | **-76.2%** | 10.2 KiB | 0.9 KiB | 0.036 ms | 0.017 ms |
+| Text & Documents | 472.28 MiB | 189.27 MiB | **-59.9%** | 4,128.4 KiB | 189.3 KiB | 41.728 ms | 2.037 ms |
+| Font Glyph Browser | 60.85 MiB | 31.26 MiB | **-48.6%** | 104.9 KiB | 37.4 KiB | 2.491 ms | 0.732 ms |
+| Visual Designer | 81.26 MiB | 46.57 MiB | **-42.7%** | 129.5 KiB | 95.3 KiB | 1.906 ms | 1.872 ms |
+| LOL/s Benchmark | 65.83 MiB | 36.80 MiB | **-44.1%** | 1,994.6 KiB | 28.9 KiB | 2.999 ms | 1.881 ms |
+
+## Measurement method
+
+`tools/profile-sample-memory.sh` discovers the gallery pages, builds the desktop application
+and analyzer in Release, and launches a new traced child process for each page. Each process
+runs 180 scrolling warm-up frames, performs a blocking compacting full collection, measures
+600 scrolling frames, performs another compacting collection, writes its metrics, and exits.
+This avoids navigation order, previously loaded fonts, and retained driver resources
+contaminating another page's result.
+
+The benchmark and analyzer record:
+
+- exact process-wide managed allocation from `GC.GetTotalAllocatedBytes`, retained managed
+ bytes, GC heap/committed/fragmented bytes, generations, collection counts, pause duration,
+ pinned objects, and finalization state;
+- working set and virtual memory plus macOS `proc_pid_rusage` resident, wired, current physical
+ footprint, and lifetime maximum physical footprint;
+- explicit glyph, color-glyph, and path texture residency, staging capacity, outline buffers,
+ atlas entries/generations/evictions, upload writes, raster batches, and compute passes;
+- compositor, upload, render, host-update, layout, animation, acquire, present, frame-budget,
+ and scene-cache measurements;
+- EventPipe CPU samples and randomized allocation samples separated into startup, warm-up,
+ and measurement phases by `ProGPU-SampleBenchmark` EventSource markers.
+
+Randomized allocation events are converted to probability-weighted estimates using the .NET
+runtime's documented `size / (1 - q^size)` estimator with `p = 1 / 102400` and `q = 1 - p`.
+The exact allocation/frame counter is the comparison metric; sampled types and stacks are
+used for attribution because a small number of samples has wide statistical error. The
+profiler retains JSON and logs for every page and can optionally retain `.nettrace` files.
+
+Run it with:
+
+```bash
+PROGPU_MEMORY_KEEP_TRACES=0 tools/profile-sample-memory.sh artifacts/sample-memory-profile
+```
+
+Use `PROGPU_MEMORY_PAGE_FILTER` for a regular-expression page subset and
+`PROGPU_MEMORY_WARMUP_FRAMES` / `PROGPU_MEMORY_MEASURE_FRAMES` to change the workload.
+
+## Attribution and implementation
+
+The baseline GC dumps for the three largest pages showed that `OpenFontSharp` whole-font
+CFF and OpenType layout object graphs retained glyph objects and decoded arrays for fonts
+whose pages had requested only a small set of glyphs. Allocation stacks on Text & Documents
+also showed a second shaping/layout pass during rendering and per-realization rich-character
+objects. CPU samples attributed its severe frame stalls to quadratic free-rectangle pruning
+during a path-atlas recovery containing more than one thousand live entries.
+
+The implementation therefore:
+
+1. Reads GSUB/GPOS feature records and uses the existing typed raw-table shaping pipeline,
+ removing the eager whole-font layout typeface.
+2. Adds an original, clean-room CFF 1 INDEX/DICT and Type 2 evaluator. It retains compact
+ offsets and evaluates only a requested glyph, including local/global subroutines and CID
+ FDSelect/FDArray data. Deprecated `seac` and unsupported malformed programs fall back to
+ the existing parser lazily, so compatibility is preserved without paying its whole-font
+ cost on normal fonts.
+3. Reuses the already-shaped cluster advances for rich-text decoration widths instead of
+ constructing another `TextLayout`; caches immutable common shaping options; and recycles
+ positioned rich characters through a presenter-local pool capped at 4,096 objects.
+4. Keeps MaxRects recovery deterministic but compacts unaffected free regions linearly and
+ compares only newly split regions with the pruned set. Worst-case complexity remains
+ `O(F^2)`, while the measured bounded-intersection case is `O(F)` and uses one pooled
+ temporary array.
+5. Preserves the earlier page-switch solution: newly visible glyphs are demand-driven and
+ coalesced into bounded outline/uniform writes, one compute pass, and one submission per
+ batch. Coverage atlases survive navigation and grow geometrically on demand, avoiding
+ both hidden-page preloading and repeated uploads.
+6. Keeps rich-text table, selection, and text ordering in one retained command list. A
+ selection-only scratch list is created lazily on the first selection repaint, then only
+ the previous overlay range is spliced; shaped text commands and their strings retain
+ identity, while ordinary `RichTextBlock` instances pay no extra cache-object cost.
+7. Reuses the registered inheritable-property set, replaces capturing visual-state trigger
+ queries with indexed loops, and indexes rich-document block/child lists. These remove
+ closure, interface-enumerator, and repeated property-list allocations from steady layout
+ without bypassing inheritance, state evaluation, or invalidation.
+8. Makes the LOL/s stress page retain a bounded 512-element pool whose controls keep one
+ `Run` and one mutable brush. At the 500-element steady-state limit it rotates child order
+ in place instead of detaching, reparenting, rebuilding inlines, and allocating brushes.
+ The workload still updates 119,600+ labels per measured run at the same active limit.
+9. Re-sorts the live path-recovery list in place for the same four deterministic orderings
+ and reuses one placement/free-rectangle trial pair across the three heuristics. This
+ removes a `PathInfo`-heavy array copy per ordering and repeated trial buffers while
+ preserving exact fallback, same-frame retry, UV generation, and failure behavior.
+
+## Cross-engine design record
+
+This is a clean-room implementation. No external implementation source was copied, ported,
+translated, or structurally reproduced. Primary sources were used to select behavior and
+architecture; the implementation follows ProGPU's own typed retained-scene, invalidation,
+atlas-generation, and GPU ownership contracts.
+
+| Source | Concept considered | ProGPU decision |
+|---|---|---|
+| [Skia strike cache](https://skia.googlesource.com/skia/+/main/src/core/SkStrikeCache.h), [SkParagraph](https://skia.googlesource.com/skia/+/refs/heads/main/modules/skparagraph/include/Paragraph.h), [GrDrawOpAtlas](https://skia.googlesource.com/skia/+/main/src/gpu/ganesh/GrDrawOpAtlas.h) | Bounded caches, retained layout, lazy atlas residency and eviction | Adopt bounded residency and retained CPU layout; adapt it to analytic R8 WebGPU coverage and ProGPU generation-based invalidation. |
+| [DirectWrite 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), [Win2D CanvasTextLayout](https://microsoft.github.io/Win2D/WinUI3/html/T_Microsoft_Graphics_Canvas_Text_CanvasTextLayout.htm) | Reusable glyph runs/layout and alpha coverage | Keep shaping/layout as reusable CPU results and upload compact coverage only when visible. |
+| [WebRender overview](https://searchfox.org/firefox-main/source/gfx/docs/RenderingOverview.rst), [blob images](https://searchfox.org/firefox-main/source/gfx/wr/webrender/doc/blob.md) | Visible-resource preparation and delayed texture-cache upload | Batch only current-scene misses before drawing; retain valid atlas entries across page switches. |
+| [Vello glyph design](https://github.com/linebender/vello/issues/204), [Parley](https://docs.rs/parley/latest/parley/) | GPU vector work paired with reusable CPU shaping/layout | Preserve CPU Unicode/OpenType shaping and GPU raster/composition; reject incomplete GPU-only shaping. |
+| [HarfBuzz shaping-plan caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | Cache stable face/script/language/feature decisions | Reuse immutable common shaping options and raw table plans; avoid rebuilding parsed font graphs. |
+| [FreeType glyph retrieval](https://freetype.org/freetype2/docs/reference/ft2-glyph_retrieval.html) | Load one requested glyph into a replaceable slot | Adopt the observable demand-loading behavior, implemented independently from the [CFF table specification](https://learn.microsoft.com/en-us/typography/opentype/spec/cff) and [Adobe Type 2 Charstring specification](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5177.Type2.pdf). |
+| [.NET randomized allocation sampling](https://github.com/dotnet/runtime/blob/main/docs/design/features/RandomizedAllocationSampling.md), [dotnet-trace](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace), [`GC.GetGCMemoryInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.gc.getgcmemoryinfo?view=net-10.0) | Phase markers, randomized allocation attribution, and GC/process counters | Combine exact counters with sampled types/stacks; never treat a low-sample allocation estimate as an exact total. |
+
+Rejected alternatives were eager parsing of every glyph and layout table, prewarming every
+hidden sample page, an unbounded exact-position atlas key, lowering raster quality, or moving
+Unicode/OpenType shaping to an incomplete GPU implementation. Those options either increase
+retained memory, recreate page-switch upload spikes, break the bounded cache contract, or
+reduce correctness and quality.
+
+## Complete 47-page comparison
+
+All values below are from the same 180-frame warm-up and 600-frame measured Release workload.
+Allocation is exact process-wide managed allocation per measured frame. Small differences
+below roughly 0.05 ms are generally timer/driver noise; the frame-budget count and full-suite
+average are the regression gates.
+
+| Page | Managed before | Managed after | Change | Alloc KiB/frame before | Alloc KiB/frame after | Compile ms before | Compile ms after |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| 3D Mesh Viewer | 52.30 | 22.51 | -57.0% | 19.1 | 1.1 | 0.056 | 0.017 |
+| Advanced Controls | 50.61 | 20.82 | -58.9% | 12.1 | 0.9 | 0.042 | 0.013 |
+| Basic Input | 50.03 | 20.25 | -59.5% | 10.9 | 0.9 | 0.038 | 0.016 |
+| Color Picker | 50.50 | 20.55 | -59.3% | 7.8 | 0.9 | 0.010 | 0.011 |
+| Compositor API | 50.85 | 21.15 | -58.4% | 14.6 | 2.8 | 0.744 | 0.378 |
+| Compute FX | 50.88 | 20.86 | -59.0% | 41.4 | 29.7 | 0.019 | 0.008 |
+| DXF CAD Viewer | 51.97 | 22.04 | -57.6% | 16.3 | 1.1 | 0.028 | 0.013 |
+| Data Virtualization | 54.10 | 24.07 | -55.5% | 37.8 | 27.9 | 1.146 | 1.018 |
+| Dock Panel | 49.39 | 19.47 | -60.6% | 6.8 | 0.9 | 0.008 | 0.010 |
+| Drawing Context | 49.45 | 19.43 | -60.7% | 11.0 | 0.9 | 0.038 | 0.010 |
+| File Storage | 49.61 | 19.69 | -60.3% | 11.9 | 0.9 | 0.042 | 0.011 |
+| Font Glyph Browser | 60.85 | 31.26 | -48.6% | 104.9 | 37.4 | 2.491 | 0.732 |
+| Framework Effects | 51.05 | 21.30 | -58.3% | 10.2 | 1.6 | 0.351 | 0.235 |
+| GDI Shim Showcase | 51.06 | 21.10 | -58.7% | 10.8 | 1.2 | 0.034 | 0.017 |
+| GPU Charting | 97.22 | 68.72 | -29.3% | 18.2 | 3.6 | 0.021 | 0.022 |
+| Glyph Run Showcase | 50.40 | 20.44 | -59.5% | 10.3 | 1.9 | 0.299 | 0.309 |
+| Grid Splitter | 49.48 | 19.51 | -60.6% | 7.2 | 0.9 | 0.012 | 0.010 |
+| Image & Buttons | 50.87 | 21.04 | -58.6% | 40.8 | 29.7 | 0.023 | 0.007 |
+| Image Effects | 50.70 | 20.69 | -59.2% | 37.6 | 29.8 | 0.013 | 0.009 |
+| Inter Typeface | 99.93 | 53.09 | -46.9% | 36.3 | 2.9 | 0.500 | 0.548 |
+| Interactive Input | 60.09 | 21.80 | -63.7% | 7.9 | 0.9 | 0.011 | 0.013 |
+| Keyboard & Focus | 52.60 | 23.09 | -56.1% | 15.5 | 1.0 | 0.037 | 0.017 |
+| LOL/s Benchmark | 65.83 | 36.80 | -44.1% | 1994.6 | 28.9 | 2.999 | 1.881 |
+| Layout Panels | 50.51 | 20.87 | -58.7% | 12.4 | 1.0 | 0.026 | 0.014 |
+| Markdown Playground | 89.72 | 35.08 | -60.9% | 26.6 | 2.2 | 1.131 | 0.929 |
+| Motion & Animations | 50.40 | 20.66 | -59.0% | 20.4 | 7.8 | 0.603 | 0.342 |
+| MotionMark Showcase | 59.40 | 30.55 | -48.6% | 81.8 | 14.8 | 1.123 | 0.149 |
+| Password Box | 50.42 | 20.63 | -59.1% | 8.1 | 1.0 | 0.016 | 0.012 |
+| Path Operations | 49.96 | 19.99 | -60.0% | 8.1 | 0.9 | 0.021 | 0.013 |
+| Picture Caching | 51.18 | 21.26 | -58.5% | 21.7 | 6.0 | 0.802 | 0.352 |
+| Radio Button | 50.43 | 20.65 | -59.1% | 12.0 | 0.9 | 0.036 | 0.011 |
+| Rating Control | 50.28 | 20.49 | -59.2% | 8.0 | 1.0 | 0.020 | 0.013 |
+| Rich Document Editor | 50.32 | 20.35 | -59.6% | 12.8 | 0.9 | 0.032 | 0.013 |
+| ShaderToy Playground | 51.48 | 21.48 | -58.3% | 148.0 | 25.6 | 0.707 | 0.510 |
+| SkiaSharp Shim | 51.80 | 21.71 | -58.1% | 5.9 | 0.9 | 0.009 | 0.010 |
+| SplitView Layout | 50.14 | 20.32 | -59.5% | 13.0 | 0.9 | 0.033 | 0.011 |
+| Styles Showcase | 49.41 | 19.52 | -60.5% | 7.3 | 1.0 | 0.028 | 0.009 |
+| Text & Documents | 472.28 | 189.27 | -59.9% | 4128.4 | 189.3 | 41.728 | 2.037 |
+| Text Shaping Lab | 513.91 | 70.07 | -86.4% | 22.8 | 0.9 | 0.082 | 0.036 |
+| Theme Showcase | 52.77 | 23.00 | -56.4% | 15.1 | 1.1 | 0.043 | 0.013 |
+| Touch & Gestures | 50.47 | 20.95 | -58.5% | 12.7 | 1.0 | 0.042 | 0.014 |
+| Typography & Scripts | 345.18 | 82.16 | -76.2% | 10.2 | 0.9 | 0.036 | 0.017 |
+| Vector Shapes | 50.67 | 20.74 | -59.1% | 7.5 | 1.0 | 0.013 | 0.011 |
+| Virtualization Controls | 55.09 | 26.69 | -51.5% | 22.9 | 1.1 | 0.016 | 0.018 |
+| Visual Designer | 81.26 | 46.57 | -42.7% | 129.5 | 95.3 | 1.906 | 1.872 |
+| WPF Shim Showcase | 57.22 | 21.27 | -62.8% | 13.9 | 1.0 | 0.031 | 0.011 |
+| Wrap Panel | 49.78 | 19.79 | -60.2% | 9.2 | 0.9 | 0.014 | 0.012 |
+
+Every page allocated less than its original isolated baseline. A few sub-millisecond compile
+measurements moved upward: Inter Typeface by 0.048 ms, Glyph Run Showcase by 0.010 ms, and
+GPU Charting by 0.001 ms. They remained below 0.55 ms with no over-budget compile frames;
+these small single-run movements are retained in the table rather than hidden by aggregate
+reporting. Compute FX, Image Effects, and Image & Buttons continue to allocate about
+30 KiB/frame for their intentionally changing effect/image workloads.
+
+## Verification
+
+- `ProGPU.Tests` Release: **2,277 passed**, 0 failed.
+- `ProGPU.Tests.Headless` Release: **196 passed**, 0 failed.
+- Path-atlas focused tests: **29 passed**, including recovery, generation, and small-atlas
+ stress coverage.
+- CFF differential tests compare independently decoded `A`, `日`, `か`, and `ナ` geometry
+ against the established fallback to 0.001 font units; on-demand tests confirm the fallback
+ object graph is not created.
+- All 47 pages completed the fresh-process Release desktop profile.
+- Headless screenshots for Text Shaping Lab, Typography & Scripts, and Text & Documents were
+ visually inspected with unchanged text coverage and quality.
+- Browser Release publish AOT-compiled 69 assemblies; the WebGPU gallery opened under a real
+ browser, rendered the 3D and rich-text pages, and reported zero console errors or warnings
+ beyond expected system-font fallback messages.
+- Quality-sensitive 8x8 analytic coverage, physical-DPI raster size, quarter-physical-pixel
+ snapping, direction-aware half-open winding, color glyphs, fallback/variation identity,
+ and same-frame atlas recovery were not reduced or bypassed.
diff --git a/docs/WINDOW_RESIZE_PERFORMANCE.md b/docs/WINDOW_RESIZE_PERFORMANCE.md
new file mode 100644
index 00000000..af3e39ea
--- /dev/null
+++ b/docs/WINDOW_RESIZE_PERFORMANCE.md
@@ -0,0 +1,92 @@
+# Window resize performance
+
+## Outcome
+
+The desktop resize path now keeps synchronous live-resize repainting without drawing the same
+size again when native event dispatch returns. Cocoa can hold the normal Silk render loop while
+the user drags a window edge, so each framebuffer callback renders the latest physical size
+immediately. The following scheduled render is suppressed; the next application update then
+continues normally. This preserves continuous repainting while removing the duplicate frame.
+
+The accompanying retained multi-column text path separates width-independent shaping inputs
+from width-dependent line breaking. It retains those inputs per document block, recycles
+positioned-character objects, represents candidate lines as index ranges instead of copying
+`RichChar` structs, and skips a duplicate arrange layout when measure already used the final
+size. Rasterization, DPI/subpixel snapping, glyph/path atlas keys, and shaders are unchanged.
+
+## Matched Release measurements
+
+Measurements use the `Text & Documents` sample, 120 warm-up frames, 300 measured frames,
+VSync enabled, a repeated 800x600 to 1280x800 resize sweep, and memory counters enabled on
+macOS/Metal. Both runs used the same final Release application. CPU allocation attribution
+used EventPipe randomized allocation sampling; exact allocation and GC values come from the
+runtime counters. Metal System Trace was captured for both runs.
+
+| Metric | Before | After | Change |
+|---|---:|---:|---:|
+| Managed allocation / measured frame | 1,761,326 B | 422,345 B | **-76.0%** |
+| Gen0 collections | 58 | 15 | **-74.1%** |
+| GC pause | 36.53 ms | 10.69 ms | **-70.7%** |
+| Live-resize callback average | 8.0714 ms | 7.5323 ms | **-6.7%** |
+| Live-resize callback maximum | 52.8272 ms | 41.6815 ms | **-21.1%** |
+| Physical footprint | 496.19 MB | 463.46 MB | **-6.6%** |
+| Lifetime max physical footprint | 554.60 MB | 517.82 MB | **-6.6%** |
+
+The old measurement contained 150 framebuffer callbacks and 149 callback frames among 300
+reported frames, followed by a second scheduled render for the same resize. The corrected path
+rendered all 299 measured framebuffer callbacks and suppressed all 299 redundant scheduled
+frames. It sustained 116.15 visible frames/second under the deterministic workload while
+keeping pixels current throughout live resize.
+
+GPU residency and quality-sensitive work remained stable: the glyph atlas stayed at
+1024x2560, the color atlas at 256x512, the path atlas at 512x2048 with 134 entries, and the
+post-trace recorded 58 glyph-outline writes and 47 compute batches. No atlas clear, eviction,
+shader, raster scale, or subpixel policy changed. Focused rich-text, bidi, flow-direction,
+and table tests cover the line-range refactor; a new regression verifies that identical
+measure/arrange sizes perform one multi-column layout. The browser Release AOT host is also
+resized through multiple viewport sizes to verify its independent canvas configuration path.
+
+## Cross-engine design record
+
+This is a clean-room implementation. External sources informed architecture and observable
+contracts only; no source was copied, translated, or structurally reproduced.
+
+| Engine / primary source | Relevant production behavior | ProGPU decision |
+|---|---|---|
+| [Vello winit example](https://github.com/linebender/vello/blob/main/examples/with_winit/src/lib.rs) | `Resized` updates the surface and requests redraw; actual rendering remains in `RedrawRequested`. Outdated/suboptimal surfaces are reconfigured and redrawn. | Adopt one draw per resize and latest-size rendering. Adapt dispatch to Cocoa/Silk, where the callback must repaint synchronously during the native live-resize loop, then suppress the duplicate scheduled draw. |
+| [Direct2D HWND render target](https://learn.microsoft.com/en-us/windows/win32/api/d2d1/nn-d2d1-id2d1hwndrendertarget), [Direct2D quickstart](https://learn.microsoft.com/en-us/windows/win32/direct2d/direct2d-quickstart), [DXGI `ResizeBuffers`](https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiswapchain-resizebuffers) | Retain the render target, resize its backing buffers for the new client size, and draw through the platform paint path after releasing outstanding references. | Retain device/pipelines/atlases and configure only the latest physical size; map the single paint to each platform's live-resize dispatch contract. |
+| [Win2D without controls](https://microsoft.github.io/Win2D/WinUI3/html/WithoutControls.htm), [CanvasSwapChain.ResizeBuffers](https://microsoft.github.io/Win2D/WinUI3/html/M_Microsoft_Graphics_Canvas_CanvasSwapChain_ResizeBuffers_2.htm) | Size changes update swapchain buffers while draw/present remain distinct operations. | Keep surface configuration in the render pipeline and prevent a second scheduled present for the same callback frame. |
+| [WebRender rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html), [Firefox vsync pipeline](https://firefox-source-docs.mozilla.org/gfx/Silk.html) | A retained scene is culled into frames, caches localize changed work, and compositor/painting are aligned to hardware vsync. | Preserve compiled-scene and atlas reuse and align resize work with the normal display tick. Full damage tiling is outside this fix. |
+| [Skia shaped-text design](https://docs.skia.org/docs/dev/design/text_shaper/), [SkParagraph API](https://skia.googlesource.com/skia/+/refs/heads/main/modules/skparagraph/include/Paragraph.h) | Shaping and width-dependent formatting are separable retained stages; paragraph objects can be laid out again at a new width. | Retain block shaping inputs and redo only line/column placement when width changes. |
+| [DirectWrite `IDWriteTextLayout`](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetextlayout), [`SetMaxWidth`](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwritetextlayout-setmaxwidth) | A fully analyzed/formatted text block is retained while layout constraints can change. | Adapt the retained-layout contract per presenter without sharing viewport coordinates between controls. |
+| [Parley layout](https://docs.rs/parley/latest/parley/), [Parley retained layout data](https://docs.rs/parley/latest/src/parley/layout/data.rs.html) | Shaped runs/clusters/glyphs are stored separately from lines; re-linebreaking and alignment do not require reconstructing text styles. | Retain block character/font/style resolution and paragraph metrics; compute new column breaks from index ranges. |
+| [HarfBuzz shaping plans and caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | Stable face/script/language/feature work is reusable, while shaping results still obey Unicode/OpenType context. | Preserve CPU shaping and line-boundary reshaping for correctness; reject a GPU-only shaper or width-keyed global cache. |
+
+Across these engines, startup remains lazy, shaping/layout and retained scene data survive
+unrelated frames, visibility controls demand, uploads are demand-driven, and GPU work is
+batched at the render boundary. ProGPU keeps those existing contracts. Surface capability
+selection is now cached for a surface lifetime; a lost/outdated surface explicitly refreshes
+capabilities. Font fallback, variable-font instances, DPI/subpixel snapping, atlas generation,
+and device-loss invalidation remain part of their existing typed cache keys.
+
+Rejected alternatives were deferring every repaint until a normal tick (which freezes Cocoa
+live resize), drawing both synchronously and again on the following scheduled tick, adding a
+timer debounce that would lag behind the pointer, drawing a lower-resolution stretched frame,
+dropping text features, lowering coverage quality, reshaping unchanged text globally, or
+pre-uploading hidden content. Each either increases latency/memory or violates quality and
+invalidation contracts.
+
+## Reproduction
+
+```bash
+PROGPU_SAMPLE_BENCHMARK_PAGE='Text & Documents' \
+PROGPU_SAMPLE_BENCHMARK_WARMUP_FRAMES=120 \
+PROGPU_SAMPLE_BENCHMARK_MEASURE_FRAMES=300 \
+PROGPU_SAMPLE_BENCHMARK_RESIZE=true \
+PROGPU_SAMPLE_BENCHMARK_MEMORY=true \
+PROGPU_SAMPLE_BENCHMARK_VSYNC=true \
+src/ProGPU.Samples.Desktop/bin/Release/net10.0/ProGPU.Samples.Desktop
+```
+
+Set `PROGPU_SAMPLE_BENCHMARK_RESIZE_MIN_WIDTH`, `_MIN_HEIGHT`, `_MAX_WIDTH`,
+`_MAX_HEIGHT`, and `_STEP` to change the deterministic sweep.
diff --git a/src/ProGPU.Backend/GpuCoverageUpload.cs b/src/ProGPU.Backend/GpuCoverageUpload.cs
new file mode 100644
index 00000000..3dc83d1d
--- /dev/null
+++ b/src/ProGPU.Backend/GpuCoverageUpload.cs
@@ -0,0 +1,96 @@
+using Silk.NET.WebGPU;
+
+namespace ProGPU.Backend;
+
+///
+/// Records copies from GPU-computed, tightly packed coverage bytes into a filterable R8 atlas.
+/// Rasterization remains GPU-only while avoiding the four-channel storage-texture footprint.
+///
+public static unsafe class GpuCoverageUpload
+{
+ public const uint CopyRowAlignment = 256;
+
+ public static uint GetBytesPerRow(uint width)
+ {
+ if (width == 0)
+ {
+ return 0;
+ }
+
+ return checked((width + CopyRowAlignment - 1) & ~(CopyRowAlignment - 1));
+ }
+
+ public static uint GetRequiredBytes(uint width, uint height) =>
+ checked(GetBytesPerRow(width) * height);
+
+ public static void RecordCopy(
+ WgpuContext context,
+ CommandEncoder* encoder,
+ GpuBuffer source,
+ uint sourceOffset,
+ uint bytesPerRow,
+ GpuTexture destination,
+ uint destinationX,
+ uint destinationY,
+ uint width,
+ uint height)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(destination);
+ if (encoder == null)
+ {
+ throw new ArgumentNullException(nameof(encoder));
+ }
+ if (destination.Format != TextureFormat.R8Unorm)
+ {
+ throw new ArgumentException("Coverage copies require an R8Unorm destination.", nameof(destination));
+ }
+ if (!source.Usage.HasFlag(BufferUsage.CopySrc))
+ {
+ throw new ArgumentException("Coverage staging buffers require CopySrc usage.", nameof(source));
+ }
+ if (width == 0 || height == 0)
+ {
+ return;
+ }
+ if (bytesPerRow < width || bytesPerRow % CopyRowAlignment != 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(bytesPerRow));
+ }
+
+ var copySource = new ImageCopyBuffer
+ {
+ Buffer = source.BufferPtr,
+ Layout = new TextureDataLayout
+ {
+ Offset = sourceOffset,
+ BytesPerRow = bytesPerRow,
+ RowsPerImage = height
+ }
+ };
+ var copyDestination = new ImageCopyTexture
+ {
+ Texture = destination.TexturePtr,
+ MipLevel = 0,
+ Origin = new Origin3D
+ {
+ X = destinationX,
+ Y = destinationY,
+ Z = 0
+ },
+ Aspect = TextureAspect.All
+ };
+ var copySize = new Extent3D
+ {
+ Width = width,
+ Height = height,
+ DepthOrArrayLayers = 1
+ };
+ context.Api.CommandEncoderCopyBufferToTexture(
+ encoder,
+ ©Source,
+ ©Destination,
+ ©Size);
+ }
+}
diff --git a/src/ProGPU.Backend/GpuTexture.cs b/src/ProGPU.Backend/GpuTexture.cs
index bf1dc56f..776679c5 100644
--- a/src/ProGPU.Backend/GpuTexture.cs
+++ b/src/ProGPU.Backend/GpuTexture.cs
@@ -1201,6 +1201,93 @@ public void CopyBaseLevelFrom(GpuTexture source)
Generation++;
}
+ ///
+ /// Copies a top-left base-level region from a compatible texture. This is used
+ /// by demand-grown atlases to preserve resident texels without a CPU readback.
+ ///
+ public void CopyBaseLevelRegionFrom(GpuTexture source, uint width, uint height)
+ {
+ if (IsDisposed) throw new ObjectDisposedException(nameof(GpuTexture));
+ ArgumentNullException.ThrowIfNull(source);
+ if (source.IsDisposed) throw new ObjectDisposedException(nameof(GpuTexture));
+ if (!ReferenceEquals(source.Context, _context))
+ {
+ throw new ArgumentException(
+ "Source texture must belong to the same WebGPU context.",
+ nameof(source));
+ }
+ if (source.DepthOrArrayLayers != 1 || DepthOrArrayLayers != 1 ||
+ source.Dimension != GpuTextureDimension.Dimension2D ||
+ Dimension != GpuTextureDimension.Dimension2D ||
+ source.Format != Format || source.SampleCount != SampleCount)
+ {
+ throw new ArgumentException(
+ "Source and destination must be compatible single-layer 2D textures.",
+ nameof(source));
+ }
+ if (width == 0 || width > source.Width || width > Width)
+ {
+ throw new ArgumentOutOfRangeException(nameof(width));
+ }
+ if (height == 0 || height > source.Height || height > Height)
+ {
+ throw new ArgumentOutOfRangeException(nameof(height));
+ }
+ if (!source.Usage.HasFlag(TextureUsage.CopySrc))
+ {
+ throw new InvalidOperationException("Source texture was not created with CopySrc usage.");
+ }
+ if (!Usage.HasFlag(TextureUsage.CopyDst))
+ {
+ throw new InvalidOperationException("Destination texture was not created with CopyDst usage.");
+ }
+
+ var encoderDescriptor = new CommandEncoderDescriptor();
+ var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDescriptor);
+ if (encoder == null)
+ {
+ throw new InvalidOperationException(
+ "Failed to create a command encoder for the regional texture copy.");
+ }
+
+ var copySource = new ImageCopyTexture
+ {
+ Texture = source.TexturePtr,
+ MipLevel = 0,
+ Origin = new Origin3D(),
+ Aspect = GetTextureCopyAspect(source.Format)
+ };
+ var copyDestination = new ImageCopyTexture
+ {
+ Texture = TexturePtr,
+ MipLevel = 0,
+ Origin = new Origin3D(),
+ Aspect = GetTextureCopyAspect(Format)
+ };
+ var copySize = new Extent3D
+ {
+ Width = width,
+ Height = height,
+ DepthOrArrayLayers = 1
+ };
+ _context.Api.CommandEncoderCopyTextureToTexture(
+ encoder,
+ ©Source,
+ ©Destination,
+ ©Size);
+
+ var commandBufferDescriptor = new CommandBufferDescriptor();
+ var commandBuffer = _context.Api.CommandEncoderFinish(
+ encoder,
+ &commandBufferDescriptor);
+ _context.Api.QueueSubmit(_context.Queue, 1, &commandBuffer);
+ _context.Api.CommandBufferRelease(commandBuffer);
+ _context.Api.CommandEncoderRelease(encoder);
+
+ AlphaMode = source.AlphaMode;
+ Generation++;
+ }
+
private void EnsurePbgra32CompatibleFormat()
{
if (Format is not (TextureFormat.Bgra8Unorm or TextureFormat.Bgra8UnormSrgb))
diff --git a/src/ProGPU.Backend/IWebGpuApi.cs b/src/ProGPU.Backend/IWebGpuApi.cs
index ae480268..e95d69f9 100644
--- a/src/ProGPU.Backend/IWebGpuApi.cs
+++ b/src/ProGPU.Backend/IWebGpuApi.cs
@@ -29,6 +29,7 @@ public unsafe interface IWebGpuApi
ComputePassEncoder* CommandEncoderBeginComputePass(CommandEncoder* commandEncoder, ComputePassDescriptor* descriptor);
RenderPassEncoder* CommandEncoderBeginRenderPass(CommandEncoder* commandEncoder, RenderPassDescriptor* descriptor);
void CommandEncoderCopyBufferToBuffer(CommandEncoder* commandEncoder, WgpuBuffer* source, ulong sourceOffset, WgpuBuffer* destination, ulong destinationOffset, ulong size);
+ void CommandEncoderCopyBufferToTexture(CommandEncoder* commandEncoder, ImageCopyBuffer* source, ImageCopyTexture* destination, Extent3D* copySize);
void CommandEncoderCopyTextureToBuffer(CommandEncoder* commandEncoder, ImageCopyTexture* source, ImageCopyBuffer* destination, Extent3D* copySize);
void CommandEncoderCopyTextureToTexture(CommandEncoder* commandEncoder, ImageCopyTexture* source, ImageCopyTexture* destination, Extent3D* copySize);
CommandBuffer* CommandEncoderFinish(CommandEncoder* commandEncoder, CommandBufferDescriptor* descriptor);
@@ -118,6 +119,7 @@ private static void CompleteBufferMap(BufferMapAsyncStatus status, void* userDat
public ComputePassEncoder* CommandEncoderBeginComputePass(CommandEncoder* e, ComputePassDescriptor* x) => api.CommandEncoderBeginComputePass(e, x);
public RenderPassEncoder* CommandEncoderBeginRenderPass(CommandEncoder* e, RenderPassDescriptor* x) => api.CommandEncoderBeginRenderPass(e, x);
public void CommandEncoderCopyBufferToBuffer(CommandEncoder* e, WgpuBuffer* s, ulong so, WgpuBuffer* d, ulong @do, ulong z) => api.CommandEncoderCopyBufferToBuffer(e, s, so, d, @do, z);
+ public void CommandEncoderCopyBufferToTexture(CommandEncoder* e, ImageCopyBuffer* s, ImageCopyTexture* d, Extent3D* z) => api.CommandEncoderCopyBufferToTexture(e, s, d, z);
public void CommandEncoderCopyTextureToBuffer(CommandEncoder* e, ImageCopyTexture* s, ImageCopyBuffer* d, Extent3D* z) => api.CommandEncoderCopyTextureToBuffer(e, s, d, z);
public void CommandEncoderCopyTextureToTexture(CommandEncoder* e, ImageCopyTexture* s, ImageCopyTexture* d, Extent3D* z) => api.CommandEncoderCopyTextureToTexture(e, s, d, z);
public CommandBuffer* CommandEncoderFinish(CommandEncoder* e, CommandBufferDescriptor* x) => api.CommandEncoderFinish(e, x);
diff --git a/src/ProGPU.Backend/ProGPU.Backend.csproj b/src/ProGPU.Backend/ProGPU.Backend.csproj
index 9c50622e..4541494c 100644
--- a/src/ProGPU.Backend/ProGPU.Backend.csproj
+++ b/src/ProGPU.Backend/ProGPU.Backend.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/ProGPU.Backend/Shaders/GlyphRasterizer.wgsl b/src/ProGPU.Backend/Shaders/GlyphRasterizer.wgsl
index f6265075..15fe6429 100644
--- a/src/ProGPU.Backend/Shaders/GlyphRasterizer.wgsl
+++ b/src/ProGPU.Backend/Shaders/GlyphRasterizer.wgsl
@@ -1,13 +1,13 @@
// Algorithm: Rasterize glyph coverage with 8x8 supersampling, sharing analytic line, quadratic, and cubic winding intersections across each eight-sample row.
// Time complexity: O(R*S + A) per texel for R=8 sample rows, A=64 anti-aliasing samples, and S outline segments.
-// Space complexity: O(R) private winding storage plus O(S) read-only segment bandwidth and one rgba8unorm output write per texel.
+// Space complexity: O(R) private winding storage plus O(S) read-only segment bandwidth and one packed u32 output write per four R8 texels.
struct GlyphUniforms {
xStart: f32,
yStart: f32,
scale: f32,
glyphIndex: u32,
- atlasX: u32,
- atlasY: u32,
+ outputOffsetWords: u32,
+ outputRowWords: u32,
width: u32,
height: u32,
subpixelX: f32,
@@ -41,7 +41,7 @@ struct Segment {
@group(0) @binding(0) var uniforms: GlyphUniforms;
@group(0) @binding(1) var glyphRecords: array;
@group(0) @binding(2) var segments: array;
-@group(0) @binding(3) var atlasTexture: texture_storage_2d;
+@group(0) @binding(3) var coverageOutput: array;
fn solve_quadratic(a: f32, b: f32, c: f32, roots: ptr>, root_count: ptr) {
@@ -269,15 +269,7 @@ fn accumulate_winding_row(
}
}
-@compute @workgroup_size(16, 16)
-fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
- let x = global_id.x;
- let y = global_id.y;
-
- if (x >= uniforms.width || y >= uniforms.height) {
- return;
- }
-
+fn glyph_coverage_byte(x: u32, y: u32) -> u32 {
let glyphIndex = uniforms.glyphIndex;
let record = glyphRecords[glyphIndex];
@@ -305,7 +297,27 @@ fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
}
}
- let coverage = f32(covered_samples) * 0.015625;
- let writeCoord = vec2(uniforms.atlasX + x, uniforms.atlasY + y);
- textureStore(atlasTexture, writeCoord, vec4(coverage, 0.0, 0.0, 0.0));
+ return min(255u, u32(round(f32(covered_samples) * 3.984375)));
+}
+
+// Four adjacent pixels are packed by one invocation so the storage buffer has
+// the exact byte layout required by WebGPU copyBufferToTexture for R8Unorm.
+@compute @workgroup_size(16, 16)
+fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
+ let wordX = global_id.x;
+ let y = global_id.y;
+ let firstX = wordX * 4u;
+ if (firstX >= uniforms.width || y >= uniforms.height) {
+ return;
+ }
+
+ var packed = 0u;
+ for (var lane = 0u; lane < 4u; lane = lane + 1u) {
+ let x = firstX + lane;
+ if (x < uniforms.width) {
+ packed = packed | (glyph_coverage_byte(x, y) << (lane * 8u));
+ }
+ }
+
+ coverageOutput[uniforms.outputOffsetWords + y * uniforms.outputRowWords + wordX] = packed;
}
diff --git a/src/ProGPU.Backend/Shaders/PathRasterizer.wgsl b/src/ProGPU.Backend/Shaders/PathRasterizer.wgsl
index 29731d8e..d10f7f48 100644
--- a/src/ProGPU.Backend/Shaders/PathRasterizer.wgsl
+++ b/src/ProGPU.Backend/Shaders/PathRasterizer.wgsl
@@ -1,14 +1,14 @@
// Algorithm: Rasterize arbitrary path coverage by supersampling each atlas texel and applying analytic non-zero or even-odd winding tests.
// Time complexity: O(A*S) per texel for A anti-aliasing samples and S path segments.
-// Space complexity: O(1) local storage plus O(S) read-only segment bandwidth.
+// Space complexity: O(1) local storage plus O(S) read-only segment bandwidth and one packed u32 output write per four R8 texels.
struct PathUniforms {
xStart: f32,
yStart: f32,
scaleX: f32,
scaleY: f32,
pathIndex: u32,
- atlasX: u32,
- atlasY: u32,
+ outputOffsetWords: u32,
+ outputRowWords: u32,
width: u32,
height: u32,
sampleGrid: u32,
@@ -41,7 +41,7 @@ struct Segment {
@group(0) @binding(0) var pathUniforms: array;
@group(0) @binding(1) var pathRecords: array;
@group(0) @binding(2) var segments: array;
-@group(0) @binding(3) var atlasTexture: texture_storage_2d;
+@group(0) @binding(3) var coverageOutput: array;
fn solve_quadratic(a: f32, b: f32, c: f32, roots: ptr>, root_count: ptr) {
@@ -341,16 +341,7 @@ fn count_row_coverage(
return covered;
}
-@compute @workgroup_size(16, 16)
-fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
- let uniforms = pathUniforms[global_id.z];
- let x = global_id.x;
- let y = global_id.y;
-
- if (x >= uniforms.width || y >= uniforms.height) {
- return;
- }
-
+fn path_coverage_byte(x: u32, y: u32, uniforms: PathUniforms) -> u32 {
let pathIndex = uniforms.pathIndex;
let record = pathRecords[pathIndex];
@@ -369,8 +360,28 @@ fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
uniforms.scaleX,
record);
}
- let coverage = f32(coveredSamples) * sampleWeight;
+ return min(255u, u32(round(f32(coveredSamples) * sampleWeight * 255.0)));
+}
+
+// Four adjacent pixels are packed by one invocation so the storage buffer has
+// the exact byte layout required by WebGPU copyBufferToTexture for R8Unorm.
+@compute @workgroup_size(16, 16)
+fn cs_main(@builtin(global_invocation_id) global_id: vec3) {
+ let uniforms = pathUniforms[global_id.z];
+ let wordX = global_id.x;
+ let y = global_id.y;
+ let firstX = wordX * 4u;
+ if (firstX >= uniforms.width || y >= uniforms.height) {
+ return;
+ }
+
+ var packed = 0u;
+ for (var lane = 0u; lane < 4u; lane = lane + 1u) {
+ let x = firstX + lane;
+ if (x < uniforms.width) {
+ packed = packed | (path_coverage_byte(x, y, uniforms) << (lane * 8u));
+ }
+ }
- let writeCoord = vec2(uniforms.atlasX + x, uniforms.atlasY + y);
- textureStore(atlasTexture, writeCoord, vec4(coverage, 0.0, 0.0, 0.0));
+ coverageOutput[uniforms.outputOffsetWords + y * uniforms.outputRowWords + wordX] = packed;
}
diff --git a/src/ProGPU.Backend/Shaders/Text.wgsl b/src/ProGPU.Backend/Shaders/Text.wgsl
index f2d16eba..26691b01 100644
--- a/src/ProGPU.Backend/Shaders/Text.wgsl
+++ b/src/ProGPU.Backend/Shaders/Text.wgsl
@@ -1,6 +1,6 @@
// Algorithm: Transform glyph quads and modulate premultiplied text color by glyph-atlas coverage.
// Time complexity: O(1) per vertex and fragment.
-// Space complexity: O(1) local storage with one atlas sample per fragment; masked variants add one mask sample and ClearType adds two atlas samples.
+// Space complexity: O(1) local storage with one coverage-or-color atlas sample per fragment; masked variants add one mask sample and ClearType adds two coverage samples.
struct VertexInput {
@builtin(vertex_index) vertexIndex: u32,
@location(0) snappedLogicalPos: vec2,
@@ -113,6 +113,7 @@ fn vs_main(input: VertexInput) -> VertexOutput {
@group(1) @binding(0) var atlasSampler: sampler;
@group(1) @binding(1) var atlasTexture: texture_2d;
+@group(1) @binding(2) var colorAtlasTexture: texture_2d;
@group(2) @binding(0) var maskSampler: sampler;
@group(2) @binding(1) var maskTexture: texture_2d;
@@ -122,14 +123,20 @@ fn text_coverage_to_alpha(alpha: f32, contrast: f32, gamma: f32, aliasedText: bo
}
fn text_fs_main_with_mask_alpha(input: VertexOutput, maskAlpha: f32) -> vec4 {
- let atlasCoordDx = dpdx(input.texCoord);
- let atlasCoordDy = dpdy(input.texCoord);
- let atlasColor = textureSample(atlasTexture, atlasSampler, input.texCoord);
- let alpha = atlasColor.r;
let aliasedText = input.cornerRadius < 0.0;
+ let coverageDims = textureDimensions(atlasTexture);
+ let colorDims = textureDimensions(colorAtlasTexture);
+ let selectedDims = select(coverageDims, colorDims, input.textMode > 2.5);
+ let selectedSize = vec2(f32(selectedDims.x), f32(selectedDims.y));
+ let atlasCoord = input.texCoord / selectedSize;
+ let atlasCoordDx = dpdx(atlasCoord);
+ let atlasCoordDy = dpdy(atlasCoord);
if (input.textMode > 2.5) {
+ let atlasColor = textureSampleGrad(colorAtlasTexture, atlasSampler, atlasCoord, atlasCoordDx, atlasCoordDy);
return vec4(atlasColor.rgb, atlasColor.a * input.color.a * maskAlpha);
}
+ let atlasColor = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord, atlasCoordDx, atlasCoordDy);
+ let alpha = atlasColor.r;
let gamma = abs(input.cornerRadius);
let grayscaleAlpha = text_coverage_to_alpha(alpha, input.strokeThickness, gamma, aliasedText);
@@ -137,9 +144,9 @@ fn text_fs_main_with_mask_alpha(input: VertexOutput, maskAlpha: f32) -> vec4(f32(atlasDims.x), f32(atlasDims.y));
let subpixelOffset = vec2(1.0 / max(atlasSize.x * 3.0, 1.0), 0.0);
- let redCoverage = textureSampleGrad(atlasTexture, atlasSampler, input.texCoord - subpixelOffset, atlasCoordDx, atlasCoordDy).r;
+ let redCoverage = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord - subpixelOffset, atlasCoordDx, atlasCoordDy).r;
let greenCoverage = alpha;
- let blueCoverage = textureSampleGrad(atlasTexture, atlasSampler, input.texCoord + subpixelOffset, atlasCoordDx, atlasCoordDy).r;
+ let blueCoverage = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord + subpixelOffset, atlasCoordDx, atlasCoordDy).r;
let rgbCoverage = vec3(
text_coverage_to_alpha(redCoverage, input.strokeThickness, gamma, false),
text_coverage_to_alpha(greenCoverage, input.strokeThickness, gamma, false),
@@ -156,19 +163,25 @@ fn text_fs_main_with_mask_alpha(input: VertexOutput, maskAlpha: f32) -> vec4 vec4 {
- let atlasCoordDx = dpdx(input.texCoord);
- let atlasCoordDy = dpdy(input.texCoord);
- let atlasColor = textureSample(atlasTexture, atlasSampler, input.texCoord);
- let alpha = atlasColor.r;
let aliasedText = input.cornerRadius < 0.0;
+ let coverageDims = textureDimensions(atlasTexture);
+ let colorDims = textureDimensions(colorAtlasTexture);
+ let selectedDims = select(coverageDims, colorDims, input.textMode > 2.5);
+ let selectedSize = vec2(f32(selectedDims.x), f32(selectedDims.y));
+ let atlasCoord = input.texCoord / selectedSize;
+ let atlasCoordDx = dpdx(atlasCoord);
+ let atlasCoordDy = dpdy(atlasCoord);
let screen_uv = input.position.xy / uniforms.canvasSize;
let maskAlpha = textureSample(maskTexture, maskSampler, screen_uv).r;
if (maskAlpha <= 0.0) {
discard;
}
if (input.textMode > 2.5) {
+ let atlasColor = textureSampleGrad(colorAtlasTexture, atlasSampler, atlasCoord, atlasCoordDx, atlasCoordDy);
return vec4(atlasColor.rgb, atlasColor.a * input.color.a * maskAlpha);
}
+ let atlasColor = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord, atlasCoordDx, atlasCoordDy);
+ let alpha = atlasColor.r;
let gamma = abs(input.cornerRadius);
let grayscaleAlpha = text_coverage_to_alpha(alpha, input.strokeThickness, gamma, aliasedText);
@@ -176,9 +189,9 @@ fn text_fs_main(input: VertexOutput) -> vec4 {
let atlasDims = textureDimensions(atlasTexture);
let atlasSize = vec2(f32(atlasDims.x), f32(atlasDims.y));
let subpixelOffset = vec2(1.0 / max(atlasSize.x * 3.0, 1.0), 0.0);
- let redCoverage = textureSampleGrad(atlasTexture, atlasSampler, input.texCoord - subpixelOffset, atlasCoordDx, atlasCoordDy).r;
+ let redCoverage = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord - subpixelOffset, atlasCoordDx, atlasCoordDy).r;
let greenCoverage = alpha;
- let blueCoverage = textureSampleGrad(atlasTexture, atlasSampler, input.texCoord + subpixelOffset, atlasCoordDx, atlasCoordDy).r;
+ let blueCoverage = textureSampleGrad(atlasTexture, atlasSampler, atlasCoord + subpixelOffset, atlasCoordDx, atlasCoordDy).r;
let rgbCoverage = vec3(
text_coverage_to_alpha(redCoverage, input.strokeThickness, gamma, false),
text_coverage_to_alpha(greenCoverage, input.strokeThickness, gamma, false),
diff --git a/src/ProGPU.Backend/Shaders/Vector.wgsl b/src/ProGPU.Backend/Shaders/Vector.wgsl
index dee42e6c..56f920f5 100644
--- a/src/ProGPU.Backend/Shaders/Vector.wgsl
+++ b/src/ProGPU.Backend/Shaders/Vector.wgsl
@@ -1456,7 +1456,16 @@ fn vector_fs_main(input: VertexOutput, maskAlpha: f32) -> vec4 {
shapeAlpha = select(antialiasedAlpha, aliasedAlpha, aliasedEdge);
} else if (sType == 4u) {
// Path rendering: sample coverage directly from PathAtlas
- let coverage = textureSampleGrad(pathAtlasTexture, pathAtlasSampler, input.texCoord, atlasCoordDx, atlasCoordDy).r;
+ let pathAtlasDims = textureDimensions(pathAtlasTexture);
+ let pathAtlasSize = vec2(f32(pathAtlasDims.x), f32(pathAtlasDims.y));
+ let pathAtlasCoord = input.texCoord / pathAtlasSize;
+ // atlasCoordDx/atlasCoordDy are evaluated at fragment-function entry,
+ // before shape-type control flow. Division by the constant texture
+ // dimensions is exactly the derivative of the normalized coordinate
+ // and remains valid under WebGPU's uniformity analysis.
+ let pathAtlasCoordDx = atlasCoordDx / pathAtlasSize;
+ let pathAtlasCoordDy = atlasCoordDy / pathAtlasSize;
+ let coverage = textureSampleGrad(pathAtlasTexture, pathAtlasSampler, pathAtlasCoord, pathAtlasCoordDx, pathAtlasCoordDy).r;
let coverageGamma = select(1.0, input.cornerRadius, input.cornerRadius > 0.0);
let correctedCoverage = pow(coverage, coverageGamma);
shapeAlpha = select(correctedCoverage, select(0.0, 1.0, coverage >= 0.5), aliasedEdge);
diff --git a/src/ProGPU.Backend/WgpuContext.cs b/src/ProGPU.Backend/WgpuContext.cs
index e2d66cfd..2313ac4c 100644
--- a/src/ProGPU.Backend/WgpuContext.cs
+++ b/src/ProGPU.Backend/WgpuContext.cs
@@ -1,6 +1,7 @@
using System;
using System.Buffers;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -43,6 +44,9 @@ public unsafe class WgpuContext : IDisposable
public bool SupportsReadOnlyAndReadWriteStorageTextures { get; private set; }
public BackendType AdapterBackendType { get; private set; } = BackendType.Undefined;
public string AdapterName { get; private set; } = string.Empty;
+ public ulong SurfaceConfigurationCount { get; private set; }
+ public double SurfaceConfigurationTimeMs { get; private set; }
+ public double MaximumSurfaceConfigurationTimeMs { get; private set; }
public static event Action? OnWebGpuError;
public static event Action? OnWebGpuDeviceLost;
@@ -391,6 +395,11 @@ public void Dispose()
private uint _lastWidth = 1;
private uint _lastHeight = 1;
private bool _isSurfaceConfigured;
+ private bool _hasSurfaceConfigurationCapabilities;
+ private TextureFormat _cachedSurfaceFormat;
+ private CompositeAlphaMode _cachedCompositeAlphaMode;
+ private PresentMode _cachedVsyncPresentMode;
+ private PresentMode _cachedUncappedPresentMode;
private bool _vsync = false;
public bool VSync
@@ -824,6 +833,7 @@ private void ReleasePresentationSurfaceCore(bool waitForDevice)
Api.SurfaceRelease(Surface);
Surface = null;
_isSurfaceConfigured = false;
+ _hasSurfaceConfigurationCapabilities = false;
}
private static NativeInstanceExtras CreateNativeInstanceExtras()
@@ -1124,7 +1134,7 @@ public void ConfigureSwapChain(uint width, uint height)
_ = TryConfigureSwapChain(width, height);
}
- public bool TryConfigureSwapChain(uint width, uint height)
+ public bool TryConfigureSwapChain(uint width, uint height, bool refreshCapabilities = false)
{
if (BackendKind == WgpuBackendKind.BrowserWebGpu)
{
@@ -1138,47 +1148,63 @@ public bool TryConfigureSwapChain(uint width, uint height)
return false;
}
+ long configurationStart = Stopwatch.GetTimestamp();
+
// Synchronize GLFW window VSync state with WebGPU context VSync state dynamically
if (_window != null)
{
_window.VSync = _vsync;
}
- // 7a. Query supported formats
- var capabilities = new SurfaceCapabilities();
- Wgpu.SurfaceGetCapabilities(Surface, Adapter, &capabilities);
-
- ReadOnlySpan formats = capabilities.FormatCount > 0 && capabilities.Formats != null
- ? new ReadOnlySpan(capabilities.Formats, checked((int)capabilities.FormatCount))
- : ReadOnlySpan.Empty;
- ReadOnlySpan alphaModes = capabilities.AlphaModeCount > 0 && capabilities.AlphaModes != null
- ? new ReadOnlySpan(capabilities.AlphaModes, checked((int)capabilities.AlphaModeCount))
- : ReadOnlySpan.Empty;
- ReadOnlySpan presentModes = capabilities.PresentModeCount > 0 && capabilities.PresentModes != null
- ? new ReadOnlySpan(capabilities.PresentModes, checked((int)capabilities.PresentModeCount))
- : ReadOnlySpan.Empty;
-
- if (!CanConfigureSurface(formats, alphaModes, presentModes))
- {
- ProGpuBackendDiagnostics.WriteLine(
- $"[WebGPU Context] Deferring SwapChain configuration for {width}x{height}: " +
- $"formats={formats.Length}, alphaModes={alphaModes.Length}, presentModes={presentModes.Length}.");
- Wgpu.SurfaceCapabilitiesFreeMembers(capabilities);
- return false;
- }
+ // Surface capabilities are stable for the lifetime of a native surface. Keep
+ // the selected immutable values across size-only reconfiguration; querying and
+ // freeing the native capability arrays on every resize can block the event loop.
+ if (refreshCapabilities || !_hasSurfaceConfigurationCapabilities)
+ {
+ var capabilities = new SurfaceCapabilities();
+ Wgpu.SurfaceGetCapabilities(Surface, Adapter, &capabilities);
+ try
+ {
+ ReadOnlySpan formats = capabilities.FormatCount > 0 && capabilities.Formats != null
+ ? new ReadOnlySpan(capabilities.Formats, checked((int)capabilities.FormatCount))
+ : ReadOnlySpan.Empty;
+ ReadOnlySpan alphaModes = capabilities.AlphaModeCount > 0 && capabilities.AlphaModes != null
+ ? new ReadOnlySpan(capabilities.AlphaModes, checked((int)capabilities.AlphaModeCount))
+ : ReadOnlySpan.Empty;
+ ReadOnlySpan presentModes = capabilities.PresentModeCount > 0 && capabilities.PresentModes != null
+ ? new ReadOnlySpan(capabilities.PresentModes, checked((int)capabilities.PresentModeCount))
+ : ReadOnlySpan.Empty;
+
+ if (!CanConfigureSurface(formats, alphaModes, presentModes))
+ {
+ ProGpuBackendDiagnostics.WriteLine(
+ $"[WebGPU Context] Deferring SwapChain configuration for {width}x{height}: " +
+ $"formats={formats.Length}, alphaModes={alphaModes.Length}, presentModes={presentModes.Length}.");
+ _hasSurfaceConfigurationCapabilities = false;
+ return false;
+ }
- TextureFormat swapChainFormat = ChooseSurfaceFormat(formats);
+ _cachedSurfaceFormat = ChooseSurfaceFormat(formats);
+ _cachedCompositeAlphaMode = ChooseCompositeAlphaMode(
+ _window?.TransparentFramebuffer == true,
+ alphaModes);
+ _cachedVsyncPresentMode = ChoosePresentMode(true, presentModes);
+ _cachedUncappedPresentMode = ChoosePresentMode(false, presentModes);
+ _hasSurfaceConfigurationCapabilities = true;
+ }
+ finally
+ {
+ Wgpu.SurfaceCapabilitiesFreeMembers(capabilities);
+ }
+ }
- var alphaMode = ChooseCompositeAlphaMode(
- _window?.TransparentFramebuffer == true,
- alphaModes);
- PresentMode presentMode = ChoosePresentMode(_vsync, presentModes);
+ TextureFormat swapChainFormat = _cachedSurfaceFormat;
+ CompositeAlphaMode alphaMode = _cachedCompositeAlphaMode;
+ PresentMode presentMode = _vsync ? _cachedVsyncPresentMode : _cachedUncappedPresentMode;
ProGpuBackendDiagnostics.WriteLine($"[WebGPU Context] Configuring SwapChain: {width}x{height}, VSync: {_vsync}, Selected Mode: {presentMode}");
- Wgpu.SurfaceCapabilitiesFreeMembers(capabilities);
-
- // 7b. Surface Configuration
+ // Configure only the latest physical size selected by the normal render tick.
var config = new SurfaceConfiguration
{
Device = Device,
@@ -1195,6 +1221,10 @@ public bool TryConfigureSwapChain(uint width, uint height)
_lastWidth = config.Width;
_lastHeight = config.Height;
_isSurfaceConfigured = true;
+ double elapsedMilliseconds = Stopwatch.GetElapsedTime(configurationStart).TotalMilliseconds;
+ SurfaceConfigurationCount++;
+ SurfaceConfigurationTimeMs += elapsedMilliseconds;
+ MaximumSurfaceConfigurationTimeMs = Math.Max(MaximumSurfaceConfigurationTimeMs, elapsedMilliseconds);
return true;
}
diff --git a/src/ProGPU.Browser/BrowserAssets/progpu-browser.js b/src/ProGPU.Browser/BrowserAssets/progpu-browser.js
index db9f8192..2e9aede6 100644
--- a/src/ProGPU.Browser/BrowserAssets/progpu-browser.js
+++ b/src/ProGPU.Browser/BrowserAssets/progpu-browser.js
@@ -27,6 +27,9 @@ const state = {
clipboardRtf: '',
clipboardHtml: '',
pickedStorageBytes: null,
+ nextStorageHandle: 1,
+ saveFileHandles: new Map(),
+ directoryHandles: new Map(),
worker: null,
workerRequests: new Map(),
nextWorkerRequest: 1,
@@ -51,7 +54,9 @@ const BENCHMARK_QUERY_VARIABLES = Object.freeze({
benchmarkMeasureFrames: 'PROGPU_SAMPLE_BENCHMARK_MEASURE_FRAMES',
benchmarkVsync: 'PROGPU_SAMPLE_BENCHMARK_VSYNC',
benchmarkScroll: 'PROGPU_SAMPLE_BENCHMARK_SCROLL',
- benchmarkScrollStep: 'PROGPU_SAMPLE_BENCHMARK_SCROLL_STEP'
+ benchmarkScrollStep: 'PROGPU_SAMPLE_BENCHMARK_SCROLL_STEP',
+ benchmarkPreconditionPages: 'PROGPU_SAMPLE_BENCHMARK_PRECONDITION_PAGES',
+ benchmarkPreconditionFrames: 'PROGPU_SAMPLE_BENCHMARK_PRECONDITION_FRAMES'
});
const uncappedFrameResolvers = [];
const uncappedGpuFenceResolvers = new Map();
@@ -702,12 +707,38 @@ async function stagePickedFile(file) {
return encodeURIComponent(file.name);
}
-function pickStorageWithInput(filters) {
+function storagePickerTypes(filters) {
+ const extensions = String(filters || '')
+ .split(',')
+ .map(value => value.trim())
+ .filter(value => /^\.[A-Za-z0-9][A-Za-z0-9._+-]{0,15}$/.test(value));
+ return extensions.length === 0
+ ? undefined
+ : [{ description: 'Supported files', accept: { 'application/octet-stream': extensions } }];
+}
+
+function rememberStorageHandle(handles, prefix, handle) {
+ const token = `${prefix}-${state.nextStorageHandle++}`;
+ handles.set(token, handle);
+ if (handles.size > 32) handles.delete(handles.keys().next().value);
+ return token;
+}
+
+function encodeStorageSelection(token, name) {
+ return `${token}\n${encodeURIComponent(name)}`;
+}
+
+function pickStorageWithInput(filters, directory = false) {
return new Promise(resolve => {
const input = document.createElement('input');
let settled = false;
input.type = 'file';
- input.accept = filters || '';
+ if (directory) {
+ input.multiple = true;
+ input.webkitdirectory = true;
+ } else {
+ input.accept = filters || '';
+ }
input.style.display = 'none';
document.body.appendChild(input);
@@ -719,6 +750,11 @@ function pickStorageWithInput(filters) {
settled = true;
cleanup();
if (!file) { resolve(''); return; }
+ if (directory) {
+ const relativePath = file.webkitRelativePath || file.name;
+ resolve(encodeStorageSelection('virtual', relativePath.split('/')[0] || 'folder'));
+ return;
+ }
try { resolve(await stagePickedFile(file)); }
catch { state.pickedStorageBytes = null; resolve(''); }
};
@@ -729,12 +765,84 @@ function pickStorageWithInput(filters) {
});
}
-async function pickStorage(mode, filters, defaultName) {
- if (mode === 1) return Promise.resolve(defaultName || 'untitled.txt');
- if (mode !== 0) return Promise.resolve('');
+async function pickOpenStorage(filters) {
state.pickedStorageBytes = null;
+ if (typeof globalThis.showOpenFilePicker !== 'function') return await pickStorageWithInput(filters);
+ try {
+ const types = storagePickerTypes(filters);
+ const handles = await globalThis.showOpenFilePicker(types ? { multiple: false, types } : { multiple: false });
+ const file = await handles[0]?.getFile();
+ return file ? await stagePickedFile(file) : '';
+ } catch (error) {
+ if (error?.name !== 'AbortError') globalThis.console.error('[ProGPU] Browser open-file picker failed.', error);
+ return '';
+ }
+}
+
+async function pickSaveStorage(filters, defaultName) {
+ const name = defaultName || 'untitled.txt';
+ if (typeof globalThis.showSaveFilePicker !== 'function') return encodeStorageSelection('download', name);
+ try {
+ const types = storagePickerTypes(filters);
+ const options = types ? { suggestedName: name, types } : { suggestedName: name };
+ const handle = await globalThis.showSaveFilePicker(options);
+ const token = rememberStorageHandle(state.saveFileHandles, 'save', handle);
+ return encodeStorageSelection(token, handle.name || name);
+ } catch (error) {
+ if (error?.name !== 'AbortError') globalThis.console.error('[ProGPU] Browser save-file picker failed.', error);
+ return '';
+ }
+}
+
+async function pickFolderStorage() {
+ if (typeof globalThis.showDirectoryPicker !== 'function') return await pickStorageWithInput('', true);
+ try {
+ const handle = await globalThis.showDirectoryPicker({ mode: 'readwrite' });
+ const token = rememberStorageHandle(state.directoryHandles, 'folder', handle);
+ return encodeStorageSelection(token, handle.name || 'folder');
+ } catch (error) {
+ if (error?.name !== 'AbortError') globalThis.console.error('[ProGPU] Browser directory picker failed.', error);
+ return '';
+ }
+}
+
+async function pickStorage(mode, filters, defaultName) {
+ if (mode === 0) return await pickOpenStorage(filters);
+ if (mode === 1) return await pickSaveStorage(filters, defaultName);
+ if (mode === 2) return await pickFolderStorage();
+ return '';
+}
+
+async function writePickedStorageText(token, text) {
+ const handle = state.saveFileHandles.get(token);
+ if (!handle) return false;
+ try {
+ const writable = await handle.createWritable();
+ await writable.write(text);
+ await writable.close();
+ return true;
+ } catch (error) {
+ globalThis.console.error('[ProGPU] Browser file write failed.', error);
+ return false;
+ }
+}
- return await pickStorageWithInput(filters);
+async function writePickedStorageBytes(token, source, length) {
+ const handle = state.saveFileHandles.get(token);
+ if (!handle) return false;
+ const heap = runtime.localHeapViewU8();
+ if (source < 0 || length < 0 || source + length > heap.byteLength) throw new RangeError('File source is outside WASM memory.');
+ // Copy before the first await because WASM memory can grow while the browser picker is open.
+ const bytes = heap.slice(source, source + length);
+ try {
+ const writable = await handle.createWritable();
+ await writable.write(bytes);
+ await writable.close();
+ return true;
+ } catch (error) {
+ globalThis.console.error('[ProGPU] Browser file write failed.', error);
+ return false;
+ }
}
function getPickedStorageLength() {
@@ -1362,6 +1470,9 @@ function execute(opcode, view, payload, payloadLength, absoluteBase) {
requireResource(view.getUint32(payload + 16, true)), Number(view.getBigUint64(payload + 24, true)),
Number(view.getBigUint64(payload + 32, true)));
break;
+ case 61:
+ requireResource(view.getUint32(payload, true)).copyBufferToTexture(imageCopyBuffer(view, payload + 4), imageCopyTexture(view, payload + 28), extent3d(view, payload + 52));
+ break;
case 62:
requireResource(view.getUint32(payload, true)).copyTextureToBuffer(imageCopyTexture(view, payload + 4), imageCopyBuffer(view, payload + 28), extent3d(view, payload + 52));
break;
@@ -1606,7 +1717,7 @@ if (isDispatcherWorker) {
} else {
initializeDiagnosticsVisibility();
runtime = await dotnet.withEnvironmentVariables(readBenchmarkEnvironment()).create();
- runtime.setModuleImports('progpu-browser', { initialize, dispatch, dispatchUpload, mapBuffer, copyMappedBuffer, writeMappedBuffer, releaseMappedBuffer, nextAnimationFrame, writeCanvasMetrics, drainInputEvents, setCanvasCursor, configureTextInput, hideTextInput, setClipboardText, getClipboardText, setClipboardRichText, getClipboardRtf, getClipboardHtml, pickStorage, getPickedStorageLength, copyPickedStorage, clearPickedStorage, downloadText, downloadBytes, getDiagnosticsVisible, setDiagnosticsVisible, setStatus, updateCounters });
+ runtime.setModuleImports('progpu-browser', { initialize, dispatch, dispatchUpload, mapBuffer, copyMappedBuffer, writeMappedBuffer, releaseMappedBuffer, nextAnimationFrame, writeCanvasMetrics, drainInputEvents, setCanvasCursor, configureTextInput, hideTextInput, setClipboardText, getClipboardText, setClipboardRichText, getClipboardRtf, getClipboardHtml, pickStorage, getPickedStorageLength, copyPickedStorage, clearPickedStorage, writePickedStorageText, writePickedStorageBytes, downloadText, downloadBytes, getDiagnosticsVisible, setDiagnosticsVisible, setStatus, updateCounters });
const browserExports = await runtime.getAssemblyExports('ProGPU.Browser.dll');
state.dispatchImmediatePointer = browserExports.ProGPU.Browser.BrowserInputDispatcher.DispatchImmediatePointer;
state.dispatchTextInput = browserExports.ProGPU.Browser.BrowserInputDispatcher.DispatchTextInput;
diff --git a/src/ProGPU.Browser/BrowserStorageServices.cs b/src/ProGPU.Browser/BrowserStorageServices.cs
index a372b771..db0aba14 100644
--- a/src/ProGPU.Browser/BrowserStorageServices.cs
+++ b/src/ProGPU.Browser/BrowserStorageServices.cs
@@ -7,6 +7,7 @@ internal static partial class BrowserStorageServices
{
private const string OpenDirectory = "/tmp/progpu-browser-open";
private const string SaveDirectory = "/tmp/progpu-browser-save";
+ private const string FolderDirectory = "/tmp/progpu-browser-folder";
public static void Initialize()
{
@@ -58,31 +59,84 @@ public static void Initialize()
if (mode == 1)
{
- Directory.CreateDirectory(SaveDirectory);
- return Path.Combine(SaveDirectory, Path.GetFileName(result));
+ var selection = ParseHandleSelection(result);
+ if (selection == null) return null;
+
+ var directory = Path.Combine(SaveDirectory, selection.Value.Token);
+ Directory.CreateDirectory(directory);
+ return Path.Combine(directory, selection.Value.Name);
+ }
+
+ if (mode == 2)
+ {
+ var selection = ParseHandleSelection(result);
+ if (selection == null) return null;
+ return Path.Combine(FolderDirectory, selection.Value.Token, selection.Value.Name);
}
return null;
}
- private static Task WriteTextAsync(string path, string text)
+ private static async Task WriteTextAsync(string path, string text)
{
- if (!path.StartsWith(SaveDirectory, StringComparison.Ordinal)) return Task.FromResult(false);
- DownloadText(Path.GetFileName(path), text);
- return Task.FromResult(true);
+ if (!TryGetSaveSelection(path, out var token, out var name)) return false;
+ if (token != "download" && await WritePickedStorageText(token, text)) return true;
+
+ DownloadText(name, text);
+ return true;
}
- private static Task WriteBytesAsync(string path, byte[] bytes)
+ private static async Task WriteBytesAsync(string path, byte[] bytes)
{
- if (!path.StartsWith(SaveDirectory, StringComparison.Ordinal)) return Task.FromResult(false);
+ if (!TryGetSaveSelection(path, out var token, out var name)) return false;
+ if (token != "download")
+ {
+ Task writeTask;
+ unsafe
+ {
+ fixed (byte* source = bytes)
+ {
+ writeTask = WritePickedStorageBytes(token, (nint)source, bytes.Length);
+ }
+ }
+
+ if (await writeTask) return true;
+ }
+
unsafe
{
fixed (byte* source = bytes)
{
- DownloadBytes(Path.GetFileName(path), (nint)source, bytes.Length);
+ DownloadBytes(name, (nint)source, bytes.Length);
}
}
- return Task.FromResult(true);
+ return true;
+ }
+
+ private static (string Token, string Name)? ParseHandleSelection(string result)
+ {
+ int separator = result.IndexOf('\n');
+ if (separator <= 0 || separator == result.Length - 1) return null;
+
+ var token = result[..separator];
+ var name = Path.GetFileName(Uri.UnescapeDataString(result[(separator + 1)..]));
+ if (string.IsNullOrWhiteSpace(token) || Path.GetFileName(token) != token || token is "." or ".." ||
+ string.IsNullOrWhiteSpace(name)) return null;
+ return (token, name);
+ }
+
+ private static bool TryGetSaveSelection(string path, out string token, out string name)
+ {
+ token = string.Empty;
+ name = string.Empty;
+ var relative = Path.GetRelativePath(SaveDirectory, path);
+ if (Path.IsPathRooted(relative) || relative.StartsWith("..", StringComparison.Ordinal)) return false;
+
+ var parts = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2 || parts.Any(part => part is "." or "..")) return false;
+ token = parts[0];
+ name = parts[1];
+ return true;
}
[JSImport("pickStorage", "progpu-browser")]
@@ -97,6 +151,12 @@ private static Task WriteBytesAsync(string path, byte[] bytes)
[JSImport("clearPickedStorage", "progpu-browser")]
private static partial void ClearPickedStorage();
+ [JSImport("writePickedStorageText", "progpu-browser")]
+ private static partial Task WritePickedStorageText(string token, string text);
+
+ [JSImport("writePickedStorageBytes", "progpu-browser")]
+ private static partial Task WritePickedStorageBytes(string token, nint source, int length);
+
[JSImport("downloadText", "progpu-browser")]
private static partial void DownloadText(string name, string text);
diff --git a/src/ProGPU.Browser/BrowserWebGpuApi.cs b/src/ProGPU.Browser/BrowserWebGpuApi.cs
index 5cc2ec4b..c9be9a14 100644
--- a/src/ProGPU.Browser/BrowserWebGpuApi.cs
+++ b/src/ProGPU.Browser/BrowserWebGpuApi.cs
@@ -350,6 +350,19 @@ public void CommandEncoderCopyBufferToBuffer(CommandEncoder* commandEncoder, Wgp
_commands.CompleteCommand();
}
+ public void CommandEncoderCopyBufferToTexture(CommandEncoder* commandEncoder, ImageCopyBuffer* source, ImageCopyTexture* destination, Extent3D* copySize)
+ {
+ RequireDescriptor(source);
+ RequireDescriptor(destination);
+ RequireDescriptor(copySize);
+ var payload = _commands.BeginCommand(BrowserGpuOpcode.CopyBufferToTexture, 64);
+ WriteHandle(payload, 0, HandleOf(commandEncoder));
+ WriteImageCopyBuffer(payload, 4, *source);
+ WriteImageCopyTexture(payload, 28, *destination);
+ WriteExtent(payload, 52, *copySize);
+ _commands.CompleteCommand();
+ }
+
public void CommandEncoderCopyTextureToBuffer(CommandEncoder* commandEncoder, ImageCopyTexture* source, ImageCopyBuffer* destination, Extent3D* copySize)
{
RequireDescriptor(source);
diff --git a/src/ProGPU.Samples/Helpers/TextDisplayFactory.cs b/src/ProGPU.Samples/Helpers/TextDisplayFactory.cs
index 8d304ae8..7bd1936f 100644
--- a/src/ProGPU.Samples/Helpers/TextDisplayFactory.cs
+++ b/src/ProGPU.Samples/Helpers/TextDisplayFactory.cs
@@ -13,12 +13,36 @@ namespace ProGPU.Samples;
internal static class TextDisplayFactory
{
+ private const int MaximumRetainedElements = 512;
private static readonly object s_lock = new();
- private static readonly Queue s_pool = new();
+ private static readonly Queue s_pool = new();
+
+ private sealed class PooledTextDisplay : Border
+ {
+ public Run Run { get; } = new("lol?");
+ public SolidColorBrush ForegroundBrush { get; } = new(Vector4.One);
+
+ public PooledTextDisplay()
+ {
+ var textBlock = new RichTextBlock
+ {
+ Font = AppState._font,
+ FontSize = 14f,
+ Foreground = ForegroundBrush
+ };
+ textBlock.Inlines.Add(Run);
+ Child = textBlock;
+ HorizontalAlignment = HorizontalAlignment.Stretch;
+ VerticalAlignment = VerticalAlignment.Stretch;
+ Width = 80f;
+ Height = 40f;
+ CenterPoint = new Vector3(40f, 20f, 0f);
+ }
+ }
public static Border Rent()
{
- Border? border = null;
+ PooledTextDisplay? border = null;
lock (s_lock)
{
if (s_pool.Count > 0)
@@ -29,40 +53,34 @@ public static Border Rent()
if (border == null)
{
- border = new Border();
- var textBlock = new RichTextBlock();
- border.Child = textBlock;
+ border = new PooledTextDisplay();
}
-
- Reset(border);
return border;
}
public static void Return(Border border)
{
- Reset(border);
+ if (border is not PooledTextDisplay pooled) return;
lock (s_lock)
{
- s_pool.Enqueue(border);
+ if (s_pool.Count < MaximumRetainedElements)
+ s_pool.Enqueue(pooled);
}
}
public static void SetText(Border border, string text)
{
- if (border.Child is RichTextBlock textBlock)
- {
- textBlock.Inlines.Clear();
- textBlock.Inlines.Add(new Run(text));
- textBlock.Invalidate();
- }
+ if (border is PooledTextDisplay pooled &&
+ !string.Equals(pooled.Run.Text, text, System.StringComparison.Ordinal))
+ pooled.Run.Text = text;
}
- public static void SetForeground(Border border, Brush? brush)
+ public static void SetForegroundColor(Border border, Vector4 color)
{
- if (border.Child is RichTextBlock textBlock)
+ if (border is PooledTextDisplay pooled && pooled.ForegroundBrush.Color != color)
{
- textBlock.Foreground = brush;
- textBlock.Invalidate();
+ pooled.ForegroundBrush.Color = color;
+ border.Invalidate();
}
}
@@ -78,32 +96,4 @@ public static void SetPadding(Border border, Microsoft.UI.Xaml.Thickness padding
border.Invalidate();
}
- private static void Reset(Border border)
- {
- border.Background = null;
- border.Padding = default;
- border.BorderBrush = null;
- border.BorderThickness = default;
- border.CornerRadius = 0f;
-
- if (border.Child is RichTextBlock textBlock)
- {
- textBlock.Inlines.Clear();
- textBlock.Foreground = null;
- textBlock.Font = AppState._font; // Default system font
- textBlock.FontSize = 14f;
- textBlock.Invalidate();
- }
-
- border.HorizontalAlignment = HorizontalAlignment.Stretch;
- border.VerticalAlignment = VerticalAlignment.Stretch;
- border.Rotation = 0f;
- border.Scale = Vector3.One;
- border.CenterPoint = Vector3.Zero;
- border.Width = float.NaN;
- border.Height = float.NaN;
-
- Canvas.SetLeft(border, 0f);
- Canvas.SetTop(border, 0f);
- }
}
diff --git a/src/ProGPU.Samples/Pages/LolsPage.cs b/src/ProGPU.Samples/Pages/LolsPage.cs
index 86b6166d..68ab5936 100644
--- a/src/ProGPU.Samples/Pages/LolsPage.cs
+++ b/src/ProGPU.Samples/Pages/LolsPage.cs
@@ -375,20 +375,35 @@ private static void ProcessPendingElements()
int red = random.Next(256);
int green = random.Next(256);
int blue = random.Next(256);
- var foreground = new SolidColorBrush(new Vector4(
+ var foreground = new Vector4(
red / 255f,
green / 255f,
blue / 255f,
- 1f));
+ 1f);
float rotation = (float)(random.NextDouble() * Math.PI * 2d);
- var textControl = TextDisplayFactory.Rent();
+ while (_canvas.Children.Count > _max && _canvas.Children[0] is Border surplus)
+ {
+ _canvas.RemoveChild(surplus);
+ TextDisplayFactory.Return(surplus);
+ }
+
+ Border textControl;
+ bool addToCanvas;
+ if (_canvas.Children.Count >= _max && _canvas.Children[0] is Border oldest)
+ {
+ textControl = oldest;
+ _canvas.BringChildToFront(oldest);
+ addToCanvas = false;
+ }
+ else
+ {
+ textControl = TextDisplayFactory.Rent();
+ addToCanvas = true;
+ }
TextDisplayFactory.SetText(textControl, "lol?");
- TextDisplayFactory.SetForeground(textControl, foreground);
+ TextDisplayFactory.SetForegroundColor(textControl, foreground);
- textControl.Width = 80f;
- textControl.Height = 40f;
- textControl.CenterPoint = new Vector3(40f, 20f, 0f);
textControl.Rotation = rotation;
float left = (float)(random.NextDouble() * (width - 80f));
@@ -396,17 +411,7 @@ private static void ProcessPendingElements()
Microsoft.UI.Xaml.Controls.Canvas.SetLeft(textControl, left);
Microsoft.UI.Xaml.Controls.Canvas.SetTop(textControl, top);
- if (_canvas.Children.Count >= _max)
- {
- var oldest = _canvas.Children[0] as Border;
- if (oldest != null)
- {
- _canvas.RemoveChild(oldest);
- TextDisplayFactory.Return(oldest);
- }
- }
-
- _canvas.AddChild(textControl);
+ if (addToCanvas) _canvas.AddChild(textControl);
_count++;
}
}
diff --git a/src/ProGPU.Samples/SamplePerformanceBenchmark.cs b/src/ProGPU.Samples/SamplePerformanceBenchmark.cs
index fe0eab25..b6933f55 100644
--- a/src/ProGPU.Samples/SamplePerformanceBenchmark.cs
+++ b/src/ProGPU.Samples/SamplePerformanceBenchmark.cs
@@ -1,5 +1,9 @@
using System;
using System.Diagnostics;
+using System.Diagnostics.Tracing;
+using System.Runtime.InteropServices;
+using Silk.NET.Maths;
+using Silk.NET.Windowing;
namespace ProGPU.Samples;
@@ -13,6 +17,14 @@ internal static class SamplePerformanceBenchmark
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 const string MemoryVariable = "PROGPU_SAMPLE_BENCHMARK_MEMORY";
+ private const string HoldMillisecondsVariable = "PROGPU_SAMPLE_BENCHMARK_HOLD_MS";
+ private const string ResizeVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE";
+ private const string ResizeMinimumWidthVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE_MIN_WIDTH";
+ private const string ResizeMinimumHeightVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE_MIN_HEIGHT";
+ private const string ResizeMaximumWidthVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE_MAX_WIDTH";
+ private const string ResizeMaximumHeightVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE_MAX_HEIGHT";
+ private const string ResizeStepVariable = "PROGPU_SAMPLE_BENCHMARK_RESIZE_STEP";
private static readonly int s_warmupFrames = ReadPositiveInt(WarmupFramesVariable, 180);
private static readonly int s_measureFrames = ReadPositiveInt(MeasureFramesVariable, 600);
@@ -34,8 +46,17 @@ internal static class SamplePerformanceBenchmark
private static double s_animationMilliseconds;
private static double s_surfaceAcquireMilliseconds;
private static double s_presentMilliseconds;
+ private static double s_totalFrameMilliseconds;
+ private static double s_maximumFrameMilliseconds;
+ private static int s_framesOverBudget;
private static Microsoft.UI.Xaml.Window? s_window;
+ private static IWindow? s_resizeWindow;
private static long s_allocatedBytesAtStart;
+ private static long s_managedHeapBytesAtStart;
+ private static TimeSpan s_gcPauseDurationAtStart;
+ private static int s_gen0CollectionsAtStart;
+ private static int s_gen1CollectionsAtStart;
+ private static int s_gen2CollectionsAtStart;
private static int s_lolsRenderedAtStart;
private static int s_sceneCacheHitFrames;
private static int s_glyphStateSamples;
@@ -62,6 +83,21 @@ internal static class SamplePerformanceBenchmark
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 readonly bool s_memoryWorkload = ReadOptionalBool(MemoryVariable) == true;
+ private static readonly int s_holdMilliseconds = ReadNonNegativeInt(HoldMillisecondsVariable, 0);
+ private static readonly bool s_resizeWorkload = ReadOptionalBool(ResizeVariable) == true;
+ private static readonly int s_resizeMinimumWidth = ReadPositiveInt(ResizeMinimumWidthVariable, 800);
+ private static readonly int s_resizeMinimumHeight = ReadPositiveInt(ResizeMinimumHeightVariable, 600);
+ private static readonly int s_resizeMaximumWidth = Math.Max(s_resizeMinimumWidth, ReadPositiveInt(ResizeMaximumWidthVariable, 1280));
+ private static readonly int s_resizeMaximumHeight = Math.Max(s_resizeMinimumHeight, ReadPositiveInt(ResizeMaximumHeightVariable, 800));
+ private static readonly int s_resizeStep = ReadPositiveInt(ResizeStepVariable, 8);
+ private static int s_resizeWidth = s_resizeMaximumWidth;
+ private static int s_resizeDirection = -1;
+ private static int s_resizeRequests;
+ private static int s_resizeRequestsAtStart;
+ private static Microsoft.UI.Xaml.WindowResizeMetrics s_resizeMetricsAtStart;
+ private static ulong s_surfaceConfigurationsAtStart;
+ private static double s_surfaceConfigurationTimeAtStart;
private static int s_preconditionPageIndex;
private static int s_preconditionFrame;
private static bool s_isPreconditioning;
@@ -71,6 +107,11 @@ internal static class SamplePerformanceBenchmark
public static void AttachWindow(Microsoft.UI.Xaml.Window window)
{
s_window = window;
+ if (s_resizeWorkload && window.SilkWindow is { } resizeWindow)
+ {
+ s_resizeWindow = resizeWindow;
+ resizeWindow.Update += AdvanceResizeWorkload;
+ }
}
public static void StartRequestedWorkload(string selectedPage)
@@ -99,8 +140,10 @@ public static void StartRequestedWorkload(string selectedPage)
$"[SampleBenchmark] page={selectedPage} warmupFrames={s_warmupFrames}" +
$" measureFrames={s_measureFrames} vsync={AppState._wgpuContext?.VSync}" +
$" scroll={s_scrollWorkload} scrollStep={s_scrollStep:F0}" +
+ $" resize={s_resizeWorkload}" +
$" preconditionPages={string.Join(';', s_preconditionPages)}" +
$" preconditionFrames={s_preconditionFrames}");
+ SampleBenchmarkEventSource.Log.WorkloadStarted(selectedPage);
}
public static void ObserveFrame(double deltaSeconds)
@@ -175,13 +218,28 @@ public static void ObserveFrame(double deltaSeconds)
if (s_frame == s_warmupFrames + 1)
{
+ if (s_memoryWorkload)
+ {
+ CollectRetainedMemory();
+ }
+
s_wallClock.Restart();
s_allocatedBytesAtStart = GC.GetTotalAllocatedBytes(precise: false);
+ s_managedHeapBytesAtStart = GC.GetTotalMemory(forceFullCollection: false);
+ s_gcPauseDurationAtStart = GC.GetTotalPauseDuration();
+ s_gen0CollectionsAtStart = GC.CollectionCount(0);
+ s_gen1CollectionsAtStart = GC.CollectionCount(1);
+ s_gen2CollectionsAtStart = GC.CollectionCount(2);
s_lolsRenderedAtStart = LolsPage.TotalRenderedCount;
s_glyphAtlasGenerationAtStart = AppState._screenCompositor?.Atlas.Generation ?? 0;
s_glyphAtlasEvictionsAtStart = AppState._screenCompositor?.Atlas.EvictionCount ?? 0;
s_glyphAtlasClearsAtStart = AppState._screenCompositor?.Atlas.ClearCount ?? 0;
s_pathAtlasGenerationAtStart = AppState._screenCompositor?.PathAtlas.Generation ?? 0;
+ s_resizeMetricsAtStart = s_window?.ResizeMetrics ?? default;
+ s_resizeRequestsAtStart = s_resizeRequests;
+ s_surfaceConfigurationsAtStart = AppState._wgpuContext?.SurfaceConfigurationCount ?? 0;
+ s_surfaceConfigurationTimeAtStart = AppState._wgpuContext?.SurfaceConfigurationTimeMs ?? 0d;
+ SampleBenchmarkEventSource.Log.MeasurementStarted(RequestedPage!);
}
s_deltaSeconds += deltaSeconds;
@@ -192,6 +250,9 @@ public static void ObserveFrame(double deltaSeconds)
s_animationMilliseconds += frameMetrics.AnimationTimeMs;
s_surfaceAcquireMilliseconds += frameMetrics.SurfaceAcquireTimeMs;
s_presentMilliseconds += frameMetrics.PresentTimeMs;
+ s_totalFrameMilliseconds += frameMetrics.TotalTimeMs;
+ s_maximumFrameMilliseconds = Math.Max(s_maximumFrameMilliseconds, frameMetrics.TotalTimeMs);
+ if (frameMetrics.TotalTimeMs > 16.667d) s_framesOverBudget++;
}
if (AppState._screenCompositor is { } compositor)
{
@@ -227,6 +288,7 @@ public static void ObserveFrame(double deltaSeconds)
s_wallClock.Stop();
s_finished = true;
LolsPage.Stop();
+ SampleBenchmarkEventSource.Log.MeasurementStopped(RequestedPage!);
double deltaFps = s_deltaSeconds > 0d ? measuredFrames / s_deltaSeconds : 0d;
double wallFps = s_wallClock.Elapsed.TotalSeconds > 0d
@@ -234,7 +296,35 @@ public static void ObserveFrame(double deltaSeconds)
: 0d;
double divisor = Math.Max(1, measuredFrames);
long allocatedBytes = Math.Max(0, GC.GetTotalAllocatedBytes(precise: false) - s_allocatedBytesAtStart);
+ int gen0Collections = GC.CollectionCount(0) - s_gen0CollectionsAtStart;
+ int gen1Collections = GC.CollectionCount(1) - s_gen1CollectionsAtStart;
+ int gen2Collections = GC.CollectionCount(2) - s_gen2CollectionsAtStart;
+ double gcPauseMilliseconds = (GC.GetTotalPauseDuration() - s_gcPauseDurationAtStart).TotalMilliseconds;
+ if (s_memoryWorkload)
+ {
+ CollectRetainedMemory();
+ }
+
+ long managedHeapBytes = GC.GetTotalMemory(forceFullCollection: false);
+ var gcMemoryInfo = GC.GetGCMemoryInfo();
+ var generationInfo = gcMemoryInfo.GenerationInfo;
+ long gen0Bytes = generationInfo.Length > 0 ? generationInfo[0].SizeAfterBytes : 0;
+ long gen1Bytes = generationInfo.Length > 1 ? generationInfo[1].SizeAfterBytes : 0;
+ long gen2Bytes = generationInfo.Length > 2 ? generationInfo[2].SizeAfterBytes : 0;
+ long lohBytes = generationInfo.Length > 3 ? generationInfo[3].SizeAfterBytes : 0;
+ long pohBytes = generationInfo.Length > 4 ? generationInfo[4].SizeAfterBytes : 0;
+ using var process = Process.GetCurrentProcess();
+ process.Refresh();
+ ProcessMemorySnapshot processMemory = ProcessMemorySnapshot.Capture(process);
var finalMetrics = AppState._screenCompositor?.Metrics;
+ var finalResizeMetrics = s_window?.ResizeMetrics ?? default;
+ ulong resizeEvents = finalResizeMetrics.LogicalResizeEvents - s_resizeMetricsAtStart.LogicalResizeEvents;
+ ulong framebufferResizeEvents = finalResizeMetrics.FramebufferResizeEvents - s_resizeMetricsAtStart.FramebufferResizeEvents;
+ ulong liveResizeFrames = finalResizeMetrics.LiveResizeFrames - s_resizeMetricsAtStart.LiveResizeFrames;
+ ulong suppressedScheduledFrames = finalResizeMetrics.SuppressedScheduledFrames - s_resizeMetricsAtStart.SuppressedScheduledFrames;
+ double resizeCallbackTimeMs = finalResizeMetrics.CallbackTimeMs - s_resizeMetricsAtStart.CallbackTimeMs;
+ ulong surfaceConfigurations = (AppState._wgpuContext?.SurfaceConfigurationCount ?? 0) - s_surfaceConfigurationsAtStart;
+ double surfaceConfigurationTimeMs = (AppState._wgpuContext?.SurfaceConfigurationTimeMs ?? 0d) - s_surfaceConfigurationTimeAtStart;
string workloadDetails = string.Empty;
if (string.Equals(RequestedPage, "Font Glyph Browser", StringComparison.OrdinalIgnoreCase))
{
@@ -327,6 +417,19 @@ public static void ObserveFrame(double deltaSeconds)
$" animationMs={s_animationMilliseconds / divisor:F4}" +
$" acquireMs={s_surfaceAcquireMilliseconds / divisor:F4}" +
$" presentMs={s_presentMilliseconds / divisor:F4}" +
+ $" totalFrameMs={s_totalFrameMilliseconds / divisor:F4}" +
+ $" maxFrameMs={s_maximumFrameMilliseconds:F4}" +
+ $" framesOverBudget={s_framesOverBudget}" +
+ $" resizeRequests={s_resizeRequests - s_resizeRequestsAtStart}" +
+ $" resizeEvents={resizeEvents}" +
+ $" framebufferResizeEvents={framebufferResizeEvents}" +
+ $" liveResizeFrames={liveResizeFrames}" +
+ $" suppressedScheduledFrames={suppressedScheduledFrames}" +
+ $" resizeCallbackMs={resizeCallbackTimeMs / Math.Max(1d, framebufferResizeEvents):F4}" +
+ $" maxResizeCallbackMs={finalResizeMetrics.MaximumCallbackTimeMs:F4}" +
+ $" surfaceConfigurations={surfaceConfigurations}" +
+ $" surfaceConfigurationMs={surfaceConfigurationTimeMs / Math.Max(1d, surfaceConfigurations):F4}" +
+ $" maxSurfaceConfigurationMs={AppState._wgpuContext?.MaximumSurfaceConfigurationTimeMs ?? 0d:F4}" +
$" allocatedBytesPerFrame={allocatedBytes / divisor:F0}" +
$" sceneCacheHits={s_sceneCacheHitFrames}/{measuredFrames}" +
$" sceneCacheMiss=\"{finalMetrics?.SceneCacheMissReason ?? "none"}\"" +
@@ -336,12 +439,99 @@ public static void ObserveFrame(double deltaSeconds)
$" textVertices={finalMetrics?.TextVerticesCount ?? 0}" +
$" pathAtlasEntries={finalMetrics?.PathAtlasCachedCount ?? 0}" +
$" pathAtlasCpuCacheBytes={finalMetrics?.PathAtlasCpuCacheBytes ?? 0}" +
+ $" glyphAtlasTextureBytes={finalMetrics?.GlyphAtlasTextureBytes ?? 0}" +
+ $" colorGlyphAtlasTextureBytes={finalMetrics?.ColorGlyphAtlasTextureBytes ?? 0}" +
+ $" glyphCoverageStagingBytes={finalMetrics?.GlyphCoverageStagingBytes ?? 0}" +
+ $" glyphOutlineGpuBytes={finalMetrics?.GlyphOutlineGpuBytes ?? 0}" +
+ $" glyphOutlineCompiled={finalMetrics?.GlyphOutlineCompiledCount ?? 0}" +
+ $" glyphOutlineCapacity={finalMetrics?.GlyphOutlineRecordCapacity ?? 0}" +
+ $" glyphOutlineUploadWrites={finalMetrics?.GlyphOutlineUploadWrites ?? 0}" +
+ $" glyphUniformUploadWrites={finalMetrics?.GlyphUniformUploadWrites ?? 0}" +
+ $" glyphRasterBatchSubmissions={finalMetrics?.GlyphRasterBatchSubmissions ?? 0}" +
+ $" glyphRasterBindGroupCreations={finalMetrics?.GlyphRasterBindGroupCreations ?? 0}" +
+ $" glyphRasterComputePasses={finalMetrics?.GlyphRasterComputePasses ?? 0}" +
+ $" glyphLastBatchNewGlyphs={finalMetrics?.GlyphLastBatchNewGlyphCount ?? 0}" +
+ $" glyphAtlasSize={finalMetrics?.GlyphAtlasSize ?? 0}/{finalMetrics?.GlyphAtlasMaximumSize ?? 0}" +
+ $" colorGlyphAtlasSize={finalMetrics?.ColorGlyphAtlasSize ?? 0}/{finalMetrics?.ColorGlyphAtlasMaximumSize ?? 0}" +
+ $" pathAtlasTextureBytes={finalMetrics?.PathAtlasTextureBytes ?? 0}" +
+ $" pathAtlasSize={finalMetrics?.PathAtlasSize ?? 0}/{finalMetrics?.PathAtlasMaximumSize ?? 0}" +
+ $" pathRasterStagingBytes={finalMetrics?.PathRasterStagingBytes ?? 0}" +
+ $" pathPeakRasterStagingBytes={finalMetrics?.PathPeakRasterStagingBytes ?? 0}" +
+ $" managedHeapStartBytes={s_managedHeapBytesAtStart}" +
+ $" managedHeapBytes={managedHeapBytes}" +
+ $" managedHeapDeltaBytes={managedHeapBytes - s_managedHeapBytesAtStart}" +
+ $" gcHeapSizeBytes={gcMemoryInfo.HeapSizeBytes}" +
+ $" gcCommittedBytes={gcMemoryInfo.TotalCommittedBytes}" +
+ $" managedFragmentedBytes={gcMemoryInfo.FragmentedBytes}" +
+ $" gen0Bytes={gen0Bytes}" +
+ $" gen1Bytes={gen1Bytes}" +
+ $" gen2Bytes={gen2Bytes}" +
+ $" lohBytes={lohBytes}" +
+ $" pohBytes={pohBytes}" +
+ $" gen0Collections={gen0Collections}" +
+ $" gen1Collections={gen1Collections}" +
+ $" gen2Collections={gen2Collections}" +
+ $" gcPauseMs={gcPauseMilliseconds:F3}" +
+ $" pinnedObjects={gcMemoryInfo.PinnedObjectsCount}" +
+ $" finalizationPending={gcMemoryInfo.FinalizationPendingCount}" +
+ $" processWorkingSetBytes={process.WorkingSet64}" +
+ $" processPeakWorkingSetBytes={process.PeakWorkingSet64}" +
+ $" processPrivateBytes={process.PrivateMemorySize64}" +
+ $" processVirtualBytes={process.VirtualMemorySize64}" +
+ $" processResidentBytes={processMemory.ResidentBytes}" +
+ $" processWiredBytes={processMemory.WiredBytes}" +
+ $" processPhysicalFootprintBytes={processMemory.PhysicalFootprintBytes}" +
+ $" processLifetimeMaxPhysicalFootprintBytes={processMemory.LifetimeMaxPhysicalFootprintBytes}" +
$" pathAtlasFillCacheEntries={AppState._screenCompositor?.PathAtlas.CachedFillPathCount ?? 0}" +
$" pathAtlasHitTestCacheEntries={AppState._screenCompositor?.PathAtlas.CachedHitTestPathCount ?? 0}");
+ if (s_holdMilliseconds > 0)
+ {
+ SampleBenchmarkEventSource.Log.SnapshotHoldStarted(RequestedPage!);
+ Thread.Sleep(s_holdMilliseconds);
+ }
+
AppState._window?.Close();
}
+ private static void AdvanceResizeWorkload(double _)
+ {
+ if (!s_resizeWorkload || !s_workloadStarted || s_isPreconditioning || s_finished || s_resizeWindow is null)
+ {
+ return;
+ }
+
+ int nextWidth = s_resizeWidth + s_resizeDirection * s_resizeStep;
+ if (nextWidth <= s_resizeMinimumWidth)
+ {
+ nextWidth = s_resizeMinimumWidth;
+ s_resizeDirection = 1;
+ }
+ else if (nextWidth >= s_resizeMaximumWidth)
+ {
+ nextWidth = s_resizeMaximumWidth;
+ s_resizeDirection = -1;
+ }
+
+ s_resizeWidth = nextWidth;
+ float progress = s_resizeMaximumWidth == s_resizeMinimumWidth
+ ? 1f
+ : (nextWidth - s_resizeMinimumWidth) / (float)(s_resizeMaximumWidth - s_resizeMinimumWidth);
+ int nextHeight = (int)MathF.Round(s_resizeMinimumHeight +
+ (s_resizeMaximumHeight - s_resizeMinimumHeight) * progress);
+ var requestedSize = new Vector2D(nextWidth, nextHeight);
+ if (s_resizeWindow.Size == requestedSize) return;
+ s_resizeWindow.Size = requestedSize;
+ s_resizeRequests++;
+ }
+
+ private static void CollectRetainedMemory()
+ {
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
+ GC.WaitForPendingFinalizers();
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
+ }
+
public static void RecordHostUpdate(TimeSpan elapsed)
{
if (RequestedPage is not null && !s_finished && s_frame > s_warmupFrames)
@@ -468,6 +658,12 @@ private static int ReadPositiveInt(string name, int fallback)
return int.TryParse(value, out int parsed) && parsed > 0 ? parsed : fallback;
}
+ private static int ReadNonNegativeInt(string name, int fallback)
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ return int.TryParse(value, out int parsed) && parsed >= 0 ? parsed : fallback;
+ }
+
private static bool? ReadOptionalBool(string name)
{
string? value = Environment.GetEnvironmentVariable(name);
@@ -482,3 +678,104 @@ private static float ReadPositiveFloat(string name, float fallback)
: fallback;
}
}
+
+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 SampleBenchmarkEventSource : EventSource
+{
+ public static readonly SampleBenchmarkEventSource 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);
+
+ [Event(4, Level = EventLevel.Informational)]
+ public void SnapshotHoldStarted(string page) => WriteEvent(4, page);
+}
diff --git a/src/ProGPU.Scene/Compositor.cs b/src/ProGPU.Scene/Compositor.cs
index 1a8d0389..1fe1f08b 100644
--- a/src/ProGPU.Scene/Compositor.cs
+++ b/src/ProGPU.Scene/Compositor.cs
@@ -118,6 +118,27 @@ public struct CompositorMetrics
public int TextVerticesCount;
public int PathAtlasCachedCount;
public long PathAtlasCpuCacheBytes;
+ public ulong GlyphAtlasTextureBytes;
+ public ulong ColorGlyphAtlasTextureBytes;
+ public ulong GlyphCoverageStagingBytes;
+ public ulong GlyphOutlineGpuBytes;
+ public ulong PathAtlasTextureBytes;
+ public uint PathRasterStagingBytes;
+ public uint PathPeakRasterStagingBytes;
+ public uint GlyphAtlasSize;
+ public uint GlyphAtlasMaximumSize;
+ public uint ColorGlyphAtlasSize;
+ public uint ColorGlyphAtlasMaximumSize;
+ public uint PathAtlasSize;
+ public uint PathAtlasMaximumSize;
+ public int GlyphOutlineCompiledCount;
+ public int GlyphOutlineRecordCapacity;
+ public ulong GlyphOutlineUploadWrites;
+ public ulong GlyphUniformUploadWrites;
+ public ulong GlyphRasterBatchSubmissions;
+ public ulong GlyphRasterBindGroupCreations;
+ public ulong GlyphRasterComputePasses;
+ public int GlyphLastBatchNewGlyphCount;
public bool SceneCacheHit;
public string? SceneCacheMissReason;
}
@@ -709,6 +730,8 @@ private void EndExtensionFrame(bool ownsFrame)
private BindGroup* _pathAtlasBindGroup;
private BindGroupLayout* _pathAtlasBindGroupLayoutOffscreen;
private BindGroup* _pathAtlasBindGroupOffscreen;
+ private ulong _boundGlyphAtlasTextureRevision;
+ private ulong _boundPathAtlasTextureRevision;
// MSAA color target resources
private Texture* _msaaTexture;
@@ -1149,7 +1172,11 @@ public Compositor(WgpuContext context, TextureFormat? renderFormat, CompositorOp
_compute = new ComputeAccelerator(_context);
// 1. Initialize GPU atlases.
- _atlas = new GlyphAtlas(_context, options.GlyphAtlasSize);
+ _atlas = new GlyphAtlas(
+ _context,
+ options.GlyphAtlasSize,
+ options.ColorGlyphAtlasSize,
+ options.GlyphCoverageStagingBytes);
_pathAtlas = new PathAtlas(
_context,
options.PathAtlasSize,
@@ -1509,8 +1536,8 @@ private void InitializePipelinesAndBindGroups()
_pathAtlasBindGroupLayout = CreateSamplerTextureLayout();
_pathAtlasBindGroupLayoutOffscreen = CreateSamplerTextureLayout();
- _atlasBindGroupLayout = CreateSamplerTextureLayout();
- _atlasBindGroupLayoutOffscreen = CreateSamplerTextureLayout();
+ _atlasBindGroupLayout = CreateTextAtlasLayout();
+ _atlasBindGroupLayoutOffscreen = CreateTextAtlasLayout();
_textureBindGroupLayout = CreateSamplerTextureLayout();
_textureBindGroupLayoutOffscreen = CreateSamplerTextureLayout();
_maskBindGroupLayout = CreateSamplerTextureLayout();
@@ -1922,14 +1949,19 @@ private void InitializePipelinesAndBindGroups()
TextureView = _atlas.AtlasTexture.ViewPtr
};
- var atlasEntries = stackalloc BindGroupEntry[2];
+ var atlasEntries = stackalloc BindGroupEntry[3];
atlasEntries[0] = samplerEntry;
atlasEntries[1] = viewEntry;
+ atlasEntries[2] = new BindGroupEntry
+ {
+ Binding = 2,
+ TextureView = _atlas.ColorAtlasTexture.ViewPtr
+ };
var atlasDesc = new BindGroupDescriptor
{
Layout = _atlasBindGroupLayout,
- EntryCount = 2,
+ EntryCount = 3,
Entries = atlasEntries
};
_atlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &atlasDesc);
@@ -1937,7 +1969,7 @@ private void InitializePipelinesAndBindGroups()
var atlasDescOffscreen = new BindGroupDescriptor
{
Layout = _atlasBindGroupLayoutOffscreen,
- EntryCount = 2,
+ EntryCount = 3,
Entries = atlasEntries
};
_atlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &atlasDescOffscreen);
@@ -1966,6 +1998,8 @@ private void InitializePipelinesAndBindGroups()
Entries = pathAtlasEntries
};
_pathAtlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &pathAtlasDescOffscreen);
+ _boundGlyphAtlasTextureRevision = _atlas.TextureRevision;
+ _boundPathAtlasTextureRevision = _pathAtlas.TextureRevision;
_dummyMaskTexture = new GpuTexture(
_context,
@@ -1999,6 +2033,62 @@ private void InitializePipelinesAndBindGroups()
_dummyMaskBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &bgDescMaskOffscreen);
}
+ private void RefreshAtlasBindGroupsIfNeeded()
+ {
+ if (_boundGlyphAtlasTextureRevision != _atlas.TextureRevision)
+ {
+ if (_atlasBindGroup != null)
+ {
+ _context.QueueBindGroupDisposal((nint)_atlasBindGroup);
+ }
+ if (_atlasBindGroupOffscreen != null)
+ {
+ _context.QueueBindGroupDisposal((nint)_atlasBindGroupOffscreen);
+ }
+
+ var entries = stackalloc BindGroupEntry[3];
+ entries[0] = new BindGroupEntry { Binding = 0, Sampler = _atlasSampler };
+ entries[1] = new BindGroupEntry { Binding = 1, TextureView = _atlas.AtlasTexture.ViewPtr };
+ entries[2] = new BindGroupEntry { Binding = 2, TextureView = _atlas.ColorAtlasTexture.ViewPtr };
+ var descriptor = new BindGroupDescriptor
+ {
+ Layout = _atlasBindGroupLayout,
+ EntryCount = 3,
+ Entries = entries
+ };
+ _atlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor);
+ descriptor.Layout = _atlasBindGroupLayoutOffscreen;
+ _atlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor);
+ _boundGlyphAtlasTextureRevision = _atlas.TextureRevision;
+ }
+
+ if (_boundPathAtlasTextureRevision != _pathAtlas.TextureRevision)
+ {
+ if (_pathAtlasBindGroup != null)
+ {
+ _context.QueueBindGroupDisposal((nint)_pathAtlasBindGroup);
+ }
+ if (_pathAtlasBindGroupOffscreen != null)
+ {
+ _context.QueueBindGroupDisposal((nint)_pathAtlasBindGroupOffscreen);
+ }
+
+ var entries = stackalloc BindGroupEntry[2];
+ entries[0] = new BindGroupEntry { Binding = 0, Sampler = _atlasSampler };
+ entries[1] = new BindGroupEntry { Binding = 1, TextureView = _pathAtlas.AtlasTexture.ViewPtr };
+ var descriptor = new BindGroupDescriptor
+ {
+ Layout = _pathAtlasBindGroupLayout,
+ EntryCount = 2,
+ Entries = entries
+ };
+ _pathAtlasBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor);
+ descriptor.Layout = _pathAtlasBindGroupLayoutOffscreen;
+ _pathAtlasBindGroupOffscreen = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor);
+ _boundPathAtlasTextureRevision = _pathAtlas.TextureRevision;
+ }
+ }
+
public void RenderScene(
Visual root,
uint logicalWidth,
@@ -2562,6 +2652,7 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView*
}
SceneStateUploadComplete:
+ RefreshAtlasBindGroupsIfNeeded();
uploadSw.Stop();
passSw = System.Diagnostics.Stopwatch.StartNew();
@@ -2937,6 +3028,28 @@ private void RenderSceneCore(Visual root, uint width, uint height, TextureView*
TextVerticesCount = _textVerticesList.Count,
PathAtlasCachedCount = _pathAtlas.CachedPathCount,
PathAtlasCpuCacheBytes = _pathAtlas.CompiledPathCacheBytes,
+ GlyphAtlasTextureBytes = (ulong)_atlas.AtlasSize * _atlas.AtlasSize,
+ ColorGlyphAtlasTextureBytes =
+ (ulong)_atlas.ColorAtlasSize * _atlas.ColorAtlasSize * 4UL,
+ GlyphCoverageStagingBytes = _atlas.CoverageStagingBytes,
+ GlyphOutlineGpuBytes = _atlas.AllocatedGpuOutlineBytes,
+ PathAtlasTextureBytes = _pathAtlas.PersistentTextureBytes,
+ PathRasterStagingBytes = _pathAtlas.LastRasterStagingBytes,
+ PathPeakRasterStagingBytes = _pathAtlas.PeakRasterStagingBytes,
+ GlyphAtlasSize = _atlas.AtlasSize,
+ GlyphAtlasMaximumSize = _atlas.MaxAtlasSize,
+ ColorGlyphAtlasSize = _atlas.ColorAtlasSize,
+ ColorGlyphAtlasMaximumSize = _atlas.MaxColorAtlasSize,
+ PathAtlasSize = _pathAtlas.AtlasSize,
+ PathAtlasMaximumSize = _pathAtlas.MaxAtlasSize,
+ GlyphOutlineCompiledCount = _atlas.CompiledGpuGlyphCount,
+ GlyphOutlineRecordCapacity = _atlas.AllocatedGpuGlyphRecordCapacity,
+ GlyphOutlineUploadWrites = _atlas.OutlineUploadWriteCount,
+ GlyphUniformUploadWrites = _atlas.UniformUploadWriteCount,
+ GlyphRasterBatchSubmissions = _atlas.RasterBatchSubmissionCount,
+ GlyphRasterBindGroupCreations = _atlas.RasterBindGroupCreationCount,
+ GlyphRasterComputePasses = _atlas.RasterComputePassCount,
+ GlyphLastBatchNewGlyphCount = _atlas.LastBatchNewGlyphCount,
SceneCacheHit = reuseCompiledScene,
SceneCacheMissReason = reuseCompiledScene ? null : _currentSceneCacheMissReason
};
@@ -3919,19 +4032,20 @@ private void CompileVisualTree(
// traversed so overflow content is never dropped.
if (compileLocalCommands)
{
+ var ownedRenderCommandCache = node as IOwnedRenderCommandCache;
+ bool ownsRenderCommandCache = ownedRenderCommandCache != null;
RetainedVisualCommands? retainedCommands = null;
- bool hasRetainedCommands = node is not DrawingVisual &&
+ bool hasRetainedCommands = !ownsRenderCommandCache && node is not DrawingVisual &&
_retainedVisualCommands.TryGetValue(node, out retainedCommands);
bool reuseRetainedCommands = hasRetainedCommands &&
retainedCommands!.RenderContentVersion == node.RenderContentVersion &&
retainedCommands.TargetWidth == _currentWidth &&
retainedCommands.TargetHeight == _currentHeight &&
retainedCommands.DpiScale == _currentDpiScale;
- bool usesPooledContext = !hasRetainedCommands;
+ bool usesPooledContext = !hasRetainedCommands && !ownsRenderCommandCache;
bool keepRetainedCommands = reuseRetainedCommands;
- var ctx = hasRetainedCommands
- ? retainedCommands!.Context
- : GetDrawingContext();
+ var ctx = ownedRenderCommandCache?.GetOrUpdateRenderCommandCache() ??
+ (hasRetainedCommands ? retainedCommands!.Context : GetDrawingContext());
try
{
if (!reuseRetainedCommands)
@@ -3941,8 +4055,13 @@ private void CompileVisualTree(
ctx.Clear();
}
- node.OnRender(ctx);
- if (node is not DrawingVisual && CanRetainVisualCommands(ctx))
+ if (!ownsRenderCommandCache)
+ {
+ node.OnRender(ctx);
+ }
+ if (!ownsRenderCommandCache &&
+ node is not DrawingVisual &&
+ CanRetainVisualCommands(ctx))
{
if (hasRetainedCommands)
{
@@ -4192,6 +4311,14 @@ private void CompileVisualTree(
ctx.Clear();
ReleaseDrawingContext();
}
+ else if (ownsRenderCommandCache)
+ {
+ // The context belongs to the visual. Clearing it here discards
+ // the visual's immutable-until-invalidated command stream while
+ // leaving that cache marked clean, so nested text disappears on
+ // the next compilation after its first successful frame.
+ _retainedVisualCommands.Remove(node);
+ }
else if (!keepRetainedCommands)
{
_retainedVisualCommands.Remove(node);
@@ -5669,10 +5796,10 @@ private void CompilePathCommand(
var v2 = Vector2.Transform(new Vector2(unscaledMinX + unscaledWidth, unscaledMinY + unscaledHeight), transform);
var v3 = Vector2.Transform(new Vector2(unscaledMinX, unscaledMinY + unscaledHeight), transform);
- var uv0 = new Vector2(info.TexCoordMin.X, info.TexCoordMin.Y);
- var uv1 = new Vector2(info.TexCoordMax.X, info.TexCoordMin.Y);
- var uv2 = new Vector2(info.TexCoordMax.X, info.TexCoordMax.Y);
- var uv3 = new Vector2(info.TexCoordMin.X, info.TexCoordMax.Y);
+ var uv0 = new Vector2(info.X + info.Key.SubpixelX, info.Y + info.Key.SubpixelY);
+ var uv1 = new Vector2(info.X + info.Width + info.Key.SubpixelX, info.Y + info.Key.SubpixelY);
+ var uv2 = new Vector2(info.X + info.Width + info.Key.SubpixelX, info.Y + info.Height + info.Key.SubpixelY);
+ var uv3 = new Vector2(info.X + info.Key.SubpixelX, info.Y + info.Height + info.Key.SubpixelY);
var cp0 = new Vector2(unscaledMinX, unscaledMinY);
var cp1 = new Vector2(unscaledMinX + unscaledWidth, unscaledMinY);
@@ -9609,7 +9736,7 @@ private void CompileTextCommand(RenderCommand cmd, ITextLayoutProvider? textNode
info.BearY,
glyphRenderWidth * fontScaleX,
glyphRenderHeight),
- TexCoords = new Vector4(info.TexCoordMin.X, info.TexCoordMin.Y, info.TexCoordMax.X, info.TexCoordMax.Y),
+ TexCoords = new Vector4(info.X, info.Y, info.X + info.Width, info.Y + info.Height),
Color = color,
ScaleBoldItalicUseMvp = new Vector4(
glyphAtlasScale,
@@ -9839,7 +9966,7 @@ private void CompileGlyphRunCommand(RenderCommand cmd, Matrix4x4 transform)
info.BearY,
glyphRenderWidth * fontScaleX,
glyphRenderHeight),
- TexCoords = new Vector4(info.TexCoordMin.X, info.TexCoordMin.Y, info.TexCoordMax.X, info.TexCoordMax.Y),
+ TexCoords = new Vector4(info.X, info.Y, info.X + info.Width, info.Y + info.Height),
Color = color,
ScaleBoldItalicUseMvp = new Vector4(
glyphAtlasScale,
@@ -12305,6 +12432,7 @@ private void RenderOffscreenCore(
_gradientStopsStorageBuffer.Write(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_activeGradientStops));
}
_pathAtlas.RasterizePendingPaths();
+ RefreshAtlasBindGroupsIfNeeded();
// Render target view for offscreen GpuTexture
var targetView = targetTexture.ViewPtr;
@@ -15354,4 +15482,48 @@ private void ExecuteMaskRenderPasses(CommandEncoder* encoder, bool isOffscreen)
return _context.Api.DeviceCreateBindGroupLayout(_context.Device, &desc);
}
+
+ private unsafe BindGroupLayout* CreateTextAtlasLayout()
+ {
+ var entries = stackalloc BindGroupLayoutEntry[3];
+ entries[0] = new BindGroupLayoutEntry
+ {
+ Binding = 0,
+ Visibility = ShaderStage.Fragment,
+ Sampler = new SamplerBindingLayout
+ {
+ Type = SamplerBindingType.Filtering
+ }
+ };
+ entries[1] = new BindGroupLayoutEntry
+ {
+ Binding = 1,
+ Visibility = ShaderStage.Fragment,
+ Texture = new TextureBindingLayout
+ {
+ SampleType = TextureSampleType.Float,
+ ViewDimension = TextureViewDimension.Dimension2D,
+ Multisampled = false
+ }
+ };
+ entries[2] = new BindGroupLayoutEntry
+ {
+ Binding = 2,
+ Visibility = ShaderStage.Fragment,
+ Texture = new TextureBindingLayout
+ {
+ SampleType = TextureSampleType.Float,
+ ViewDimension = TextureViewDimension.Dimension2D,
+ Multisampled = false
+ }
+ };
+
+ var desc = new BindGroupLayoutDescriptor
+ {
+ EntryCount = (UIntPtr)3,
+ Entries = entries
+ };
+
+ return _context.Api.DeviceCreateBindGroupLayout(_context.Device, &desc);
+ }
}
diff --git a/src/ProGPU.Scene/CompositorOptions.cs b/src/ProGPU.Scene/CompositorOptions.cs
index 72ca1bd6..c3dc73c0 100644
--- a/src/ProGPU.Scene/CompositorOptions.cs
+++ b/src/ProGPU.Scene/CompositorOptions.cs
@@ -1,4 +1,5 @@
using ProGPU.Vector;
+using ProGPU.Text;
namespace ProGPU.Scene;
@@ -8,6 +9,10 @@ public sealed record CompositorOptions
public uint GlyphAtlasSize { get; init; } = 2048;
+ public uint ColorGlyphAtlasSize { get; init; } = GlyphAtlas.DefaultColorAtlasSize;
+
+ public uint GlyphCoverageStagingBytes { get; init; } = GlyphAtlas.DefaultCoverageRingBufferSize;
+
public uint PathAtlasSize { get; init; } = 2048;
public long PathAtlasCpuCacheBudgetBytes { get; init; } =
@@ -29,6 +34,14 @@ internal void Validate()
{
throw new ArgumentOutOfRangeException(nameof(GlyphAtlasSize));
}
+ if (ColorGlyphAtlasSize <= 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(ColorGlyphAtlasSize));
+ }
+ if (GlyphCoverageStagingBytes < 256 || GlyphCoverageStagingBytes % 256 != 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(GlyphCoverageStagingBytes));
+ }
if (PathAtlasSize == 0)
{
throw new ArgumentOutOfRangeException(nameof(PathAtlasSize));
diff --git a/src/ProGPU.Scene/Visual.cs b/src/ProGPU.Scene/Visual.cs
index 925d015c..23ebdd2f 100644
--- a/src/ProGPU.Scene/Visual.cs
+++ b/src/ProGPU.Scene/Visual.cs
@@ -8,6 +8,16 @@
namespace ProGPU.Scene;
+///
+/// Marks a visual whose implementation already owns
+/// an immutable-until-invalidated command cache. The compositor must not retain a
+/// second copy of that command stream.
+///
+public interface IOwnedRenderCommandCache
+{
+ DrawingContext GetOrUpdateRenderCommandCache();
+}
+
public class Visual
{
private Vector2 _offset;
diff --git a/src/ProGPU.Tests.Headless/SamplePagesTests.cs b/src/ProGPU.Tests.Headless/SamplePagesTests.cs
index 65d27bc4..6234821c 100644
--- a/src/ProGPU.Tests.Headless/SamplePagesTests.cs
+++ b/src/ProGPU.Tests.Headless/SamplePagesTests.cs
@@ -80,7 +80,11 @@ private static void EnsureFontsAndStateLoaded()
AppState.GenerateLogItems();
}
- private void RunPageTest(FrameworkElement page, string pageName, TimeSpan? readbackTimeout = null)
+ private void RunPageTest(
+ FrameworkElement page,
+ string pageName,
+ TimeSpan? readbackTimeout = null,
+ Action? verifyPixels = null)
{
EnsureFontsAndStateLoaded();
@@ -113,6 +117,7 @@ private void RunPageTest(FrameworkElement page, string pageName, TimeSpan? readb
Console.WriteLine($"[SamplePagesTests] Page '{pageName}' rendered {nonBgCount} non-background pixels.");
Assert.True(nonBgCount > 100, $"Page '{pageName}' rendered blank or empty. Only {nonBgCount} non-background pixels found.");
+ verifyPixels?.Invoke(pixels);
string screenshotPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"page_{pageName.Replace(" ", "_").ToLower()}.png");
PngEncoder.SavePng(screenshotPath, pixels, window.Width, window.Height);
@@ -134,6 +139,33 @@ public void Test_SkiaSharpShimPage_Renders()
RunPageTest(SkiaSharpShimPage.Create(), "SkiaSharp Shim");
}
+ [Fact]
+ public void Test_Mesh3DViewerPage_Renders()
+ {
+ RunPageTest(
+ Mesh3DViewerPage.Create(),
+ "Mesh 3D Viewer",
+ verifyPixels: pixels =>
+ {
+ int brightHeaderPixels = 0;
+ for (int y = 0; y < 120; y++)
+ {
+ for (int x = 0; x < 240; x++)
+ {
+ int offset = (y * 1280 + x) * 4;
+ if (pixels[offset] > 140 && pixels[offset + 1] > 140 && pixels[offset + 2] > 140)
+ {
+ brightHeaderPixels++;
+ }
+ }
+ }
+
+ Assert.True(
+ brightHeaderPixels > 100,
+ $"The retained rich-text header disappeared after the stabilized second frame ({brightHeaderPixels} bright pixels).");
+ });
+ }
+
[Fact]
public void Test_FontIconAndPathIcon_Renders()
{
diff --git a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs
index 0f54db90..56ec442f 100644
--- a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs
+++ b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs
@@ -260,6 +260,31 @@ static float MinimumX(FlowDocument document)
Assert.True(MinimumX(Create(assignLeft: true)) < 1f);
}
+ [Fact]
+ public void FlowDocumentRetainsIdenticalMeasureArrangeLayout()
+ {
+ var document = new FlowDocument
+ {
+ Font = InterFontFamily.Regular,
+ FontSize = 18f,
+ ColumnCount = 1,
+ Padding = new Thickness(0f)
+ };
+ document.Blocks.Add(new Paragraph(new Microsoft.UI.Xaml.Documents.Run("Retained shaping and layout")));
+
+ document.Measure(new System.Numerics.Vector2(200f, 60f));
+ document.Arrange(new Rect(0f, 0f, 200f, 60f));
+ Assert.Equal(1UL, document.FlowLayoutPassCount);
+
+ document.Measure(new System.Numerics.Vector2(200f, 60f));
+ document.Arrange(new Rect(0f, 0f, 200f, 60f));
+ Assert.Equal(1UL, document.FlowLayoutPassCount);
+
+ document.Measure(new System.Numerics.Vector2(240f, 60f));
+ document.Arrange(new Rect(0f, 0f, 240f, 60f));
+ Assert.Equal(2UL, document.FlowLayoutPassCount);
+ }
+
[Fact]
public void RunFlowDirectionFeedsSharedLayoutAndRoundTripsHtmlAndRtf()
{
@@ -2696,6 +2721,41 @@ public void RichEditBoxVirtualizesLargeMultilineDocumentsByParagraph()
Assert.InRange(editor.LayoutSession.RealizedBlockCount, 1, 300);
}
+ [Fact]
+ public void RichEditBoxRepeatedLargeDocumentScrollKeepsManagedAllocationsBounded()
+ {
+ string text = string.Join('\n', Enumerable.Range(0, 5_000)
+ .Select(static index => $"paragraph {index:D4}: Latin office affinity — العربية مرحبا — 日本語かなカナ"));
+ var editor = new RichEditBox
+ {
+ Font = InterFontFamily.Regular,
+ FontSize = 16f,
+ Text = text
+ };
+ editor.Measure(new System.Numerics.Vector2(420f, 220f));
+ editor.Arrange(new Rect(0f, 0f, 420f, 220f));
+
+ int[] positions = [
+ text.IndexOf("paragraph 1000", StringComparison.Ordinal),
+ text.IndexOf("paragraph 2000", StringComparison.Ordinal),
+ text.IndexOf("paragraph 3000", StringComparison.Ordinal),
+ text.IndexOf("paragraph 4000", StringComparison.Ordinal)];
+ foreach (int position in positions)
+ editor.TextDocument.GetRange(position, position).ScrollIntoView(Microsoft.UI.Text.PointOptions.None);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ const int Iterations = 12;
+ for (int iteration = 0; iteration < Iterations; iteration++)
+ {
+ int position = positions[iteration % positions.Length];
+ editor.TextDocument.GetRange(position, position).ScrollIntoView(Microsoft.UI.Text.PointOptions.None);
+ }
+ long allocatedPerScroll = (GC.GetAllocatedBytesForCurrentThread() - before) / Iterations;
+
+ Assert.InRange(allocatedPerScroll, 0, 8_000_000);
+ Assert.InRange(editor.LayoutSession.RealizedBlockCount, 1, 300);
+ }
+
[Fact]
public void RichEditBoxRealizesTheScrolledViewportAndHitTestsInDocumentCoordinates()
{
diff --git a/src/ProGPU.Tests/BrowserWebGpuApiTests.cs b/src/ProGPU.Tests/BrowserWebGpuApiTests.cs
index 80acd593..492d51c2 100644
--- a/src/ProGPU.Tests/BrowserWebGpuApiTests.cs
+++ b/src/ProGPU.Tests/BrowserWebGpuApiTests.cs
@@ -60,6 +60,60 @@ public void ResourceCopyUploadAndSubmitUseOneTypedPacket()
ReadOpcodes(packets[0]));
}
+ [Fact]
+ public void CoverageBufferToTextureCopyUsesTypedBrowserProtocol()
+ {
+ var packets = new List();
+ using var api = new BrowserWebGpuApi(packet => packets.Add(packet.WrittenSpan.ToArray()));
+ var bufferDescriptor = new BufferDescriptor
+ {
+ Size = 1024,
+ Usage = BufferUsage.Storage | BufferUsage.CopySrc
+ };
+ var textureDescriptor = new TextureDescriptor
+ {
+ Size = new Extent3D { Width = 64, Height = 16, DepthOrArrayLayers = 1 },
+ Format = TextureFormat.R8Unorm,
+ Dimension = TextureDimension.Dimension2D,
+ MipLevelCount = 1,
+ SampleCount = 1,
+ Usage = TextureUsage.TextureBinding | TextureUsage.CopyDst
+ };
+ var sourceBuffer = api.DeviceCreateBuffer(BrowserWebGpuApi.DeviceHandle, &bufferDescriptor);
+ var destinationTexture = api.DeviceCreateTexture(BrowserWebGpuApi.DeviceHandle, &textureDescriptor);
+ var encoder = api.DeviceCreateCommandEncoder(BrowserWebGpuApi.DeviceHandle, null);
+ var source = new ImageCopyBuffer
+ {
+ Buffer = sourceBuffer,
+ Layout = new TextureDataLayout { Offset = 256, BytesPerRow = 256, RowsPerImage = 2 }
+ };
+ var destination = new ImageCopyTexture
+ {
+ Texture = destinationTexture,
+ MipLevel = 0,
+ Origin = new Origin3D { X = 3, Y = 5, Z = 0 },
+ Aspect = TextureAspect.All
+ };
+ var extent = new Extent3D { Width = 17, Height = 2, DepthOrArrayLayers = 1 };
+
+ api.CommandEncoderCopyBufferToTexture(encoder, &source, &destination, &extent);
+ var commands = api.CommandEncoderFinish(encoder, null);
+ api.QueueSubmit(BrowserWebGpuApi.QueueHandle, 1, &commands);
+
+ Assert.Single(packets);
+ Assert.Equal(
+ new[]
+ {
+ BrowserGpuOpcode.CreateBuffer,
+ BrowserGpuOpcode.CreateTexture,
+ BrowserGpuOpcode.CreateCommandEncoder,
+ BrowserGpuOpcode.CopyBufferToTexture,
+ BrowserGpuOpcode.FinishCommandEncoder,
+ BrowserGpuOpcode.Submit
+ },
+ ReadOpcodes(packets[0]));
+ }
+
[Fact]
public void SurfaceRenderPassIsEncodedThroughOrdinaryWebGpuFacade()
{
diff --git a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs
index 6b3f7097..7f8e6394 100644
--- a/src/ProGPU.Tests/CompositorReviewRegressionTests.cs
+++ b/src/ProGPU.Tests/CompositorReviewRegressionTests.cs
@@ -512,7 +512,7 @@ private static int GetPathAtlasPixelOffset(
{
var localX = checked((uint)(worldX - (int)info.MinX));
var localY = checked((uint)(worldY - (int)info.MinY));
- return checked((int)(((info.Y + localY) * atlasWidth + info.X + localX) * 4));
+ return checked((int)((info.Y + localY) * atlasWidth + info.X + localX));
}
private static byte ReadGlyphAtlasCoverage(
@@ -522,7 +522,7 @@ private static byte ReadGlyphAtlasCoverage(
uint localX,
uint localY)
{
- int offset = checked((int)(((info.Y + localY) * atlasWidth + info.X + localX) * 4));
+ int offset = checked((int)((info.Y + localY) * atlasWidth + info.X + localX));
return pixels[offset];
}
@@ -3117,6 +3117,108 @@ public void RichTextBlockReusesCommandsUntilContentIsInvalidated()
Assert.Single(updated.Commands, command => command.Type == RenderCommandType.DrawText).Brush);
}
+ [Fact]
+ public void OwnedRichTextCommandCacheRendersThroughCompositor()
+ {
+ var font = InterFontFamily.Regular;
+ using var window = new HeadlessWindow(320, 64);
+ var block = new RichTextBlock
+ {
+ Font = font,
+ FontSize = 20f,
+ Foreground = new SolidColorBrush(new Vector4(1f, 0f, 0f, 1f))
+ };
+ block.Inlines.Add(new Microsoft.UI.Xaml.Documents.Bold(
+ new Microsoft.UI.Xaml.Documents.Run("WebGPU 3D Mesh Viewer")));
+ window.Content = block;
+
+ window.Render();
+ window.Render();
+
+ Assert.Contains(
+ GetDrawCalls(window.Compositor),
+ drawCall => drawCall.Type == Compositor.DrawCallType.Text && drawCall.IndexCount > 0);
+ byte[] pixels = window.ReadPixels();
+ Assert.Contains(
+ Enumerable.Range(0, pixels.Length / 4),
+ pixel => pixels[pixel * 4] > 160 && pixels[pixel * 4 + 1] < 80);
+ }
+
+ [Fact]
+ public void RichTextSelectionOverlayReusesRetainedTextCommands()
+ {
+ var block = new RichTextBlock
+ {
+ Font = InterFontFamily.Regular,
+ FontSize = 20f,
+ Foreground = new SolidColorBrush(Vector4.One)
+ };
+ block.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run("Retained selection text"));
+ block.Measure(new Vector2(320f, 64f));
+ block.Arrange(new Rect(0f, 0f, 320f, 64f));
+
+ DrawingContext first = ((IOwnedRenderCommandCache)block).GetOrUpdateRenderCommandCache();
+ string[] firstText = first.Commands
+ .Where(static command => command.Type == RenderCommandType.DrawText)
+ .Select(static command => command.Text!)
+ .ToArray();
+ Assert.NotEmpty(firstText);
+ FieldInfo selectionCacheField = typeof(RichTextBlock).GetField(
+ "_selectionRenderCommandCache",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ Assert.Null(selectionCacheField.GetValue(block));
+
+ block.SelectionStart = 0;
+ block.SelectionLength = 8;
+ block.InvalidateSelectionRendering();
+ DrawingContext selected = ((IOwnedRenderCommandCache)block).GetOrUpdateRenderCommandCache();
+ string[] selectedText = selected.Commands
+ .Where(static command => command.Type == RenderCommandType.DrawText)
+ .Select(static command => command.Text!)
+ .ToArray();
+ int selectionIndex = selected.Commands.FindIndex(
+ static command => command.Type == RenderCommandType.DrawRect && command.Brush is not null);
+ int textIndex = selected.Commands.FindIndex(
+ static command => command.Type == RenderCommandType.DrawText);
+
+ Assert.Equal(firstText.Length, selectedText.Length);
+ for (int index = 0; index < firstText.Length; index++)
+ Assert.Same(firstText[index], selectedText[index]);
+ Assert.InRange(selectionIndex, 0, textIndex - 1);
+ Assert.NotNull(selectionCacheField.GetValue(block));
+ }
+
+ [Fact]
+ public void NestedOwnedRichTextCommandCacheRendersThroughCompositor()
+ {
+ var block = new RichTextBlock
+ {
+ Font = InterFontFamily.Regular,
+ FontSize = 20f,
+ Foreground = new SolidColorBrush(new Vector4(1f, 0f, 0f, 1f))
+ };
+ block.Inlines.Add(new Microsoft.UI.Xaml.Documents.Bold(
+ new Microsoft.UI.Xaml.Documents.Run("Nested rich text")));
+ var stack = new StackPanel { Padding = new Thickness(12f) };
+ stack.AddChild(block);
+ var scrollViewer = new ScrollViewer { Content = stack };
+ using var window = new HeadlessWindow(320, 64);
+ window.Content = scrollViewer;
+
+ window.Render();
+ window.Render();
+
+ DrawingContext ownedCommands = ((IOwnedRenderCommandCache)block).GetOrUpdateRenderCommandCache();
+ Assert.Contains(ownedCommands.Commands, command => command.Type == RenderCommandType.DrawText);
+ Assert.Contains(
+ GetDrawCalls(window.Compositor),
+ drawCall => drawCall.Type == Compositor.DrawCallType.Text && drawCall.IndexCount > 0);
+ byte[] pixels = window.ReadPixels();
+ Assert.Contains(
+ Enumerable.Range(0, pixels.Length / 4),
+ pixel => pixels[pixel * 4] > 160 && pixels[pixel * 4 + 1] < 80);
+ }
+
[Fact]
public void CompatibilityShimsReuseIsolatedCompositorScopes()
{
diff --git a/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs b/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs
index 1824aac4..c069e598 100644
--- a/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs
+++ b/src/ProGPU.Tests/DiagnosticsLoggingSourceTests.cs
@@ -328,9 +328,22 @@ public void GlyphAtlasUsesIndexedBatchAndOutlineTraversal()
Assert.Contains("var figureSegments = figure.Segments;", source, StringComparison.Ordinal);
Assert.Contains("for (int segmentIndex = 0; segmentIndex < figureSegments.Count; segmentIndex++)", source, StringComparison.Ordinal);
Assert.Contains("var segment = figureSegments[segmentIndex];", source, StringComparison.Ordinal);
- Assert.Contains("var fontGpuDataEnumerator = _fontGpuData.Values.GetEnumerator();", source, StringComparison.Ordinal);
- Assert.Contains("while (fontGpuDataEnumerator.MoveNext())", source, StringComparison.Ordinal);
- Assert.Contains("var data = fontGpuDataEnumerator.Current;", source, StringComparison.Ordinal);
+ Assert.Contains("private GpuBuffer _recordsBuffer;", source, StringComparison.Ordinal);
+ Assert.Contains("private GpuBuffer _segmentsBuffer;", source, StringComparison.Ordinal);
+ Assert.Contains("private static int GrowCapacity(int current, int required)", source, StringComparison.Ordinal);
+ Assert.Contains("capacity + Math.Max(1, capacity / 2)", source, StringComparison.Ordinal);
+ Assert.Contains("private bool TryGrowAtlas(bool colorBitmap", source, StringComparison.Ordinal);
+ Assert.Contains("CopyBaseLevelRegionFrom(oldTexture, currentSize, currentSize)", source, StringComparison.Ordinal);
+ Assert.Contains("private void FlushPendingOutlineUploads()", source, StringComparison.Ordinal);
+ Assert.Contains("ReleaseOversizedStagingCapacity(", source, StringComparison.Ordinal);
+ Assert.Contains("_uniformRingBuffer.WriteAlignedBytes(", source, StringComparison.Ordinal);
+ Assert.Contains("DefaultOutlineUploadChunkBytes = 256 * 1024", source, StringComparison.Ordinal);
+ Assert.Contains("HasDynamicOffset = true", source, StringComparison.Ordinal);
+ Assert.Contains("GetOrCreateRingBindGroup()", source, StringComparison.Ordinal);
+ Assert.Contains("GetOrCreateBatchComputePass()", source, StringComparison.Ordinal);
+ Assert.Contains("_batchCoverageCopies.Add(new PendingCoverageCopy(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("QueueWriteBuffer(_context.Queue, _uniformRingBuffer.BufferPtr", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ComputePipelineGetBindGroupLayout", source, StringComparison.Ordinal);
Assert.DoesNotContain("foreach (var buffer in _batchBuffers)", source, StringComparison.Ordinal);
Assert.DoesNotContain("foreach (var bg in _batchBindGroups)", source, StringComparison.Ordinal);
Assert.DoesNotContain("foreach (var figure in outline.Figures)", source, StringComparison.Ordinal);
@@ -1084,6 +1097,10 @@ public void PathAtlasCleanupAndRasterizationUsePooledBuffers()
Assert.Contains("private readonly PipelineLayout* _computePipelineLayout;", pathAtlas, StringComparison.Ordinal);
Assert.DoesNotContain("ComputePipelineGetBindGroupLayout", pathAtlas, StringComparison.Ordinal);
Assert.Contains("checked((uint)dispatch.Count)", pathAtlas, StringComparison.Ordinal);
+ Assert.Contains("public const uint DefaultRasterStagingChunkBytes = 256 * 1024;", pathAtlas, StringComparison.Ordinal);
+ Assert.Contains("int maxCoverageBytes = 0;", pathAtlas, StringComparison.Ordinal);
+ Assert.Contains("candidateEnd > DefaultRasterStagingChunkBytes", pathAtlas, StringComparison.Ordinal);
+ Assert.Contains("checked((uint)maxCoverageBytes)", pathAtlas, StringComparison.Ordinal);
Assert.DoesNotContain("var bindGroupsToRelease = new List();", pathAtlas, StringComparison.Ordinal);
Assert.Contains("for (int i = 0; i < _pendingPaths.Count; i++)", pathAtlas, StringComparison.Ordinal);
Assert.Contains("var info = _pendingPaths[i];", pathAtlas, StringComparison.Ordinal);
diff --git a/src/ProGPU.Tests/FontManagerTests.cs b/src/ProGPU.Tests/FontManagerTests.cs
index d19024df..8872498c 100644
--- a/src/ProGPU.Tests/FontManagerTests.cs
+++ b/src/ProGPU.Tests/FontManagerTests.cs
@@ -61,6 +61,76 @@ public void RegisteringACloserFaceInvalidatesPriorStyleMatch()
Assert.Same(InterFontFamily.Bold, manager.MatchTypeface(regular, boldStyle));
}
+ [Fact]
+ public void RepeatedCharacterFallbackUsesBoundedAllocationFreeMatchCache()
+ {
+ var manager = new FontManager();
+ manager.RegisterFont(
+ "ProGPU Cached Fallback",
+ new Lazy(() => InterFontFamily.Regular),
+ FontStyleRequest.Normal,
+ isFallback: true);
+
+ Assert.True(manager.TryMatchCharacter(
+ "ProGPU Cached Fallback",
+ FontStyleRequest.Normal,
+ null,
+ 'A',
+ out TtfFont? initial,
+ out ushort initialGlyph));
+ Assert.Same(InterFontFamily.Regular, initial);
+ Assert.NotEqual((ushort)0, initialGlyph);
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ bool allMatched = true;
+ for (int index = 0; index < 1_000; index++)
+ {
+ bool matched = manager.TryMatchCharacter(
+ "ProGPU Cached Fallback",
+ FontStyleRequest.Normal,
+ null,
+ 'A',
+ out TtfFont? repeated,
+ out ushort repeatedGlyph);
+ allMatched &= matched && ReferenceEquals(initial, repeated) && initialGlyph == repeatedGlyph;
+ }
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(allMatched);
+ Assert.InRange(allocated, 0, 4_096);
+ }
+
+ [Fact]
+ public void RegisteringFontInvalidatesCachedCharacterMatch()
+ {
+ var manager = new FontManager();
+ const string family = "ProGPU Dynamic Character Match";
+
+ Assert.True(manager.TryMatchCharacter(
+ family,
+ FontStyleRequest.Normal,
+ null,
+ 'A',
+ out TtfFont? original,
+ out _));
+ Assert.NotNull(original);
+
+ manager.RegisterFont(
+ family,
+ new Lazy(() => InterFontFamily.Regular),
+ FontStyleRequest.Normal);
+
+ Assert.True(manager.TryMatchCharacter(
+ family,
+ FontStyleRequest.Normal,
+ null,
+ 'A',
+ out TtfFont? updated,
+ out ushort glyph));
+ Assert.Same(InterFontFamily.Regular, updated);
+ Assert.NotEqual((ushort)0, glyph);
+ }
+
[Fact]
public void FailedRegisteredFaceIsNotRetried()
{
diff --git a/src/ProGPU.Tests/NotoFontFamilyTests.cs b/src/ProGPU.Tests/NotoFontFamilyTests.cs
index 9a579ebe..074605d7 100644
--- a/src/ProGPU.Tests/NotoFontFamilyTests.cs
+++ b/src/ProGPU.Tests/NotoFontFamilyTests.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using ProGPU.Fonts.Noto;
using ProGPU.Text;
+using ProGPU.Vector;
using Xunit;
namespace ProGPU.Tests;
@@ -29,6 +30,43 @@ public void JapaneseFaceCoversSampleCjk(char character)
Assert.NotEqual((ushort)0, NotoFontFamily.Japanese.GetGlyphIndex(character));
}
+ [Theory]
+ [InlineData('日')]
+ [InlineData('本')]
+ [InlineData('語')]
+ [InlineData('描')]
+ public void JapaneseCffOutlinesAreDecodedOnDemandWithoutWholeFontFallback(char character)
+ {
+ TtfFont font = NotoFontFamily.Japanese;
+ ushort glyph = font.GetGlyphIndex(character);
+
+ PathGeometry outline = Assert.IsType(font.GetGlyphOutline(glyph));
+
+ Assert.NotEmpty(outline.Figures);
+ Assert.Contains(outline.Figures.SelectMany(static figure => figure.Segments),
+ static segment => segment is LineSegment or CubicBezierSegment);
+ Assert.False(font.IsCffFallbackLoaded);
+ }
+
+ [Theory]
+ [InlineData('A')]
+ [InlineData('日')]
+ [InlineData('か')]
+ [InlineData('ナ')]
+ public void JapaneseCffOnDemandOutlinesMatchEstablishedFallback(char character)
+ {
+ byte[] fontData = NotoFontFamily.Japanese.FontData.ToArray();
+ var compactFont = new TtfFont(fontData);
+ var fallbackFont = new TtfFont(fontData);
+ ushort glyph = compactFont.GetGlyphIndex(character);
+
+ PathGeometry compact = Assert.IsType(compactFont.GetGlyphOutline(glyph));
+ PathGeometry fallback = Assert.IsType(
+ fallbackFont.GetCffFallbackGlyphOutlineForTesting(glyph));
+
+ AssertGeometryEqual(fallback, compact);
+ }
+
[Theory]
[InlineData('♠')]
[InlineData('♦')]
@@ -66,4 +104,48 @@ public void RegisteredFallbacksMatchByScriptAndCharacter()
Assert.Same(NotoFontFamily.Symbols, symbols);
Assert.NotEqual((ushort)0, symbolGlyph);
}
+
+ private static void AssertGeometryEqual(PathGeometry expected, PathGeometry actual)
+ {
+ Assert.Equal(expected.FillRule, actual.FillRule);
+ Assert.Equal(expected.Figures.Count, actual.Figures.Count);
+ for (int figureIndex = 0; figureIndex < expected.Figures.Count; figureIndex++)
+ {
+ PathFigure expectedFigure = expected.Figures[figureIndex];
+ PathFigure actualFigure = actual.Figures[figureIndex];
+ AssertPointEqual(expectedFigure.StartPoint, actualFigure.StartPoint);
+ Assert.Equal(expectedFigure.IsClosed, actualFigure.IsClosed);
+ Assert.Equal(expectedFigure.Segments.Count, actualFigure.Segments.Count);
+ for (int segmentIndex = 0; segmentIndex < expectedFigure.Segments.Count; segmentIndex++)
+ {
+ PathSegment expectedSegment = expectedFigure.Segments[segmentIndex];
+ PathSegment actualSegment = actualFigure.Segments[segmentIndex];
+ Assert.Equal(expectedSegment.GetType(), actualSegment.GetType());
+ switch (expectedSegment, actualSegment)
+ {
+ case (LineSegment expectedLine, LineSegment actualLine):
+ AssertPointEqual(expectedLine.Point, actualLine.Point);
+ break;
+ case (QuadraticBezierSegment expectedQuadratic, QuadraticBezierSegment actualQuadratic):
+ AssertPointEqual(expectedQuadratic.ControlPoint, actualQuadratic.ControlPoint);
+ AssertPointEqual(expectedQuadratic.Point, actualQuadratic.Point);
+ break;
+ case (CubicBezierSegment expectedCubic, CubicBezierSegment actualCubic):
+ AssertPointEqual(expectedCubic.ControlPoint1, actualCubic.ControlPoint1);
+ AssertPointEqual(expectedCubic.ControlPoint2, actualCubic.ControlPoint2);
+ AssertPointEqual(expectedCubic.Point, actualCubic.Point);
+ break;
+ default:
+ throw new Xunit.Sdk.XunitException(
+ $"Unexpected CFF segment type {expectedSegment.GetType().Name}.");
+ }
+ }
+ }
+ }
+
+ private static void AssertPointEqual(System.Numerics.Vector2 expected, System.Numerics.Vector2 actual)
+ {
+ Assert.InRange(MathF.Abs(expected.X - actual.X), 0f, 0.001f);
+ Assert.InRange(MathF.Abs(expected.Y - actual.Y), 0f, 0.001f);
+ }
}
diff --git a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs
index 5f1220e8..c066ea9e 100644
--- a/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs
+++ b/src/ProGPU.Tests/SamplePerformanceRegressionTests.cs
@@ -2,6 +2,7 @@
using System.Numerics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using ProGPU.Backend;
using ProGPU.Fonts.Inter;
using ProGPU.Fonts.Noto;
using ProGPU.Scene;
@@ -9,12 +10,249 @@
using ProGPU.Tests.Headless;
using ProGPU.Vector;
using ProGPU.Virtualization;
+using Silk.NET.WebGPU;
using Xunit;
namespace ProGPU.Tests;
public sealed class SamplePerformanceRegressionTests
{
+ [Fact]
+ public void CoverageAtlasesUseCompactSingleChannelResidency()
+ {
+ using var glyphAtlas = new GlyphAtlas(
+ HeadlessWindow.Shared.Context,
+ atlasSize: 256,
+ colorAtlasSize: 64,
+ coverageRingBufferSize: 256 * 1024);
+ using var pathAtlas = new PathAtlas(
+ HeadlessWindow.Shared.Context,
+ atlasSize: 256);
+
+ Assert.Equal(TextureFormat.R8Unorm, glyphAtlas.AtlasTexture.Format);
+ Assert.Equal(TextureFormat.Rgba8Unorm, glyphAtlas.ColorAtlasTexture.Format);
+ Assert.Equal(TextureFormat.R8Unorm, pathAtlas.AtlasTexture.Format);
+ Assert.Equal(65_536UL + 16_384UL, glyphAtlas.PersistentTextureBytes);
+ Assert.Equal(65_536UL, pathAtlas.PersistentTextureBytes);
+ Assert.Equal(256u, GpuCoverageUpload.GetBytesPerRow(1));
+ Assert.Equal(512u, GpuCoverageUpload.GetBytesPerRow(257));
+ }
+
+ [Fact]
+ public void SampleAtlasConfigurationStartsAtThreePercentOfLegacyResidency()
+ {
+ const ulong glyphSize = 2_560;
+ const ulong pathSize = 2_048;
+ const ulong legacyBytes =
+ glyphSize * glyphSize * 4UL +
+ pathSize * pathSize * 4UL;
+ const ulong initialBytes =
+ GlyphAtlas.DefaultInitialAtlasSize * GlyphAtlas.DefaultInitialAtlasSize +
+ GlyphAtlas.DefaultInitialColorAtlasSize * GlyphAtlas.DefaultInitialColorAtlasSize * 4UL +
+ GlyphAtlas.DefaultCoverageRingBufferSize +
+ PathAtlas.DefaultInitialAtlasSize * PathAtlas.DefaultInitialAtlasSize;
+
+ Assert.Equal(42_991_616UL, legacyBytes);
+ Assert.Equal(1_048_576UL, initialBytes);
+ Assert.True(initialBytes * 100UL / legacyBytes <= 3UL);
+ }
+
+ [Fact]
+ public void CoverageAtlasesGrowWithoutMovingTexelCoordinatesOrChangingGeneration()
+ {
+ using var pathAtlas = new PathAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024);
+ PathAtlas.PathInfo first = pathAtlas.GetOrCreatePath(
+ PrimitivePathGeometry.CreateRectangle(0f, 0f, 72f, 72f),
+ scale: 1f);
+ pathAtlas.RasterizePendingPaths();
+ byte[] initialPixels = pathAtlas.AtlasTexture.ReadPixels();
+ 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));
+ Assert.True(initialPixels[initialCoverageOffset] > 200);
+ ulong generation = pathAtlas.Generation;
+ ulong revision = pathAtlas.TextureRevision;
+
+ for (int index = 1; index < 80 && pathAtlas.AtlasSize == PathAtlas.DefaultInitialAtlasSize; index++)
+ {
+ _ = pathAtlas.GetOrCreatePath(
+ PrimitivePathGeometry.CreateRectangle(index * 100f, 0f, 72f, 72f),
+ scale: 1f);
+ }
+
+ PathAtlas.PathInfo repeated = pathAtlas.GetOrCreatePath(
+ PrimitivePathGeometry.CreateRectangle(0f, 0f, 72f, 72f),
+ scale: 1f);
+ Assert.Equal(1024u, pathAtlas.AtlasSize);
+ Assert.True(pathAtlas.TextureRevision > revision);
+ Assert.Equal(generation, pathAtlas.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.TexCoordMin.X,
+ 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));
+ Assert.Equal(initialPixels[initialCoverageOffset], grownPixels[grownCoverageOffset]);
+ }
+
+ [Fact]
+ public void GlyphAtlasGrowthPreservesResidentCoverageAndStableTexelCoordinates()
+ {
+ TtfFont font = LoadTestFont();
+ using var atlas = new GlyphAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024);
+ ushort glyph = font.GetGlyphIndex('A');
+ atlas.BeginBatch();
+ GlyphInfo first;
+ try
+ {
+ first = atlas.GetOrCreateGlyphByIndex(font, glyph, 128f, subpixelX: 0);
+ }
+ finally
+ {
+ atlas.EndBatch();
+ }
+
+ byte[] initialPixels = atlas.AtlasTexture.ReadPixels();
+ uint sampleX = 0;
+ uint sampleY = 0;
+ byte expectedCoverage = 0;
+ for (uint y = 0; y < first.Height; y++)
+ {
+ for (uint x = 0; x < first.Width; x++)
+ {
+ byte coverage = initialPixels[(first.Y + y) * atlas.AtlasSize + first.X + x];
+ if (coverage > expectedCoverage)
+ {
+ expectedCoverage = coverage;
+ sampleX = x;
+ sampleY = y;
+ }
+ }
+ }
+ Assert.True(expectedCoverage > 0);
+
+ ulong generation = atlas.Generation;
+ atlas.BeginBatch();
+ try
+ {
+ for (byte phase = 1; phase < 64 && atlas.AtlasSize == GlyphAtlas.DefaultInitialAtlasSize; phase++)
+ {
+ _ = atlas.GetOrCreateGlyphByIndex(font, glyph, 128f, phase);
+ }
+ }
+ finally
+ {
+ atlas.EndBatch();
+ }
+
+ GlyphInfo repeated = atlas.GetOrCreateGlyphByIndex(font, glyph, 128f, subpixelX: 0);
+ Assert.Equal(1024u, atlas.AtlasSize);
+ Assert.True(atlas.TextureRevision > 0);
+ Assert.Equal(generation, atlas.Generation);
+ Assert.Equal(first.X, repeated.X);
+ Assert.Equal(first.Y, repeated.Y);
+ byte[] grownPixels = atlas.AtlasTexture.ReadPixels();
+ Assert.Equal(
+ expectedCoverage,
+ grownPixels[(repeated.Y + sampleY) * atlas.AtlasSize + repeated.X + sampleX]);
+ }
+
+ [Fact]
+ public void GlyphAtlasCoalescesFirstUseQueueWritesAcrossACompilationBatch()
+ {
+ TtfFont font = LoadTestFont();
+ using var atlas = new GlyphAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024);
+ const string text = "GPU text batching";
+ int uniqueGlyphCount = text.Distinct().Count(character => character != ' ');
+ ulong outlineWritesBefore = atlas.OutlineUploadWriteCount;
+ ulong uniformWritesBefore = atlas.UniformUploadWriteCount;
+ ulong submissionsBefore = atlas.RasterBatchSubmissionCount;
+ ulong bindGroupsBefore = atlas.RasterBindGroupCreationCount;
+ ulong computePassesBefore = atlas.RasterComputePassCount;
+
+ atlas.BeginBatch();
+ try
+ {
+ foreach (char character in text)
+ {
+ if (character != ' ')
+ {
+ _ = atlas.GetOrCreateGlyph(font, character, 32f);
+ }
+ }
+ }
+ finally
+ {
+ atlas.EndBatch();
+ }
+
+ Assert.Equal(uniqueGlyphCount, atlas.LastBatchNewGlyphCount);
+ Assert.InRange(atlas.OutlineUploadWriteCount - outlineWritesBefore, 1UL, 2UL);
+ Assert.Equal(1UL, atlas.UniformUploadWriteCount - uniformWritesBefore);
+ Assert.Equal(1UL, atlas.RasterBatchSubmissionCount - submissionsBefore);
+ Assert.Equal(1UL, atlas.RasterBindGroupCreationCount - bindGroupsBefore);
+ Assert.Equal(1UL, atlas.RasterComputePassCount - computePassesBefore);
+
+ atlas.BeginBatch();
+ atlas.EndBatch();
+ Assert.Equal(submissionsBefore + 1UL, atlas.RasterBatchSubmissionCount);
+ }
+
+ [Fact]
+ public void PathRasterizationReusesBoundedCoverageChunks()
+ {
+ using var atlas = new PathAtlas(HeadlessWindow.Shared.Context, atlasSize: 1024);
+ for (int index = 0; index < 24; index++)
+ {
+ _ = atlas.GetOrCreatePath(
+ PrimitivePathGeometry.CreateRectangle(index * 128f, 0f, 96f, 96f),
+ scale: 1f);
+ }
+
+ atlas.RasterizePendingPaths();
+
+ Assert.InRange(
+ atlas.LastRasterStagingBytes,
+ 1u,
+ PathAtlas.DefaultRasterStagingChunkBytes);
+ Assert.Equal(atlas.LastRasterStagingBytes, atlas.PeakRasterStagingBytes);
+ }
+
+ [Fact]
+ public void TextControlsDeclareTheirOwnedCommandCaches()
+ {
+ Assert.IsAssignableFrom(new RichTextBlock());
+ Assert.IsAssignableFrom(new MarkdownTextBlock());
+ Assert.IsAssignableFrom(new FlowDocument());
+ }
+
+ [Fact]
+ public void LolsPoolRetainsTextAndBrushStateWithoutRebuildingRuns()
+ {
+ string factory = File.ReadAllText(FindRepoFile(
+ "src", "ProGPU.Samples", "Helpers", "TextDisplayFactory.cs"));
+ string page = File.ReadAllText(FindRepoFile(
+ "src", "ProGPU.Samples", "Pages", "LolsPage.cs"));
+
+ Assert.Contains("private sealed class PooledTextDisplay", factory, StringComparison.Ordinal);
+ Assert.Contains("public Run Run { get; }", factory, StringComparison.Ordinal);
+ Assert.Contains("public SolidColorBrush ForegroundBrush { get; }", factory, StringComparison.Ordinal);
+ Assert.DoesNotContain("textBlock.Inlines.Clear()", factory, StringComparison.Ordinal);
+ Assert.DoesNotContain("new Run(text)", factory, StringComparison.Ordinal);
+ Assert.Contains("SetForegroundColor(textControl, foreground)", page, StringComparison.Ordinal);
+ Assert.DoesNotContain("new SolidColorBrush(new Vector4", page, StringComparison.Ordinal);
+ Assert.Contains("_canvas.BringChildToFront(oldest)", page, StringComparison.Ordinal);
+ Assert.Contains("if (addToCanvas) _canvas.AddChild(textControl)", page, StringComparison.Ordinal);
+ }
+
[Fact]
public void ScrollOffsetsTranslateRetainedContentWithoutRearrangingIt()
{
diff --git a/src/ProGPU.Tests/SampleProjectSplitTests.cs b/src/ProGPU.Tests/SampleProjectSplitTests.cs
index 2a056df4..554f7fd0 100644
--- a/src/ProGPU.Tests/SampleProjectSplitTests.cs
+++ b/src/ProGPU.Tests/SampleProjectSplitTests.cs
@@ -179,6 +179,8 @@ public void BrowserBenchmarkQueryUsesExplicitEnvironmentAllowList()
Assert.Contains("benchmarkPage: 'PROGPU_SAMPLE_BENCHMARK_PAGE'", browserAsset, StringComparison.Ordinal);
Assert.Contains("benchmarkMeasureFrames: 'PROGPU_SAMPLE_BENCHMARK_MEASURE_FRAMES'", browserAsset, StringComparison.Ordinal);
Assert.Contains("benchmarkScrollStep: 'PROGPU_SAMPLE_BENCHMARK_SCROLL_STEP'", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("benchmarkPreconditionPages: 'PROGPU_SAMPLE_BENCHMARK_PRECONDITION_PAGES'", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("benchmarkPreconditionFrames: 'PROGPU_SAMPLE_BENCHMARK_PRECONDITION_FRAMES'", browserAsset, StringComparison.Ordinal);
Assert.Contains("function readBenchmarkEnvironment()", browserAsset, StringComparison.Ordinal);
Assert.Contains("dotnet.withEnvironmentVariables(readBenchmarkEnvironment()).create()", browserAsset, StringComparison.Ordinal);
Assert.DoesNotContain("Object.fromEntries(query", browserAsset, StringComparison.Ordinal);
@@ -198,8 +200,16 @@ public void BrowserFilePickerUsesCancellationSafeDirectByteTransfer()
Assert.Contains("DispatchImmediatePointer", browserInput, StringComparison.Ordinal);
Assert.Contains("heap.set(bytes, destination);", browserAsset, StringComparison.Ordinal);
Assert.DoesNotContain("bytesToBase64", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("globalThis.showOpenFilePicker", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("globalThis.showSaveFilePicker", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("globalThis.showDirectoryPicker", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("input.webkitdirectory = true;", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("handle.createWritable()", browserAsset, StringComparison.Ordinal);
+ Assert.Contains("const bytes = heap.slice(source, source + length);", browserAsset, StringComparison.Ordinal);
Assert.Contains("CopyPickedStorage((nint)destination, length)", storageServices, StringComparison.Ordinal);
Assert.Contains("ClearPickedStorage();", storageServices, StringComparison.Ordinal);
+ Assert.Contains("WritePickedStorageText(token, text)", storageServices, StringComparison.Ordinal);
+ Assert.Contains("WritePickedStorageBytes(token, (nint)source, bytes.Length)", storageServices, StringComparison.Ordinal);
}
[Fact]
diff --git a/src/ProGPU.Tests/SfntFontFaceTests.cs b/src/ProGPU.Tests/SfntFontFaceTests.cs
index e7265e54..ee63d29d 100644
--- a/src/ProGPU.Tests/SfntFontFaceTests.cs
+++ b/src/ProGPU.Tests/SfntFontFaceTests.cs
@@ -291,6 +291,8 @@ public void GlyphAtlasUploadsSbixAsIntrinsicColorBitmap()
Assert.Equal(2f, info.RasterScale);
Assert.True(info.TexCoordMax.X > info.TexCoordMin.X);
Assert.True(info.TexCoordMax.Y > info.TexCoordMin.Y);
+ Assert.Contains(atlas.ColorAtlasTexture.ReadPixels(), static value => value != 0);
+ Assert.All(atlas.AtlasTexture.ReadPixels(), static value => Assert.Equal(0, value));
}
[Theory]
@@ -344,6 +346,7 @@ public void GlyphAtlasUploadsCbdtUsingBitmapBearings()
Assert.Equal(3f, info.BearX);
Assert.Equal(-4f, info.BearY);
Assert.Equal(2f, info.RasterScale);
+ Assert.Contains(atlas.ColorAtlasTexture.ReadPixels(), static value => value != 0);
}
[Theory]
@@ -499,6 +502,7 @@ public void TtfFontLoadsCffOutlinesFromWoffContainer()
Assert.Equal((short)0, yMin);
Assert.Equal((short)400, xMax);
Assert.Equal((short)750, yMax);
+ Assert.False(font.IsCffFallbackLoaded);
Assert.Equal("OTTO", Encoding.ASCII.GetString(font.FontData.Span[..4]));
}
diff --git a/src/ProGPU.Tests/ShaderResourceTests.cs b/src/ProGPU.Tests/ShaderResourceTests.cs
index 19143523..68785bcb 100644
--- a/src/ProGPU.Tests/ShaderResourceTests.cs
+++ b/src/ProGPU.Tests/ShaderResourceTests.cs
@@ -107,6 +107,31 @@ public void VectorShaderMapsSkiaSweepAngleRangesBeforeTiling()
StringComparison.Ordinal);
}
+ [Fact]
+ public void VectorPathAtlasDerivativesExecuteBeforeShapeControlFlow()
+ {
+ Assert.Contains(
+ "let atlasCoordDx = dpdx(input.texCoord);",
+ Shaders.VectorShader,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "let pathAtlasCoordDx = atlasCoordDx / pathAtlasSize;",
+ Shaders.VectorShader,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "let pathAtlasCoordDy = atlasCoordDy / pathAtlasSize;",
+ Shaders.VectorShader,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "dpdx(pathAtlasCoord)",
+ Shaders.VectorShader,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "dpdy(pathAtlasCoord)",
+ Shaders.VectorShader,
+ StringComparison.Ordinal);
+ }
+
[Fact]
public void EveryShaderSourceIsEmbeddedAndDocumentsItsCostModel()
{
diff --git a/src/ProGPU.Tests/WindowsDpiAwarenessTests.cs b/src/ProGPU.Tests/WindowsDpiAwarenessTests.cs
index fdcc9360..a51973c2 100644
--- a/src/ProGPU.Tests/WindowsDpiAwarenessTests.cs
+++ b/src/ProGPU.Tests/WindowsDpiAwarenessTests.cs
@@ -41,6 +41,15 @@ public void SilkWindowsReconfigureSwapchainOnFramebufferResize()
Assert.Contains(".FramebufferResize += OnFramebufferResize", window, StringComparison.Ordinal);
Assert.Contains("!wgpuContext.TryReconfigureIfNeeded((uint)framebufferSize.X, (uint)framebufferSize.Y)", window, StringComparison.Ordinal);
+ string framebufferCallback = window[
+ window.IndexOf("private void OnFramebufferResize", StringComparison.Ordinal)..
+ window.IndexOf("private Vector2D GetCurrentFramebufferSize", StringComparison.Ordinal)];
+ Assert.DoesNotContain("ConfigureSwapChain", framebufferCallback, StringComparison.Ordinal);
+ Assert.Contains("RenderFrame(0d);", framebufferCallback, StringComparison.Ordinal);
+ Assert.Contains("_suppressNextScheduledRender = true;", framebufferCallback, StringComparison.Ordinal);
+ Assert.Contains("_liveResizeRenderedVersion = _renderRoot.ChangeVersion;", framebufferCallback, StringComparison.Ordinal);
+ Assert.Contains("if (_suppressNextScheduledRender)", window, StringComparison.Ordinal);
+ Assert.Contains("_renderRoot.Invalidate();", framebufferCallback, StringComparison.Ordinal);
Assert.Contains("return;", window, StringComparison.Ordinal);
Assert.Contains("DisplayScaleResolver.ResolveWindowDisplayScale(_silkWindow, monitorScale)", window, StringComparison.Ordinal);
Assert.Contains("(uint)framebufferSize.X", window, StringComparison.Ordinal);
diff --git a/src/ProGPU.Text/Cff1OutlineSource.cs b/src/ProGPU.Text/Cff1OutlineSource.cs
new file mode 100644
index 00000000..30e28060
--- /dev/null
+++ b/src/ProGPU.Text/Cff1OutlineSource.cs
@@ -0,0 +1,1137 @@
+using System.Globalization;
+using System.Numerics;
+using ProGPU.Vector;
+
+namespace ProGPU.Text;
+
+///
+/// Reads CFF 1 INDEX/DICT data once and evaluates only requested Type 2 charstrings.
+/// Construction is O(G + R + F) time and storage for glyph, subroutine, and font-dictionary
+/// offsets. A glyph request is O(B + S) time and O(1) temporary storage for B executed
+/// charstring bytes and S emitted segments; no unrequested glyph program is decoded.
+///
+internal sealed class Cff1OutlineSource
+{
+ private readonly ReadOnlyMemory _data;
+ private readonly CffIndex _charStrings;
+ private readonly CffIndex _globalSubroutines;
+ private readonly CffIndex _defaultLocalSubroutines;
+ private readonly CffIndex _fontDictionaries;
+ private readonly CffIndex?[]? _fontLocalSubroutines;
+ private readonly FdSelect? _fdSelect;
+
+ private Cff1OutlineSource(
+ ReadOnlyMemory data,
+ CffIndex charStrings,
+ CffIndex globalSubroutines,
+ CffIndex defaultLocalSubroutines,
+ CffIndex fontDictionaries,
+ FdSelect? fdSelect)
+ {
+ _data = data;
+ _charStrings = charStrings;
+ _globalSubroutines = globalSubroutines;
+ _defaultLocalSubroutines = defaultLocalSubroutines;
+ _fontDictionaries = fontDictionaries;
+ _fdSelect = fdSelect;
+ _fontLocalSubroutines = fontDictionaries.Count == 0
+ ? null
+ : new CffIndex?[fontDictionaries.Count];
+ }
+
+ public int GlyphCount => _charStrings.Count;
+
+ public static Cff1OutlineSource? TryCreate(ReadOnlyMemory data, int expectedGlyphCount)
+ {
+ ReadOnlySpan span = data.Span;
+ if (span.Length < 4 || span[0] != 1)
+ {
+ return null;
+ }
+
+ int cursor = span[2];
+ if (cursor < 4 || cursor > span.Length ||
+ !CffIndex.TryRead(data, ref cursor, out _) ||
+ !CffIndex.TryRead(data, ref cursor, out CffIndex topDictionaries) ||
+ topDictionaries.Count != 1 ||
+ !CffIndex.TryRead(data, ref cursor, out _) ||
+ !CffIndex.TryRead(data, ref cursor, out CffIndex globalSubroutines) ||
+ !TryReadTopDictionary(topDictionaries.GetItem(0), out TopDictionary top) ||
+ !CffIndex.TryReadAt(data, top.CharStringsOffset, out CffIndex charStrings) ||
+ charStrings.Count == 0 ||
+ expectedGlyphCount != 0 && charStrings.Count != expectedGlyphCount)
+ {
+ return null;
+ }
+
+ CffIndex defaultLocalSubroutines = default;
+ if (top.PrivateSize > 0 &&
+ !TryReadLocalSubroutines(data, top.PrivateOffset, top.PrivateSize, out defaultLocalSubroutines))
+ {
+ return null;
+ }
+
+ CffIndex fontDictionaries = default;
+ FdSelect? fdSelect = null;
+ if (top.FontDictionaryOffset != 0 || top.FdSelectOffset != 0)
+ {
+ if (top.FontDictionaryOffset == 0 || top.FdSelectOffset == 0 ||
+ !CffIndex.TryReadAt(data, top.FontDictionaryOffset, out fontDictionaries) ||
+ fontDictionaries.Count == 0 ||
+ !FdSelect.TryRead(span, top.FdSelectOffset, charStrings.Count, fontDictionaries.Count, out fdSelect))
+ {
+ return null;
+ }
+ }
+
+ return new Cff1OutlineSource(
+ data,
+ charStrings,
+ globalSubroutines,
+ defaultLocalSubroutines,
+ fontDictionaries,
+ fdSelect);
+ }
+
+ public bool TryGetOutline(ushort glyphIndex, out PathGeometry? geometry)
+ {
+ geometry = null;
+ if (glyphIndex >= _charStrings.Count)
+ {
+ return false;
+ }
+
+ CffIndex localSubroutines = _defaultLocalSubroutines;
+ if (_fdSelect is not null)
+ {
+ int fontDictionary = _fdSelect.GetFontDictionary(glyphIndex);
+ if ((uint)fontDictionary >= _fontDictionaries.Count)
+ {
+ return false;
+ }
+ localSubroutines = GetFontLocalSubroutines(fontDictionary);
+ }
+
+ var builder = new OutlineBuilder();
+ Span operands = stackalloc double[513];
+ Span transient = stackalloc double[32];
+ var evaluator = new Type2Evaluator(
+ builder,
+ _globalSubroutines,
+ localSubroutines,
+ operands,
+ transient,
+ (uint)glyphIndex + 1u);
+ if (!evaluator.TryEvaluate(_charStrings.GetItem(glyphIndex)))
+ {
+ return false;
+ }
+
+ builder.EndGlyph();
+ geometry = builder.Geometry.Figures.Count == 0 ? null : builder.Geometry;
+ return true;
+ }
+
+ private CffIndex GetFontLocalSubroutines(int fontDictionary)
+ {
+ CffIndex? cached = _fontLocalSubroutines![fontDictionary];
+ if (cached.HasValue)
+ {
+ return cached.Value;
+ }
+
+ CffIndex result = default;
+ if (TryReadPrivateDictionary(
+ _fontDictionaries.GetItem(fontDictionary),
+ out int privateSize,
+ out int privateOffset) &&
+ privateSize > 0)
+ {
+ _ = TryReadLocalSubroutines(_data, privateOffset, privateSize, out result);
+ }
+ _fontLocalSubroutines[fontDictionary] = result;
+ return result;
+ }
+
+ private static bool TryReadLocalSubroutines(
+ ReadOnlyMemory data,
+ int privateOffset,
+ int privateSize,
+ out CffIndex subroutines)
+ {
+ subroutines = default;
+ if (privateOffset < 0 || privateSize < 0 || privateOffset > data.Length - privateSize)
+ {
+ return false;
+ }
+ if (!TryReadPrivateSubroutineOffset(
+ data.Span.Slice(privateOffset, privateSize),
+ out int relativeOffset))
+ {
+ return true;
+ }
+ return relativeOffset >= 0 &&
+ relativeOffset <= data.Length - privateOffset &&
+ CffIndex.TryReadAt(data, privateOffset + relativeOffset, out subroutines);
+ }
+
+ private static bool TryReadTopDictionary(ReadOnlySpan data, out TopDictionary result)
+ {
+ result = default;
+ Span operands = stackalloc double[48];
+ int count = 0;
+ int cursor = 0;
+ while (cursor < data.Length)
+ {
+ byte value = data[cursor++];
+ if (TryReadDictionaryNumber(data, ref cursor, value, out double number))
+ {
+ if (count >= operands.Length) return false;
+ operands[count++] = number;
+ continue;
+ }
+
+ int op = value;
+ if (value == 12)
+ {
+ if (cursor >= data.Length) return false;
+ op = 0x0c00 | data[cursor++];
+ }
+ switch (op)
+ {
+ case 17 when count >= 1:
+ result.CharStringsOffset = ToOffset(operands[count - 1]);
+ break;
+ case 18 when count >= 2:
+ result.PrivateSize = ToOffset(operands[count - 2]);
+ result.PrivateOffset = ToOffset(operands[count - 1]);
+ break;
+ case 0x0c24 when count >= 1:
+ result.FontDictionaryOffset = ToOffset(operands[count - 1]);
+ break;
+ case 0x0c25 when count >= 1:
+ result.FdSelectOffset = ToOffset(operands[count - 1]);
+ break;
+ }
+ count = 0;
+ }
+ return result.CharStringsOffset > 0;
+ }
+
+ private static bool TryReadPrivateDictionary(
+ ReadOnlySpan data,
+ out int privateSize,
+ out int privateOffset)
+ {
+ privateSize = privateOffset = 0;
+ Span operands = stackalloc double[48];
+ int count = 0;
+ int cursor = 0;
+ while (cursor < data.Length)
+ {
+ byte value = data[cursor++];
+ if (TryReadDictionaryNumber(data, ref cursor, value, out double number))
+ {
+ if (count >= operands.Length) return false;
+ operands[count++] = number;
+ continue;
+ }
+ int op = value;
+ if (value == 12)
+ {
+ if (cursor >= data.Length) return false;
+ op = 0x0c00 | data[cursor++];
+ }
+ if (op == 18 && count >= 2)
+ {
+ privateSize = ToOffset(operands[count - 2]);
+ privateOffset = ToOffset(operands[count - 1]);
+ }
+ count = 0;
+ }
+ return privateSize >= 0 && privateOffset >= 0;
+ }
+
+ private static bool TryReadPrivateSubroutineOffset(ReadOnlySpan data, out int offset)
+ {
+ offset = 0;
+ Span operands = stackalloc double[48];
+ int count = 0;
+ int cursor = 0;
+ while (cursor < data.Length)
+ {
+ byte value = data[cursor++];
+ if (TryReadDictionaryNumber(data, ref cursor, value, out double number))
+ {
+ if (count >= operands.Length) return false;
+ operands[count++] = number;
+ continue;
+ }
+ if (value == 12)
+ {
+ if (cursor >= data.Length) return false;
+ cursor++;
+ }
+ else if (value == 19 && count >= 1)
+ {
+ offset = ToOffset(operands[count - 1]);
+ return offset >= 0;
+ }
+ count = 0;
+ }
+ return false;
+ }
+
+ private static bool TryReadDictionaryNumber(
+ ReadOnlySpan data,
+ ref int cursor,
+ byte first,
+ out double value)
+ {
+ value = 0;
+ if (first is >= 32 and <= 246)
+ {
+ value = first - 139;
+ return true;
+ }
+ if (first is >= 247 and <= 250)
+ {
+ if (cursor >= data.Length) return false;
+ value = (first - 247) * 256 + data[cursor++] + 108;
+ return true;
+ }
+ if (first is >= 251 and <= 254)
+ {
+ if (cursor >= data.Length) return false;
+ value = -(first - 251) * 256 - data[cursor++] - 108;
+ return true;
+ }
+ if (first == 28)
+ {
+ if (cursor > data.Length - 2) return false;
+ value = (short)((data[cursor] << 8) | data[cursor + 1]);
+ cursor += 2;
+ return true;
+ }
+ if (first == 29)
+ {
+ if (cursor > data.Length - 4) return false;
+ value = (int)((uint)data[cursor] << 24 |
+ (uint)data[cursor + 1] << 16 |
+ (uint)data[cursor + 2] << 8 |
+ data[cursor + 3]);
+ cursor += 4;
+ return true;
+ }
+ if (first != 30)
+ {
+ return false;
+ }
+
+ Span encoded = stackalloc char[96];
+ int length = 0;
+ bool ended = false;
+ while (cursor < data.Length && !ended)
+ {
+ byte pair = data[cursor++];
+ ended = AppendRealNibble(pair >> 4, encoded, ref length) ||
+ AppendRealNibble(pair & 0x0f, encoded, ref length);
+ }
+ return ended && double.TryParse(
+ encoded[..length],
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out value);
+ }
+
+ private static bool AppendRealNibble(int nibble, Span destination, ref int length)
+ {
+ if (nibble == 15) return true;
+ ReadOnlySpan text = nibble switch
+ {
+ <= 9 => stackalloc char[1] { (char)('0' + nibble) },
+ 10 => ".",
+ 11 => "E",
+ 12 => "E-",
+ 14 => "-",
+ _ => ""
+ };
+ if (length > destination.Length - text.Length) return true;
+ text.CopyTo(destination[length..]);
+ length += text.Length;
+ return false;
+ }
+
+ private static int ToOffset(double value) =>
+ value is >= 0 and <= int.MaxValue ? (int)value : -1;
+
+ private struct TopDictionary
+ {
+ public int CharStringsOffset;
+ public int PrivateSize;
+ public int PrivateOffset;
+ public int FontDictionaryOffset;
+ public int FdSelectOffset;
+ }
+
+ private readonly struct CffIndex
+ {
+ private readonly ReadOnlyMemory _data;
+ private readonly int[]? _offsets;
+
+ private CffIndex(ReadOnlyMemory data, int[] offsets)
+ {
+ _data = data;
+ _offsets = offsets;
+ }
+
+ public int Count => _offsets is null ? 0 : _offsets.Length - 1;
+
+ public ReadOnlySpan GetItem(int index)
+ {
+ int[] offsets = _offsets!;
+ return _data.Span.Slice(offsets[index], offsets[index + 1] - offsets[index]);
+ }
+
+ public static bool TryReadAt(ReadOnlyMemory data, int offset, out CffIndex index)
+ {
+ int cursor = offset;
+ return TryRead(data, ref cursor, out index);
+ }
+
+ public static bool TryRead(ReadOnlyMemory data, ref int cursor, out CffIndex index)
+ {
+ index = default;
+ ReadOnlySpan span = data.Span;
+ if (cursor < 0 || cursor > span.Length - 2) return false;
+ int count = (span[cursor] << 8) | span[cursor + 1];
+ cursor += 2;
+ if (count == 0)
+ {
+ index = new CffIndex(data, [cursor]);
+ return true;
+ }
+ if (cursor >= span.Length) return false;
+ int offsetSize = span[cursor++];
+ if (offsetSize is < 1 or > 4 || count == int.MaxValue ||
+ cursor > span.Length - (count + 1) * offsetSize)
+ {
+ return false;
+ }
+
+ int dataStart = cursor + (count + 1) * offsetSize;
+ var offsets = new int[count + 1];
+ int previous = 0;
+ for (var item = 0; item <= count; item++)
+ {
+ int encoded = 0;
+ for (var component = 0; component < offsetSize; component++)
+ {
+ encoded = (encoded << 8) | span[cursor++];
+ }
+ if (encoded < 1 || item != 0 && encoded < previous ||
+ encoded - 1 > span.Length - dataStart)
+ {
+ return false;
+ }
+ offsets[item] = dataStart + encoded - 1;
+ previous = encoded;
+ }
+ cursor = offsets[count];
+ index = new CffIndex(data, offsets);
+ return true;
+ }
+ }
+
+ private sealed class FdSelect
+ {
+ private readonly int[] _firstGlyphs;
+ private readonly int[] _fontDictionaries;
+
+ private FdSelect(int[] firstGlyphs, int[] fontDictionaries)
+ {
+ _firstGlyphs = firstGlyphs;
+ _fontDictionaries = fontDictionaries;
+ }
+
+ public int GetFontDictionary(int glyph)
+ {
+ int low = 0;
+ int high = _firstGlyphs.Length - 2;
+ while (low <= high)
+ {
+ int middle = (low + high) >> 1;
+ if (glyph < _firstGlyphs[middle])
+ {
+ high = middle - 1;
+ }
+ else if (glyph >= _firstGlyphs[middle + 1])
+ {
+ low = middle + 1;
+ }
+ else
+ {
+ return _fontDictionaries[middle];
+ }
+ }
+ return -1;
+ }
+
+ public static bool TryRead(
+ ReadOnlySpan data,
+ int offset,
+ int glyphCount,
+ int fontDictionaryCount,
+ out FdSelect? result)
+ {
+ result = null;
+ if (offset < 0 || offset >= data.Length) return false;
+ int cursor = offset;
+ int format = data[cursor++];
+ if (format == 0)
+ {
+ if (cursor > data.Length - glyphCount) return false;
+ var starts = new int[glyphCount + 1];
+ var dictionaries = new int[glyphCount];
+ for (var glyph = 0; glyph < glyphCount; glyph++)
+ {
+ int dictionary = data[cursor++];
+ if (dictionary >= fontDictionaryCount) return false;
+ starts[glyph] = glyph;
+ dictionaries[glyph] = dictionary;
+ }
+ starts[glyphCount] = glyphCount;
+ result = new FdSelect(starts, dictionaries);
+ return true;
+ }
+ if (format == 3)
+ {
+ if (cursor > data.Length - 2) return false;
+ int rangeCount = ReadU16(data, ref cursor);
+ if (rangeCount == 0 || cursor > data.Length - rangeCount * 3 - 2) return false;
+ var starts = new int[rangeCount + 1];
+ var dictionaries = new int[rangeCount];
+ for (var range = 0; range < rangeCount; range++)
+ {
+ starts[range] = ReadU16(data, ref cursor);
+ dictionaries[range] = data[cursor++];
+ if (dictionaries[range] >= fontDictionaryCount ||
+ range != 0 && starts[range] <= starts[range - 1]) return false;
+ }
+ starts[rangeCount] = ReadU16(data, ref cursor);
+ if (starts[0] != 0 || starts[rangeCount] != glyphCount) return false;
+ result = new FdSelect(starts, dictionaries);
+ return true;
+ }
+ if (format == 4)
+ {
+ if (cursor > data.Length - 4) return false;
+ uint encodedCount = ReadU32(data, ref cursor);
+ if (encodedCount == 0 || encodedCount > int.MaxValue) return false;
+ int rangeCount = (int)encodedCount;
+ if (rangeCount > (data.Length - cursor - 4) / 6) return false;
+ var starts = new int[rangeCount + 1];
+ var dictionaries = new int[rangeCount];
+ for (var range = 0; range < rangeCount; range++)
+ {
+ uint first = ReadU32(data, ref cursor);
+ int dictionary = ReadU16(data, ref cursor);
+ if (first > int.MaxValue || dictionary >= fontDictionaryCount ||
+ range != 0 && first <= (uint)starts[range - 1]) return false;
+ starts[range] = (int)first;
+ dictionaries[range] = dictionary;
+ }
+ uint sentinel = ReadU32(data, ref cursor);
+ if (starts[0] != 0 || sentinel != glyphCount) return false;
+ starts[rangeCount] = glyphCount;
+ result = new FdSelect(starts, dictionaries);
+ return true;
+ }
+ return false;
+ }
+
+ private static int ReadU16(ReadOnlySpan data, ref int cursor)
+ {
+ int value = (data[cursor] << 8) | data[cursor + 1];
+ cursor += 2;
+ return value;
+ }
+
+ private static uint ReadU32(ReadOnlySpan data, ref int cursor)
+ {
+ uint value = (uint)data[cursor] << 24 |
+ (uint)data[cursor + 1] << 16 |
+ (uint)data[cursor + 2] << 8 |
+ data[cursor + 3];
+ cursor += 4;
+ return value;
+ }
+ }
+
+ private sealed class OutlineBuilder
+ {
+ private PathFigure? _figure;
+
+ public PathGeometry Geometry { get; } = new();
+
+ public void MoveTo(double x, double y)
+ {
+ CloseFigure();
+ _figure = new PathFigure(new Vector2((float)x, (float)y));
+ Geometry.Figures.Add(_figure);
+ }
+
+ public void LineTo(double x, double y) =>
+ EnsureFigure().Segments.Add(new LineSegment(new Vector2((float)x, (float)y)));
+
+ public void CurveTo(double x1, double y1, double x2, double y2, double x3, double y3) =>
+ EnsureFigure().Segments.Add(new CubicBezierSegment(
+ new Vector2((float)x1, (float)y1),
+ new Vector2((float)x2, (float)y2),
+ new Vector2((float)x3, (float)y3)));
+
+ public void EndGlyph() => CloseFigure();
+
+ private PathFigure EnsureFigure()
+ {
+ if (_figure is null) MoveTo(0, 0);
+ return _figure!;
+ }
+
+ private void CloseFigure()
+ {
+ if (_figure is null) return;
+ _figure.IsClosed = true;
+ _figure = null;
+ }
+ }
+
+ private ref struct Type2Evaluator
+ {
+ private const int MaximumSubroutineDepth = 10;
+
+ private readonly OutlineBuilder _builder;
+ private readonly CffIndex _globalSubroutines;
+ private readonly CffIndex _localSubroutines;
+ private readonly Span _operands;
+ private readonly Span _transient;
+ private int _operandCount;
+ private int _stemCount;
+ private bool _widthSeen;
+ private double _x;
+ private double _y;
+ private uint _randomState;
+
+ public Type2Evaluator(
+ OutlineBuilder builder,
+ CffIndex globalSubroutines,
+ CffIndex localSubroutines,
+ Span operands,
+ Span transient,
+ uint randomSeed)
+ {
+ _builder = builder;
+ _globalSubroutines = globalSubroutines;
+ _localSubroutines = localSubroutines;
+ _operands = operands;
+ _transient = transient;
+ _operandCount = 0;
+ _stemCount = 0;
+ _widthSeen = false;
+ _x = 0;
+ _y = 0;
+ _randomState = randomSeed;
+ _transient.Clear();
+ }
+
+ public bool TryEvaluate(ReadOnlySpan charString) =>
+ Execute(charString, 0, isSubroutine: false) == ExecutionResult.EndGlyph;
+
+ private ExecutionResult Execute(ReadOnlySpan program, int depth, bool isSubroutine)
+ {
+ if (depth > MaximumSubroutineDepth) return ExecutionResult.Failed;
+ int cursor = 0;
+ while (cursor < program.Length)
+ {
+ byte op = program[cursor++];
+ if (TryReadOperand(program, ref cursor, op, out double value))
+ {
+ if (!Push(value)) return ExecutionResult.Failed;
+ continue;
+ }
+
+ switch (op)
+ {
+ case 1:
+ case 3:
+ case 18:
+ case 23:
+ if (!ConsumeStems()) return ExecutionResult.Failed;
+ break;
+ case 19:
+ case 20:
+ if (!ConsumeStems()) return ExecutionResult.Failed;
+ int maskBytes = (_stemCount + 7) >> 3;
+ if (cursor > program.Length - maskBytes) return ExecutionResult.Failed;
+ cursor += maskBytes;
+ break;
+ case 4:
+ if (!ConsumeWidth(1) || _operandCount != 1) return ExecutionResult.Failed;
+ _y += _operands[0];
+ _builder.MoveTo(_x, _y);
+ Clear();
+ break;
+ case 5:
+ if (_operandCount < 2 || (_operandCount & 1) != 0) return ExecutionResult.Failed;
+ for (var index = 0; index < _operandCount; index += 2)
+ {
+ _x += _operands[index];
+ _y += _operands[index + 1];
+ _builder.LineTo(_x, _y);
+ }
+ Clear();
+ break;
+ case 6:
+ case 7:
+ if (_operandCount == 0) return ExecutionResult.Failed;
+ bool horizontal = op == 6;
+ for (var index = 0; index < _operandCount; index++)
+ {
+ if (horizontal) _x += _operands[index]; else _y += _operands[index];
+ _builder.LineTo(_x, _y);
+ horizontal = !horizontal;
+ }
+ Clear();
+ break;
+ case 8:
+ if (_operandCount == 0 || _operandCount % 6 != 0) return ExecutionResult.Failed;
+ for (var index = 0; index < _operandCount; index += 6)
+ Curve(_operands[index], _operands[index + 1], _operands[index + 2],
+ _operands[index + 3], _operands[index + 4], _operands[index + 5]);
+ Clear();
+ break;
+ case 10:
+ case 29:
+ {
+ if (!Pop(out double encodedIndex)) return ExecutionResult.Failed;
+ CffIndex subroutines = op == 10 ? _localSubroutines : _globalSubroutines;
+ int subroutine = (int)encodedIndex + GetSubroutineBias(subroutines.Count);
+ if ((uint)subroutine >= subroutines.Count) return ExecutionResult.Failed;
+ ExecutionResult result = Execute(subroutines.GetItem(subroutine), depth + 1, isSubroutine: true);
+ if (result == ExecutionResult.Failed || result == ExecutionResult.EndGlyph) return result;
+ break;
+ }
+ case 11:
+ return isSubroutine ? ExecutionResult.Return : ExecutionResult.Failed;
+ case 12:
+ if (cursor >= program.Length || !ExecuteEscaped(program[cursor++]))
+ return ExecutionResult.Failed;
+ break;
+ case 14:
+ if (!ConsumeWidth(_operandCount is 1 or 5 ? _operandCount - 1 : _operandCount))
+ return ExecutionResult.Failed;
+ // Four residual operands encode the deprecated Type 1 seac composite.
+ // The bounded reader deliberately asks the full fallback to handle it.
+ if (_operandCount != 0) return ExecutionResult.Failed;
+ return ExecutionResult.EndGlyph;
+ case 21:
+ if (!ConsumeWidth(2) || _operandCount != 2) return ExecutionResult.Failed;
+ _x += _operands[0];
+ _y += _operands[1];
+ _builder.MoveTo(_x, _y);
+ Clear();
+ break;
+ case 22:
+ if (!ConsumeWidth(1) || _operandCount != 1) return ExecutionResult.Failed;
+ _x += _operands[0];
+ _builder.MoveTo(_x, _y);
+ Clear();
+ break;
+ case 24:
+ if (_operandCount < 8 || (_operandCount - 2) % 6 != 0) return ExecutionResult.Failed;
+ int curveLimit = _operandCount - 2;
+ for (var index = 0; index < curveLimit; index += 6)
+ Curve(_operands[index], _operands[index + 1], _operands[index + 2],
+ _operands[index + 3], _operands[index + 4], _operands[index + 5]);
+ _x += _operands[curveLimit];
+ _y += _operands[curveLimit + 1];
+ _builder.LineTo(_x, _y);
+ Clear();
+ break;
+ case 25:
+ if (_operandCount < 8 || (_operandCount - 6) % 2 != 0) return ExecutionResult.Failed;
+ int lineLimit = _operandCount - 6;
+ for (var index = 0; index < lineLimit; index += 2)
+ {
+ _x += _operands[index];
+ _y += _operands[index + 1];
+ _builder.LineTo(_x, _y);
+ }
+ Curve(_operands[lineLimit], _operands[lineLimit + 1], _operands[lineLimit + 2],
+ _operands[lineLimit + 3], _operands[lineLimit + 4], _operands[lineLimit + 5]);
+ Clear();
+ break;
+ case 26:
+ case 27:
+ if (!ExecuteVvOrHhCurve(op == 27)) return ExecutionResult.Failed;
+ break;
+ case 30:
+ case 31:
+ if (!ExecuteAlternatingCurve(op == 31)) return ExecutionResult.Failed;
+ break;
+ default:
+ return ExecutionResult.Failed;
+ }
+ }
+ return isSubroutine ? ExecutionResult.Return : ExecutionResult.Failed;
+ }
+
+ private bool ExecuteEscaped(byte op)
+ {
+ switch (op)
+ {
+ case 0: // dotsection is deprecated and has no outline effect.
+ Clear();
+ return true;
+ case 3:
+ return Binary(static (left, right) => left != 0 && right != 0 ? 1 : 0);
+ case 4:
+ return Binary(static (left, right) => left != 0 || right != 0 ? 1 : 0);
+ case 5:
+ if (!Pop(out double notValue)) return false;
+ return Push(notValue == 0 ? 1 : 0);
+ case 9:
+ if (!Pop(out double absolute)) return false;
+ return Push(Math.Abs(absolute));
+ case 10:
+ return Binary(static (left, right) => left + right);
+ case 11:
+ return Binary(static (left, right) => left - right);
+ case 12:
+ return Binary(static (left, right) => right == 0 ? 0 : left / right);
+ case 14:
+ if (!Pop(out double negative)) return false;
+ return Push(-negative);
+ case 15:
+ return Binary(static (left, right) => left == right ? 1 : 0);
+ case 18:
+ return Pop(out _);
+ case 20:
+ if (!Pop(out double putIndexValue) || !Pop(out double putValue)) return false;
+ int putIndex = (int)putIndexValue;
+ if ((uint)putIndex >= _transient.Length) return false;
+ _transient[putIndex] = putValue;
+ return true;
+ case 21:
+ if (!Pop(out double getIndexValue)) return false;
+ int getIndex = (int)getIndexValue;
+ return (uint)getIndex < _transient.Length && Push(_transient[getIndex]);
+ case 22:
+ if (_operandCount < 4) return false;
+ double secondLimit = _operands[--_operandCount];
+ double firstLimit = _operands[--_operandCount];
+ double secondValue = _operands[--_operandCount];
+ double firstValue = _operands[--_operandCount];
+ return Push(firstLimit <= secondLimit ? firstValue : secondValue);
+ case 23:
+ _randomState ^= _randomState << 13;
+ _randomState ^= _randomState >> 17;
+ _randomState ^= _randomState << 5;
+ return Push((_randomState + 1.0) / (uint.MaxValue + 2.0));
+ case 24:
+ return Binary(static (left, right) => left * right);
+ case 26:
+ if (!Pop(out double squareRoot)) return false;
+ return Push(Math.Sqrt(Math.Max(0, squareRoot)));
+ case 27:
+ return _operandCount != 0 && Push(_operands[_operandCount - 1]);
+ case 28:
+ if (_operandCount < 2) return false;
+ (_operands[_operandCount - 1], _operands[_operandCount - 2]) =
+ (_operands[_operandCount - 2], _operands[_operandCount - 1]);
+ return true;
+ case 29:
+ if (!Pop(out double indexValue) || _operandCount == 0) return false;
+ int index = Math.Clamp((int)indexValue, 0, _operandCount - 1);
+ return Push(_operands[_operandCount - 1 - index]);
+ case 30:
+ return Roll();
+ case 34:
+ return Flex(FlexKind.Horizontal);
+ case 35:
+ return Flex(FlexKind.Full);
+ case 36:
+ return Flex(FlexKind.HorizontalOne);
+ case 37:
+ return Flex(FlexKind.One);
+ default:
+ return false;
+ }
+ }
+
+ private bool ConsumeStems()
+ {
+ if (!_widthSeen && (_operandCount & 1) != 0)
+ {
+ RemoveFirstOperand();
+ _widthSeen = true;
+ }
+ if ((_operandCount & 1) != 0) return false;
+ _stemCount += _operandCount >> 1;
+ Clear();
+ return _stemCount <= 96;
+ }
+
+ private bool ConsumeWidth(int expectedOperands)
+ {
+ if (!_widthSeen && _operandCount == expectedOperands + 1)
+ {
+ RemoveFirstOperand();
+ _widthSeen = true;
+ }
+ else if (!_widthSeen)
+ {
+ _widthSeen = true;
+ }
+ return _operandCount == expectedOperands;
+ }
+
+ private void RemoveFirstOperand()
+ {
+ _operands[1.._operandCount].CopyTo(_operands);
+ _operandCount--;
+ }
+
+ private bool ExecuteVvOrHhCurve(bool horizontal)
+ {
+ if (_operandCount < 4) return false;
+ int cursor = 0;
+ double firstCrossDelta = 0;
+ if ((_operandCount & 1) != 0)
+ {
+ firstCrossDelta = _operands[cursor++];
+ }
+ while (cursor <= _operandCount - 4)
+ {
+ if (horizontal)
+ {
+ Curve(_operands[cursor], firstCrossDelta, _operands[cursor + 1],
+ _operands[cursor + 2], _operands[cursor + 3], 0);
+ }
+ else
+ {
+ Curve(firstCrossDelta, _operands[cursor], _operands[cursor + 1],
+ _operands[cursor + 2], 0, _operands[cursor + 3]);
+ }
+ firstCrossDelta = 0;
+ cursor += 4;
+ }
+ bool valid = cursor == _operandCount;
+ Clear();
+ return valid;
+ }
+
+ private bool ExecuteAlternatingCurve(bool horizontal)
+ {
+ if (_operandCount < 4) return false;
+ int cursor = 0;
+ while (cursor <= _operandCount - 4)
+ {
+ int remaining = _operandCount - cursor;
+ bool finalWithExtra = remaining == 5;
+ if (horizontal)
+ {
+ Curve(_operands[cursor], 0, _operands[cursor + 1], _operands[cursor + 2],
+ finalWithExtra ? _operands[cursor + 4] : 0, _operands[cursor + 3]);
+ }
+ else
+ {
+ Curve(0, _operands[cursor], _operands[cursor + 1], _operands[cursor + 2],
+ _operands[cursor + 3], finalWithExtra ? _operands[cursor + 4] : 0);
+ }
+ cursor += finalWithExtra ? 5 : 4;
+ horizontal = !horizontal;
+ }
+ bool valid = cursor == _operandCount;
+ Clear();
+ return valid;
+ }
+
+ private bool Flex(FlexKind kind)
+ {
+ switch (kind)
+ {
+ case FlexKind.Horizontal when _operandCount == 7:
+ {
+ double dy = _operands[2];
+ Curve(_operands[0], 0, _operands[1], dy, _operands[3], 0);
+ Curve(_operands[4], 0, _operands[5], -dy, _operands[6], 0);
+ Clear();
+ return true;
+ }
+ case FlexKind.Full when _operandCount == 13:
+ Curve(_operands[0], _operands[1], _operands[2], _operands[3],
+ _operands[4], _operands[5]);
+ Curve(_operands[6], _operands[7], _operands[8], _operands[9],
+ _operands[10], _operands[11]);
+ Clear();
+ return true;
+ case FlexKind.HorizontalOne when _operandCount == 9:
+ {
+ double finalY = -(_operands[1] + _operands[3] + _operands[7]);
+ Curve(_operands[0], _operands[1], _operands[2], _operands[3], _operands[4], 0);
+ Curve(_operands[5], 0, _operands[6], _operands[7], _operands[8], finalY);
+ Clear();
+ return true;
+ }
+ case FlexKind.One when _operandCount == 11:
+ {
+ double sumX = _operands[0] + _operands[2] + _operands[4] + _operands[6] + _operands[8];
+ double sumY = _operands[1] + _operands[3] + _operands[5] + _operands[7] + _operands[9];
+ double dx6;
+ double dy6;
+ if (Math.Abs(sumX) > Math.Abs(sumY))
+ {
+ dx6 = _operands[10];
+ dy6 = -sumY;
+ }
+ else
+ {
+ dx6 = -sumX;
+ dy6 = _operands[10];
+ }
+ Curve(_operands[0], _operands[1], _operands[2], _operands[3],
+ _operands[4], _operands[5]);
+ Curve(_operands[6], _operands[7], _operands[8], _operands[9], dx6, dy6);
+ Clear();
+ return true;
+ }
+ default:
+ return false;
+ }
+ }
+
+ private void Curve(double dx1, double dy1, double dx2, double dy2, double dx3, double dy3)
+ {
+ double x1 = _x + dx1;
+ double y1 = _y + dy1;
+ double x2 = x1 + dx2;
+ double y2 = y1 + dy2;
+ _x = x2 + dx3;
+ _y = y2 + dy3;
+ _builder.CurveTo(x1, y1, x2, y2, _x, _y);
+ }
+
+ private bool Roll()
+ {
+ if (!Pop(out double shiftValue) || !Pop(out double countValue)) return false;
+ int count = (int)countValue;
+ if (count < 0 || count > _operandCount) return false;
+ if (count < 2) return true;
+ int shift = (int)shiftValue % count;
+ if (shift < 0) shift += count;
+ if (shift == 0) return true;
+ int start = _operandCount - count;
+ Reverse(start, _operandCount - 1);
+ Reverse(start, start + shift - 1);
+ Reverse(start + shift, _operandCount - 1);
+ return true;
+ }
+
+ private void Reverse(int start, int end)
+ {
+ while (start < end)
+ {
+ (_operands[start], _operands[end]) = (_operands[end], _operands[start]);
+ start++;
+ end--;
+ }
+ }
+
+ private bool Binary(Func operation)
+ {
+ if (!Pop(out double right) || !Pop(out double left)) return false;
+ return Push(operation(left, right));
+ }
+
+ private bool Push(double value)
+ {
+ if (_operandCount >= _operands.Length || !double.IsFinite(value)) return false;
+ _operands[_operandCount++] = value;
+ return true;
+ }
+
+ private bool Pop(out double value)
+ {
+ if (_operandCount == 0)
+ {
+ value = 0;
+ return false;
+ }
+ value = _operands[--_operandCount];
+ return true;
+ }
+
+ private void Clear() => _operandCount = 0;
+
+ private static int GetSubroutineBias(int count) =>
+ count < 1240 ? 107 : count < 33900 ? 1131 : 32768;
+
+ private static bool TryReadOperand(
+ ReadOnlySpan data,
+ ref int cursor,
+ byte first,
+ out double value)
+ {
+ value = 0;
+ if (first is >= 32 and <= 246)
+ {
+ value = first - 139;
+ return true;
+ }
+ if (first is >= 247 and <= 250)
+ {
+ if (cursor >= data.Length) return false;
+ value = (first - 247) * 256 + data[cursor++] + 108;
+ return true;
+ }
+ if (first is >= 251 and <= 254)
+ {
+ if (cursor >= data.Length) return false;
+ value = -(first - 251) * 256 - data[cursor++] - 108;
+ return true;
+ }
+ if (first == 28)
+ {
+ if (cursor > data.Length - 2) return false;
+ value = (short)((data[cursor] << 8) | data[cursor + 1]);
+ cursor += 2;
+ return true;
+ }
+ if (first != 255) return false;
+ if (cursor > data.Length - 4) return false;
+ int fixedValue = (int)((uint)data[cursor] << 24 |
+ (uint)data[cursor + 1] << 16 |
+ (uint)data[cursor + 2] << 8 |
+ data[cursor + 3]);
+ cursor += 4;
+ value = fixedValue / 65536.0;
+ return true;
+ }
+
+ private enum ExecutionResult
+ {
+ Failed,
+ Return,
+ EndGlyph
+ }
+
+ private enum FlexKind
+ {
+ Horizontal,
+ Full,
+ HorizontalOne,
+ One
+ }
+ }
+}
diff --git a/src/ProGPU.Text/FontApi.cs b/src/ProGPU.Text/FontApi.cs
index f543d28f..18f3e01f 100644
--- a/src/ProGPU.Text/FontApi.cs
+++ b/src/ProGPU.Text/FontApi.cs
@@ -190,6 +190,10 @@ public static List GetSystemFonts()
return new List(GetCachedSystemFonts());
}
+ // FontManager only reads the process-wide catalog. Keep the public copy-on-return
+ // ownership contract without cloning the complete catalog during fallback lookup.
+ internal static IReadOnlyList GetSystemFontsView() => GetCachedSystemFonts();
+
public static Task WarmUpSystemFontsAsync()
{
lock (s_cachedSystemFontsLock)
diff --git a/src/ProGPU.Text/FontManager.cs b/src/ProGPU.Text/FontManager.cs
index 22465d8f..7daf8334 100644
--- a/src/ProGPU.Text/FontManager.cs
+++ b/src/ProGPU.Text/FontManager.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Globalization;
+using System.Runtime.CompilerServices;
namespace ProGPU.Text;
@@ -54,6 +55,40 @@ internal FontFace(string familyName, string name, FontStyleRequest style, Func
public sealed class FontManager
{
+ private const int MaximumCharacterMatchCacheEntries = 4096;
+ private const int MaximumVariationMatchCacheEntries = 256;
+
+ private readonly record struct CharacterMatchKey(
+ string? FamilyName,
+ FontStyleRequest Style,
+ int CodePoint,
+ TtfFont? ExcludedFont,
+ int CatalogVersion);
+
+ private readonly record struct CharacterMatchResult(TtfFont? Font, ushort GlyphIndex)
+ {
+ public bool Found => Font is not null && GlyphIndex != 0;
+ }
+
+ private sealed class CharacterMatchKeyComparer : IEqualityComparer
+ {
+ public static CharacterMatchKeyComparer Instance { get; } = new();
+
+ public bool Equals(CharacterMatchKey left, CharacterMatchKey right) =>
+ left.CodePoint == right.CodePoint &&
+ left.CatalogVersion == right.CatalogVersion &&
+ left.Style.Equals(right.Style) &&
+ ReferenceEquals(left.ExcludedFont, right.ExcludedFont) &&
+ string.Equals(left.FamilyName, right.FamilyName, StringComparison.OrdinalIgnoreCase);
+
+ public int GetHashCode(CharacterMatchKey key) => HashCode.Combine(
+ key.FamilyName is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(key.FamilyName),
+ key.Style,
+ key.CodePoint,
+ key.ExcludedFont is null ? 0 : RuntimeHelpers.GetHashCode(key.ExcludedFont),
+ key.CatalogVersion);
+ }
+
private sealed class RegisteredFace
{
public required string FamilyName { get; init; }
@@ -71,6 +106,15 @@ private sealed class RegisteredFace
private readonly object _registeredLock = new();
private RegisteredFace[] _registeredFaces = [];
private readonly ConcurrentDictionary<(TtfFont Font, FontStyleRequest Style), TtfFont> _styleMatches = new();
+ private readonly ConcurrentDictionary<(TtfFont Font, FontStyleRequest Style), TtfFont> _variationMatches = new();
+ private readonly ConcurrentQueue<(TtfFont Font, FontStyleRequest Style)> _variationMatchOrder = new();
+ // Fallback lookup is on the per-character layout path. Positive and negative
+ // matches are process-catalog-versioned and FIFO-bounded: average lookup is O(1),
+ // retained state is O(MaximumCharacterMatchCacheEntries).
+ private readonly ConcurrentDictionary _characterMatches =
+ new(CharacterMatchKeyComparer.Instance);
+ private readonly ConcurrentQueue _characterMatchOrder = new();
+ private int _catalogVersion;
public static FontManager Default => s_default.Value;
@@ -90,7 +134,7 @@ public IReadOnlyList FontFamilies
}
}
- IReadOnlyList system = FontApi.GetSystemFonts();
+ IReadOnlyList system = FontApi.GetSystemFontsView();
for (var index = 0; index < system.Count; index++)
{
if (names.Add(system[index].FamilyName))
@@ -132,7 +176,7 @@ public IReadOnlyList GetFontStyles(string? familyName)
styles.Add(face.Style);
}
- IReadOnlyList system = FontApi.GetSystemFonts();
+ IReadOnlyList system = FontApi.GetSystemFontsView();
for (var index = 0; index < system.Count; index++)
{
FontInfo info = system[index];
@@ -223,6 +267,11 @@ private void RegisterFontCore(
};
Volatile.Write(ref _registeredFaces, updated);
_styleMatches.Clear();
+ _variationMatches.Clear();
+ _variationMatchOrder.Clear();
+ Interlocked.Increment(ref _catalogVersion);
+ _characterMatches.Clear();
+ _characterMatchOrder.Clear();
}
}
@@ -237,11 +286,11 @@ private void RegisterFontCore(
RegisteredFace? registered = FindRegisteredFace(familyName, style);
if (registered is not null)
{
- return ApplyStyleVariations(TryGetRegisteredFont(registered), style);
+ return ApplyStyleVariationsCached(TryGetRegisteredFont(registered), style);
}
FontInfo? system = FindSystemFace(familyName, style, requiredCodePoint: null);
- return system is null ? null : ApplyStyleVariations(TryLoadSystemFont(system), style);
+ return system is null ? null : ApplyStyleVariationsCached(TryLoadSystemFont(system), style);
}
public TtfFont MatchTypeface(TtfFont typeface, FontStyleRequest style)
@@ -261,7 +310,7 @@ public TtfFont MatchTypeface(TtfFont typeface, FontStyleRequest style)
? familyMatch
: ApplyStyleVariations(key.Font, key.Style) ?? key.Font);
}
- return ApplyStyleVariations(typeface, style) ?? typeface;
+ return ApplyStyleVariationsCached(typeface, style) ?? typeface;
}
if (GetStyleDistance(FontStyleRequest.FromFont(typeface), style) == 0)
{
@@ -281,14 +330,62 @@ public bool TryMatchCharacter(
out TtfFont? font,
out ushort glyphIndex)
{
- font = null;
- glyphIndex = 0;
if ((uint)codePoint > 0x10FFFFu)
{
+ font = null;
+ glyphIndex = 0;
return false;
}
style = NormalizeStyle(style);
+ if (languageTags is null)
+ {
+ var key = new CharacterMatchKey(
+ familyName,
+ style,
+ codePoint,
+ excludedFont,
+ Volatile.Read(ref _catalogVersion));
+ if (_characterMatches.TryGetValue(key, out CharacterMatchResult cached))
+ {
+ font = cached.Font;
+ glyphIndex = cached.GlyphIndex;
+ return cached.Found;
+ }
+
+ bool found = TryMatchCharacterCore(
+ familyName,
+ style,
+ languageTags,
+ codePoint,
+ excludedFont,
+ out font,
+ out glyphIndex);
+ CacheCharacterMatch(key, new CharacterMatchResult(font, glyphIndex));
+ return found;
+ }
+
+ return TryMatchCharacterCore(
+ familyName,
+ style,
+ languageTags,
+ codePoint,
+ excludedFont,
+ out font,
+ out glyphIndex);
+ }
+
+ private bool TryMatchCharacterCore(
+ string? familyName,
+ FontStyleRequest style,
+ IReadOnlyList? languageTags,
+ int codePoint,
+ TtfFont? excludedFont,
+ out TtfFont? font,
+ out ushort glyphIndex)
+ {
+ font = null;
+ glyphIndex = 0;
var visitedRegistered = new HashSet();
var visitedSystem = new HashSet<(string Path, int FaceIndex)>();
@@ -329,7 +426,7 @@ public bool TryMatchCharacter(
}
}
- IReadOnlyList systemFonts = FontApi.GetSystemFonts();
+ IReadOnlyList systemFonts = FontApi.GetSystemFontsView();
for (var stylePass = 0; stylePass < 2; stylePass++)
{
bool requireStyle = stylePass == 0;
@@ -343,7 +440,7 @@ public bool TryMatchCharacter(
continue;
}
- TtfFont? loaded = ApplyStyleVariations(TryLoadSystemFont(candidate), style);
+ TtfFont? loaded = ApplyStyleVariationsCached(TryLoadSystemFont(candidate), style);
if (loaded is null || ReferenceEquals(loaded, excludedFont))
{
continue;
@@ -362,6 +459,21 @@ public bool TryMatchCharacter(
return false;
}
+ private void CacheCharacterMatch(CharacterMatchKey key, CharacterMatchResult result)
+ {
+ if (!_characterMatches.TryAdd(key, result))
+ {
+ return;
+ }
+
+ _characterMatchOrder.Enqueue(key);
+ while (_characterMatches.Count > MaximumCharacterMatchCacheEntries &&
+ _characterMatchOrder.TryDequeue(out CharacterMatchKey oldest))
+ {
+ _characterMatches.TryRemove(oldest, out _);
+ }
+ }
+
public bool TryMatchCharacter(
string? familyName,
FontStyleRequest style,
@@ -542,7 +654,7 @@ private static string GetStyleName(string familyName, FontStyleRequest style)
{
FontInfo? best = null;
var bestDistance = int.MaxValue;
- IReadOnlyList system = FontApi.GetSystemFonts();
+ IReadOnlyList system = FontApi.GetSystemFontsView();
for (var index = 0; index < system.Count; index++)
{
FontInfo candidate = system[index];
@@ -617,7 +729,7 @@ private bool TrySystemFamilyCharacter(
return false;
}
- TtfFont? loaded = ApplyStyleVariations(TryLoadSystemFont(candidate), style);
+ TtfFont? loaded = ApplyStyleVariationsCached(TryLoadSystemFont(candidate), style);
if (loaded is null || ReferenceEquals(loaded, excludedFont))
{
font = null;
@@ -638,7 +750,7 @@ private bool TrySystemFamilyCharacter(
return true;
}
- private static bool TryGetRenderableGlyph(
+ private bool TryGetRenderableGlyph(
RegisteredFace face,
FontStyleRequest style,
int codePoint,
@@ -646,7 +758,7 @@ private static bool TryGetRenderableGlyph(
out TtfFont? font,
out ushort glyphIndex)
{
- TtfFont? candidate = ApplyStyleVariations(TryGetRegisteredFont(face), style);
+ TtfFont? candidate = ApplyStyleVariationsCached(TryGetRegisteredFont(face), style);
if (candidate is null || ReferenceEquals(candidate, excludedFont))
{
font = null;
@@ -739,6 +851,34 @@ private static bool TryGetRenderableGlyph(
return settings.Count == 0 ? font : font.WithVariations(settings);
}
+ private TtfFont? ApplyStyleVariationsCached(TtfFont? font, FontStyleRequest style)
+ {
+ if (font is null || !font.IsVariableFont)
+ {
+ return font;
+ }
+
+ var key = (font, style);
+ if (_variationMatches.TryGetValue(key, out TtfFont? cached))
+ {
+ return cached;
+ }
+
+ TtfFont created = ApplyStyleVariations(font, style) ?? font;
+ if (!_variationMatches.TryAdd(key, created))
+ {
+ return _variationMatches.TryGetValue(key, out cached) ? cached : created;
+ }
+
+ _variationMatchOrder.Enqueue(key);
+ while (_variationMatches.Count > MaximumVariationMatchCacheEntries &&
+ _variationMatchOrder.TryDequeue(out var oldest))
+ {
+ _variationMatches.TryRemove(oldest, out _);
+ }
+ return created;
+ }
+
private static bool SupportsSlantVariation(TtfFont font)
{
IReadOnlyList axes = font.VariationAxes;
diff --git a/src/ProGPU.Text/GlyphAtlas.cs b/src/ProGPU.Text/GlyphAtlas.cs
index 80186a92..e448cbf6 100644
--- a/src/ProGPU.Text/GlyphAtlas.cs
+++ b/src/ProGPU.Text/GlyphAtlas.cs
@@ -33,13 +33,23 @@ public struct GlyphInfo
public unsafe class GlyphAtlas : IDisposable
{
- private const uint DefaultUniformRingBufferSize = 256 * 1024;
- private const int InitialFontRecordCapacity = 32;
- private const int InitialFontSegmentCapacity = 256;
+ private 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;
+ public const uint DefaultColorAtlasSize = 512;
+ private const int InitialRecordCapacity = 64;
+ private const int InitialSegmentCapacity = 1024;
+ public const ulong DefaultGpuOutlineCacheBudgetBytes = 4UL * 1024UL * 1024UL;
private readonly WgpuContext _context;
- private readonly GpuTexture _atlasTexture;
- private readonly uint _atlasSize;
+ private GpuTexture _atlasTexture;
+ private GpuTexture _colorAtlasTexture;
+ private uint _atlasSize;
+ private uint _colorAtlasSize;
+ private readonly uint _maxAtlasSize;
+ private readonly uint _maxColorAtlasSize;
// Packing state. Shelves are segregated by height so a tall glyph cannot waste
// the full width of a row containing short punctuation and combining marks.
@@ -51,7 +61,9 @@ private struct AtlasShelf
}
private readonly List _shelves = new();
+ private readonly List _colorShelves = new();
private uint _nextShelfY = 2;
+ private uint _nextColorShelfY = 2;
private readonly record struct GlyphKey(TtfFont Font, ushort GlyphIndex, float Size, byte SubpixelX);
@@ -65,28 +77,50 @@ private struct CachedGlyph
private readonly Dictionary _glyphs = new();
private sealed class FontGpuData
{
- public required GpuBuffer RecordsBuffer { get; set; }
- public required GpuBuffer SegmentsBuffer { get; set; }
public Dictionary RecordSlots { get; } = new();
- public List Records { get; } = new(InitialFontRecordCapacity);
- public List Segments { get; } = new(InitialFontSegmentCapacity);
- public int RecordCapacity { get; set; } = InitialFontRecordCapacity;
- public int SegmentCapacity { get; set; } = InitialFontSegmentCapacity;
}
private readonly Dictionary _fontGpuData = new();
+ private readonly List _glyphSegmentScratch = new(InitialSegmentCapacity);
+ private readonly List _pendingRecordUploads = new(InitialRecordCapacity);
+ private readonly List _pendingSegmentUploads = new(InitialSegmentCapacity);
+ private GpuBuffer _recordsBuffer;
+ private GpuBuffer _segmentsBuffer;
+ private int _recordCount;
+ private int _segmentCount;
+ private int _recordCapacity = InitialRecordCapacity;
+ private int _segmentCapacity = InitialSegmentCapacity;
+ private int _pendingRecordUploadStart;
+ private int _pendingSegmentUploadStart;
private readonly RenderPipelineCache _pipelineCache;
+ private readonly BindGroupLayout* _computeBindGroupLayout;
+ private readonly PipelineLayout* _computePipelineLayout;
private readonly ComputePipeline* _computePipeline;
+ private BindGroup* _ringBindGroup;
private CommandEncoder* _batchEncoder;
+ private ComputePassEncoder* _batchComputePass;
private int _batchDepth;
private readonly List _batchBuffers = new();
private readonly List _batchBindGroups = new();
+ private readonly List _batchCoverageCopies = new(256);
+
+ private readonly record struct PendingCoverageCopy(
+ uint BufferOffset,
+ uint BytesPerRow,
+ uint AtlasX,
+ uint AtlasY,
+ uint Width,
+ uint Height);
private readonly GpuBuffer _uniformRingBuffer;
+ private readonly GpuBuffer _coverageRingBuffer;
+ private readonly byte[] _uniformRingUpload;
private uint _ringOffset;
+ private uint _coverageRingOffset;
private ulong _frameNumber;
+ private int _currentBatchNewGlyphCount;
public void BeginBatch()
{
@@ -94,11 +128,36 @@ public void BeginBatch()
_batchDepth++;
if (_batchDepth > 1) return;
+ TrimGpuOutlineCache();
_frameNumber++;
+ _currentBatchNewGlyphCount = 0;
CreateBatchEncoder();
}
+ private void TrimGpuOutlineCache()
+ {
+ if (AllocatedGpuOutlineBytes <= DefaultGpuOutlineCacheBudgetBytes)
+ {
+ return;
+ }
+
+ InvalidateRingBindGroup();
+ _recordsBuffer.Dispose();
+ _segmentsBuffer.Dispose();
+ _fontGpuData.Clear();
+ _pendingRecordUploads.Clear();
+ _pendingSegmentUploads.Clear();
+ _recordCount = 0;
+ _segmentCount = 0;
+ _recordCapacity = InitialRecordCapacity;
+ _segmentCapacity = InitialSegmentCapacity;
+ (_recordsBuffer, _segmentsBuffer) = CreateOutlineBuffers(
+ _recordCapacity,
+ _segmentCapacity);
+ GpuOutlineCacheResetCount++;
+ }
+
private void CreateBatchEncoder()
{
var encoderDesc = new CommandEncoderDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Batch Encoder") };
@@ -111,6 +170,9 @@ private void CreateBatchEncoder()
}
_ringOffset = 0;
+ _coverageRingOffset = 0;
+ _batchComputePass = null;
+ _batchCoverageCopies.Clear();
}
public void EndBatch()
@@ -120,6 +182,7 @@ public void EndBatch()
_batchDepth--;
if (_batchDepth > 0) return;
FlushBatchEncoder();
+ LastBatchNewGlyphCount = _currentBatchNewGlyphCount;
}
///
@@ -142,15 +205,48 @@ private void FlushBatchEncoder()
{
if (_batchEncoder == null) return;
+ EndBatchComputePass();
+
+ for (int copyIndex = 0; copyIndex < _batchCoverageCopies.Count; copyIndex++)
+ {
+ PendingCoverageCopy copy = _batchCoverageCopies[copyIndex];
+ GpuCoverageUpload.RecordCopy(
+ _context,
+ _batchEncoder,
+ _coverageRingBuffer,
+ copy.BufferOffset,
+ copy.BytesPerRow,
+ _atlasTexture,
+ copy.AtlasX,
+ copy.AtlasY,
+ copy.Width,
+ copy.Height);
+ }
+
+ FlushPendingOutlineUploads();
+ if (_ringOffset == 0)
+ {
+ _context.Api.CommandEncoderRelease(_batchEncoder);
+ _batchEncoder = null;
+ _batchCoverageCopies.Clear();
+ return;
+ }
+
+ _uniformRingBuffer.WriteAlignedBytes(
+ _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);
_context.Api.QueueSubmit(_context.Queue, 1, &cmdBuffer);
+ RasterBatchSubmissionCount++;
_context.Api.CommandBufferRelease(cmdBuffer);
_context.Api.CommandEncoderRelease(_batchEncoder);
_batchEncoder = null;
+ _batchCoverageCopies.Clear();
int batchBufferCount = _batchBuffers.Count;
for (int bufferIndex = 0; bufferIndex < batchBufferCount; bufferIndex++)
@@ -168,41 +264,94 @@ private void FlushBatchEncoder()
}
_batchBindGroups.Clear();
}
+
+ private ComputePassEncoder* GetOrCreateBatchComputePass()
+ {
+ if (_batchComputePass != null)
+ {
+ return _batchComputePass;
+ }
+
+ var descriptor = new ComputePassDescriptor();
+ _batchComputePass = _context.Api.CommandEncoderBeginComputePass(
+ _batchEncoder,
+ &descriptor);
+ if (_batchComputePass == null)
+ {
+ throw new InvalidOperationException("Failed to begin the glyph rasterization batch pass.");
+ }
+ _context.Api.ComputePassEncoderSetPipeline(_batchComputePass, _computePipeline);
+ RasterComputePassCount++;
+ return _batchComputePass;
+ }
+
+ private void EndBatchComputePass()
+ {
+ if (_batchComputePass == null)
+ {
+ return;
+ }
+
+ _context.Api.ComputePassEncoderEnd(_batchComputePass);
+ _context.Api.ComputePassEncoderRelease(_batchComputePass);
+ _batchComputePass = null;
+ }
private bool _isDisposed;
public GpuTexture AtlasTexture => _atlasTexture;
+ public GpuTexture ColorAtlasTexture => _colorAtlasTexture;
+
public uint AtlasSize => _atlasSize;
+ public uint MaxAtlasSize => _maxAtlasSize;
+
+ public uint ColorAtlasSize => _colorAtlasSize;
+
+ public uint MaxColorAtlasSize => _maxColorAtlasSize;
+
+ public ulong TextureRevision { get; private set; }
+
+ public ulong PersistentTextureBytes =>
+ (ulong)_atlasSize * _atlasSize +
+ (ulong)_colorAtlasSize * _colorAtlasSize * 4UL;
+
+ public ulong CoverageStagingBytes => _coverageRingBuffer.Size;
+
public int CachedGlyphCount => _glyphs.Count;
public int CompiledGpuGlyphCount
{
- get
- {
- int count = 0;
- foreach (FontGpuData data in _fontGpuData.Values)
- {
- count += data.RecordSlots.Count;
- }
- return count;
- }
+ get => _recordCount;
}
public int AllocatedGpuGlyphRecordCapacity
{
- get
- {
- int count = 0;
- foreach (FontGpuData data in _fontGpuData.Values)
- {
- count += data.RecordCapacity;
- }
- return count;
- }
+ get => _recordCapacity;
+ }
+
+ public ulong AllocatedGpuOutlineBytes
+ {
+ get => _recordsBuffer.Size + _segmentsBuffer.Size;
}
+ public ulong GpuOutlineCacheBudgetBytes => DefaultGpuOutlineCacheBudgetBytes;
+
+ public ulong GpuOutlineCacheResetCount { get; private set; }
+
+ public ulong OutlineUploadWriteCount { get; private set; }
+
+ public ulong UniformUploadWriteCount { get; private set; }
+
+ public ulong RasterBatchSubmissionCount { get; private set; }
+
+ public ulong RasterBindGroupCreationCount { get; private set; }
+
+ public ulong RasterComputePassCount { get; private set; }
+
+ public int LastBatchNewGlyphCount { get; private set; }
+
public ulong Generation { get; private set; }
public bool CapacityExceeded { get; private set; }
@@ -211,7 +360,10 @@ public int AllocatedGpuGlyphRecordCapacity
public ulong ClearCount { get; private set; }
- public bool IsAlmostFull => _nextShelfY > (_atlasSize * 0.85f);
+ public bool IsAlmostFull =>
+ (_atlasSize >= _maxAtlasSize && _nextShelfY > (_atlasSize * 0.85f)) ||
+ (_colorAtlasSize >= _maxColorAtlasSize &&
+ _nextColorShelfY > (_colorAtlasSize * 0.85f));
public void Clear()
{
@@ -220,55 +372,208 @@ public void Clear()
ProGpuTextDiagnostics.WriteLine("[GlyphAtlas] Proactive Clear: Resetting packer and clearing cache.");
_glyphs.Clear();
_shelves.Clear();
+ _colorShelves.Clear();
_nextShelfY = 2;
+ _nextColorShelfY = 2;
_atlasTexture.ClearRenderTarget();
+ _colorAtlasTexture.ClearRenderTarget();
CapacityExceeded = false;
ClearCount++;
Generation++;
}
public GlyphAtlas(WgpuContext context, uint atlasSize = 2048)
- : this(context, atlasSize, DefaultUniformRingBufferSize)
+ : this(
+ context,
+ atlasSize,
+ Math.Min(atlasSize, DefaultColorAtlasSize),
+ DefaultUniformRingBufferSize,
+ DefaultCoverageRingBufferSize)
{
}
internal GlyphAtlas(WgpuContext context, uint atlasSize, uint uniformRingBufferSize)
+ : this(
+ context,
+ atlasSize,
+ Math.Min(atlasSize, DefaultColorAtlasSize),
+ uniformRingBufferSize,
+ DefaultCoverageRingBufferSize)
+ {
+ }
+
+ public GlyphAtlas(
+ WgpuContext context,
+ uint atlasSize,
+ uint colorAtlasSize,
+ uint coverageRingBufferSize)
+ : this(
+ context,
+ atlasSize,
+ colorAtlasSize,
+ DefaultUniformRingBufferSize,
+ coverageRingBufferSize)
+ {
+ }
+
+ private GlyphAtlas(
+ WgpuContext context,
+ uint atlasSize,
+ uint colorAtlasSize,
+ uint uniformRingBufferSize,
+ uint coverageRingBufferSize)
{
if (uniformRingBufferSize < 256 || uniformRingBufferSize % 256 != 0)
{
throw new ArgumentOutOfRangeException(nameof(uniformRingBufferSize));
}
+ if (atlasSize <= 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(atlasSize));
+ }
+ if (colorAtlasSize <= 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(colorAtlasSize));
+ }
+ if (coverageRingBufferSize < 256 || coverageRingBufferSize % 256 != 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(coverageRingBufferSize));
+ }
_context = context;
- _atlasSize = atlasSize;
+ _maxAtlasSize = atlasSize;
+ _maxColorAtlasSize = colorAtlasSize;
+ _atlasSize = Math.Min(atlasSize, DefaultInitialAtlasSize);
+ _colorAtlasSize = Math.Min(colorAtlasSize, DefaultInitialColorAtlasSize);
- // Use Rgba8Unorm for dynamic alpha mapping (highly memory efficient and WebGPU Storage standard-compliant)
- // With TextureUsage.StorageBinding to allow Compute Shader writing directly to it
+ // Monochrome coverage is sampled from one byte per texel. Compute writes a
+ // packed storage buffer and the GPU copies it into this filterable texture.
_atlasTexture = new GpuTexture(
_context,
_atlasSize,
_atlasSize,
- TextureFormat.Rgba8Unorm,
+ TextureFormat.R8Unorm,
+ TextureUsage.TextureBinding | TextureUsage.CopySrc | TextureUsage.CopyDst |
+ TextureUsage.RenderAttachment,
+ "Dynamic Glyph Coverage Atlas"
+ );
+ _colorAtlasTexture = new GpuTexture(
+ _context,
+ _colorAtlasSize,
+ _colorAtlasSize,
+ TextureFormat.Rgba8Unorm,
TextureUsage.TextureBinding | TextureUsage.CopySrc | TextureUsage.CopyDst |
- TextureUsage.StorageBinding | TextureUsage.RenderAttachment,
- "Dynamic Glyph Atlas"
+ TextureUsage.RenderAttachment,
+ "Dynamic Color Glyph Atlas"
);
_atlasTexture.ClearRenderTarget();
+ _colorAtlasTexture.ClearRenderTarget();
// Compile and create the compute pipeline
_pipelineCache = new RenderPipelineCache(_context);
+ _computeBindGroupLayout = CreateRasterizationBindGroupLayout();
+ _computePipelineLayout = CreateRasterizationPipelineLayout(_computeBindGroupLayout);
var shaderModule = _pipelineCache.GetOrCreateShader("GlyphRasterizer", Shaders.GlyphRasterizerShader, "GlyphRasterizerShader");
- _computePipeline = _pipelineCache.GetOrCreateComputePipeline("GlyphRasterizer", shaderModule, "cs_main");
-
- // Allocate a 256KB uniform ring buffer once at startup to eliminate CPU-to-GPU memory allocation overhead
+ _computePipeline = _pipelineCache.GetOrCreateComputePipeline(
+ "GlyphRasterizer",
+ shaderModule,
+ "cs_main",
+ _computePipelineLayout);
+
+ // Allocate a small bounded uniform ring once at startup to avoid
+ // per-glyph CPU-to-GPU buffer allocation overhead.
_uniformRingBuffer = new GpuBuffer(
_context,
uniformRingBufferSize,
BufferUsage.Uniform | BufferUsage.CopyDst,
"Glyph Atlas Uniform Ring Buffer");
+ _coverageRingBuffer = new GpuBuffer(
+ _context,
+ coverageRingBufferSize,
+ BufferUsage.Storage | BufferUsage.CopySrc,
+ "Glyph Coverage Staging Ring Buffer");
+ _uniformRingUpload = new byte[uniformRingBufferSize];
+ (_recordsBuffer, _segmentsBuffer) = CreateOutlineBuffers(
+ _recordCapacity,
+ _segmentCapacity);
_ringOffset = 0;
+ _coverageRingOffset = 0;
+ }
+
+ private BindGroupLayout* CreateRasterizationBindGroupLayout()
+ {
+ var entries = stackalloc BindGroupLayoutEntry[4];
+ entries[0] = new BindGroupLayoutEntry
+ {
+ Binding = 0,
+ Visibility = ShaderStage.Compute,
+ Buffer = new BufferBindingLayout
+ {
+ Type = BufferBindingType.Uniform,
+ HasDynamicOffset = true,
+ MinBindingSize = (ulong)Marshal.SizeOf()
+ }
+ };
+ entries[1] = new BindGroupLayoutEntry
+ {
+ Binding = 1,
+ Visibility = ShaderStage.Compute,
+ Buffer = new BufferBindingLayout
+ {
+ Type = BufferBindingType.ReadOnlyStorage,
+ MinBindingSize = (ulong)Marshal.SizeOf()
+ }
+ };
+ entries[2] = new BindGroupLayoutEntry
+ {
+ Binding = 2,
+ Visibility = ShaderStage.Compute,
+ Buffer = new BufferBindingLayout
+ {
+ Type = BufferBindingType.ReadOnlyStorage,
+ MinBindingSize = (ulong)Marshal.SizeOf()
+ }
+ };
+ entries[3] = new BindGroupLayoutEntry
+ {
+ Binding = 3,
+ Visibility = ShaderStage.Compute,
+ Buffer = new BufferBindingLayout
+ {
+ Type = BufferBindingType.Storage,
+ MinBindingSize = 4
+ }
+ };
+ var descriptor = new BindGroupLayoutDescriptor
+ {
+ EntryCount = 4,
+ Entries = entries
+ };
+ var layout = _context.Api.DeviceCreateBindGroupLayout(_context.Device, &descriptor);
+ if (layout == null)
+ {
+ throw new InvalidOperationException("Failed to create the glyph rasterization bind group layout.");
+ }
+ return layout;
+ }
+
+ private PipelineLayout* CreateRasterizationPipelineLayout(BindGroupLayout* bindGroupLayout)
+ {
+ var layouts = stackalloc BindGroupLayout*[1];
+ layouts[0] = bindGroupLayout;
+ var descriptor = new PipelineLayoutDescriptor
+ {
+ BindGroupLayoutCount = 1,
+ BindGroupLayouts = layouts
+ };
+ var layout = _context.Api.DeviceCreatePipelineLayout(_context.Device, &descriptor);
+ if (layout == null)
+ {
+ throw new InvalidOperationException("Failed to create the glyph rasterization pipeline layout.");
+ }
+ return layout;
}
private static uint DivRoundUp(uint value, uint divisor) => (value + divisor - 1) / divisor;
@@ -433,6 +738,7 @@ void ProcessPt(Vector2 pt)
gW,
gH,
preferGlyphAtlas,
+ colorBitmap: false,
out uint posX,
out uint posY,
out uint regionWidth,
@@ -453,10 +759,12 @@ void ProcessPt(Vector2 pt)
// large or variable font here creates severe first-use stalls.
if (!_fontGpuData.TryGetValue(font, out var gpuData))
{
- gpuData = CreateFontGpuData();
+ gpuData = new FontGpuData();
_fontGpuData[font] = gpuData;
}
uint gpuGlyphSlot = EnsureGpuGlyph(font, glyphIdx, gpuData);
+ uint coverageBytesPerRow = GpuCoverageUpload.GetBytesPerRow(gW);
+ uint coverageBytes = checked(coverageBytesPerRow * gH);
// Write uniforms for the glyph
var uniforms = new GlyphUniforms
@@ -465,63 +773,79 @@ void ProcessPt(Vector2 pt)
YStart = yStart,
Scale = scale,
GlyphIndex = gpuGlyphSlot,
- AtlasX = posX,
- AtlasY = posY,
Width = gW,
Height = gH,
SubpixelX = subpixelX * 0.25f
};
- var bindGroupLayout = _context.Api.ComputePipelineGetBindGroupLayout(_computePipeline, 0);
uint alignedSize = (uint)((Marshal.SizeOf() + 255) & ~255);
if (_batchEncoder != null)
{
// Ring buffer slice allocation
- if (_ringOffset + alignedSize > _uniformRingBuffer.Size)
+ if (coverageBytes > _coverageRingBuffer.Size)
{
FlushBatchEncoder();
+ RasterizeOversizedGlyph(
+ uniforms,
+ coverageBytes,
+ coverageBytesPerRow,
+ posX,
+ posY,
+ gW,
+ gH);
CreateBatchEncoder();
+ goto RasterizationComplete;
}
-
- _context.Api.QueueWriteBuffer(_context.Queue, _uniformRingBuffer.BufferPtr, _ringOffset, &uniforms, (uint)Marshal.SizeOf());
-
- var entries = stackalloc BindGroupEntry[4];
- entries[0] = new BindGroupEntry { Binding = 0, Buffer = _uniformRingBuffer.BufferPtr, Offset = _ringOffset, Size = (uint)Marshal.SizeOf() };
- entries[1] = new BindGroupEntry { Binding = 1, Buffer = gpuData.RecordsBuffer.BufferPtr, Offset = 0, Size = gpuData.RecordsBuffer.Size };
- entries[2] = new BindGroupEntry { Binding = 2, Buffer = gpuData.SegmentsBuffer.BufferPtr, Offset = 0, Size = gpuData.SegmentsBuffer.Size };
- entries[3] = new BindGroupEntry { Binding = 3, TextureView = _atlasTexture.ViewPtr };
-
- var bgDesc = new BindGroupDescriptor
+ if (_ringOffset + alignedSize > _uniformRingBuffer.Size ||
+ _coverageRingOffset + coverageBytes > _coverageRingBuffer.Size)
{
- Layout = bindGroupLayout,
- EntryCount = 4,
- Entries = entries
- };
- var bg = _context.Api.DeviceCreateBindGroup(_context.Device, &bgDesc);
-
- // Batch path: Record compute pass to batch encoder, defer resource cleanup to EndBatch
- var passDesc = new ComputePassDescriptor();
- var pass = _context.Api.CommandEncoderBeginComputePass(_batchEncoder, &passDesc);
-
- _context.Api.ComputePassEncoderSetPipeline(pass, _computePipeline);
- _context.Api.ComputePassEncoderSetBindGroup(pass, 0, bg, 0, null);
+ FlushBatchEncoder();
+ CreateBatchEncoder();
+ }
- uint workgroupsX = DivRoundUp(gW, 16);
+ uniforms.OutputOffsetWords = _coverageRingOffset / 4;
+ uniforms.OutputRowWords = coverageBytesPerRow / 4;
+ MemoryMarshal.Write(
+ _uniformRingUpload.AsSpan(
+ checked((int)_ringOffset),
+ Marshal.SizeOf()),
+ in uniforms);
+
+ var bg = GetOrCreateRingBindGroup();
+
+ // Batch path: all disjoint coverage dispatches share one
+ // compute pass; their buffer-to-texture copies are appended
+ // after the pass closes at the batch boundary.
+ var pass = GetOrCreateBatchComputePass();
+ uint dynamicUniformOffset = _ringOffset;
+ _context.Api.ComputePassEncoderSetBindGroup(
+ pass,
+ 0,
+ bg,
+ 1,
+ &dynamicUniformOffset);
+
+ uint workgroupsX = DivRoundUp(DivRoundUp(gW, 4), 16);
uint workgroupsY = DivRoundUp(gH, 16);
_context.Api.ComputePassEncoderDispatchWorkgroups(pass, workgroupsX, workgroupsY, 1);
- _context.Api.ComputePassEncoderEnd(pass);
- _context.Api.ComputePassEncoderRelease(pass);
-
- _batchBindGroups.Add((nint)bg);
- _context.Api.BindGroupLayoutRelease(bindGroupLayout);
+ _batchCoverageCopies.Add(new PendingCoverageCopy(
+ _coverageRingOffset,
+ coverageBytesPerRow,
+ posX,
+ posY,
+ gW,
+ gH));
_ringOffset += alignedSize;
+ _coverageRingOffset += coverageBytes;
}
else
{
- // Immediate path: Allocate a temporary GPU buffer, write, and submit instantly
+ // Immediate path: use a compact GPU coverage buffer, then copy its R8 bytes.
+ uniforms.OutputOffsetWords = 0;
+ uniforms.OutputRowWords = coverageBytesPerRow / 4;
var uniformsBuffer = new GpuBuffer(
_context,
(uint)Marshal.SizeOf(),
@@ -529,20 +853,26 @@ void ProcessPt(Vector2 pt)
"Glyph Uniforms"
);
uniformsBuffer.WriteSingle(uniforms);
+ var coverageBuffer = new GpuBuffer(
+ _context,
+ coverageBytes,
+ BufferUsage.Storage | BufferUsage.CopySrc,
+ "Glyph Coverage Staging Buffer");
var entries = stackalloc BindGroupEntry[4];
entries[0] = new BindGroupEntry { Binding = 0, Buffer = uniformsBuffer.BufferPtr, Offset = 0, Size = uniformsBuffer.Size };
- entries[1] = new BindGroupEntry { Binding = 1, Buffer = gpuData.RecordsBuffer.BufferPtr, Offset = 0, Size = gpuData.RecordsBuffer.Size };
- entries[2] = new BindGroupEntry { Binding = 2, Buffer = gpuData.SegmentsBuffer.BufferPtr, Offset = 0, Size = gpuData.SegmentsBuffer.Size };
- entries[3] = new BindGroupEntry { Binding = 3, TextureView = _atlasTexture.ViewPtr };
+ entries[1] = new BindGroupEntry { Binding = 1, Buffer = _recordsBuffer.BufferPtr, Offset = 0, Size = _recordsBuffer.Size };
+ entries[2] = new BindGroupEntry { Binding = 2, Buffer = _segmentsBuffer.BufferPtr, Offset = 0, Size = _segmentsBuffer.Size };
+ entries[3] = new BindGroupEntry { Binding = 3, Buffer = coverageBuffer.BufferPtr, Offset = 0, Size = coverageBuffer.Size };
var bgDesc = new BindGroupDescriptor
{
- Layout = bindGroupLayout,
+ Layout = _computeBindGroupLayout,
EntryCount = 4,
Entries = entries
};
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);
@@ -552,15 +882,33 @@ void ProcessPt(Vector2 pt)
var pass = _context.Api.CommandEncoderBeginComputePass(encoder, &passDesc);
_context.Api.ComputePassEncoderSetPipeline(pass, _computePipeline);
- _context.Api.ComputePassEncoderSetBindGroup(pass, 0, bg, 0, null);
-
- uint workgroupsX = DivRoundUp(gW, 16);
+ uint dynamicUniformOffset = 0;
+ _context.Api.ComputePassEncoderSetBindGroup(
+ pass,
+ 0,
+ bg,
+ 1,
+ &dynamicUniformOffset);
+
+ uint workgroupsX = DivRoundUp(DivRoundUp(gW, 4), 16);
uint workgroupsY = DivRoundUp(gH, 16);
_context.Api.ComputePassEncoderDispatchWorkgroups(pass, workgroupsX, workgroupsY, 1);
_context.Api.ComputePassEncoderEnd(pass);
_context.Api.ComputePassEncoderRelease(pass);
+ GpuCoverageUpload.RecordCopy(
+ _context,
+ encoder,
+ coverageBuffer,
+ 0,
+ coverageBytesPerRow,
+ _atlasTexture,
+ posX,
+ posY,
+ gW,
+ gH);
+
// Submit to queue
var cmdDesc = new CommandBufferDescriptor { Label = (byte*)SilkMarshal.StringToPtr("Glyph Rasterizer Command Buffer") };
var cmdBuffer = _context.Api.CommandEncoderFinish(encoder, &cmdDesc);
@@ -572,10 +920,11 @@ void ProcessPt(Vector2 pt)
_context.Api.CommandBufferRelease(cmdBuffer);
_context.Api.CommandEncoderRelease(encoder);
_context.Api.BindGroupRelease(bg);
- _context.Api.BindGroupLayoutRelease(bindGroupLayout);
uniformsBuffer.Dispose();
+ coverageBuffer.Dispose();
}
+ RasterizationComplete:
// Compute UV coordinates
float texelSize = 1.0f / _atlasSize;
var uvMin = new Vector2(posX * texelSize, posY * texelSize);
@@ -616,6 +965,80 @@ private void CacheGlyph(GlyphKey key, GlyphInfo info, bool isCapacityFallback =
};
}
+ private void RasterizeOversizedGlyph(
+ GlyphUniforms uniforms,
+ uint coverageBytes,
+ uint coverageBytesPerRow,
+ uint atlasX,
+ uint atlasY,
+ uint width,
+ uint height)
+ {
+ uniforms.OutputOffsetWords = 0;
+ uniforms.OutputRowWords = coverageBytesPerRow / 4;
+ using var uniformsBuffer = new GpuBuffer(
+ _context,
+ (uint)Marshal.SizeOf(),
+ BufferUsage.Uniform | BufferUsage.CopyDst,
+ "Oversized Glyph Uniforms");
+ using var coverageBuffer = new GpuBuffer(
+ _context,
+ coverageBytes,
+ BufferUsage.Storage | BufferUsage.CopySrc,
+ "Oversized Glyph Coverage Staging Buffer");
+ uniformsBuffer.WriteSingle(uniforms);
+
+ var entries = stackalloc BindGroupEntry[4];
+ entries[0] = new BindGroupEntry { Binding = 0, Buffer = uniformsBuffer.BufferPtr, Offset = 0, Size = uniformsBuffer.Size };
+ entries[1] = new BindGroupEntry { Binding = 1, Buffer = _recordsBuffer.BufferPtr, Offset = 0, Size = _recordsBuffer.Size };
+ entries[2] = new BindGroupEntry { Binding = 2, Buffer = _segmentsBuffer.BufferPtr, Offset = 0, Size = _segmentsBuffer.Size };
+ entries[3] = new BindGroupEntry { Binding = 3, Buffer = coverageBuffer.BufferPtr, Offset = 0, Size = coverageBuffer.Size };
+ var bindGroupDescriptor = new BindGroupDescriptor
+ {
+ Layout = _computeBindGroupLayout,
+ EntryCount = 4,
+ Entries = entries
+ };
+ var bindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &bindGroupDescriptor);
+ RasterBindGroupCreationCount++;
+ var encoderDescriptor = new CommandEncoderDescriptor();
+ var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDescriptor);
+ var passDescriptor = new ComputePassDescriptor();
+ var pass = _context.Api.CommandEncoderBeginComputePass(encoder, &passDescriptor);
+ _context.Api.ComputePassEncoderSetPipeline(pass, _computePipeline);
+ uint dynamicUniformOffset = 0;
+ _context.Api.ComputePassEncoderSetBindGroup(
+ pass,
+ 0,
+ bindGroup,
+ 1,
+ &dynamicUniformOffset);
+ _context.Api.ComputePassEncoderDispatchWorkgroups(
+ pass,
+ DivRoundUp(DivRoundUp(width, 4), 16),
+ DivRoundUp(height, 16),
+ 1);
+ _context.Api.ComputePassEncoderEnd(pass);
+ _context.Api.ComputePassEncoderRelease(pass);
+ GpuCoverageUpload.RecordCopy(
+ _context,
+ encoder,
+ coverageBuffer,
+ 0,
+ coverageBytesPerRow,
+ _atlasTexture,
+ atlasX,
+ atlasY,
+ width,
+ height);
+ var commandBufferDescriptor = new CommandBufferDescriptor();
+ var commandBuffer = _context.Api.CommandEncoderFinish(encoder, &commandBufferDescriptor);
+ _context.Api.QueueSubmit(_context.Queue, 1, &commandBuffer);
+ _context.Api.CommandBufferRelease(commandBuffer);
+ _context.Api.CommandEncoderRelease(encoder);
+ _context.Api.BindGroupRelease(bindGroup);
+ }
+
private bool TryCreateColorBitmapGlyph(
TtfFont font,
ushort glyphIndex,
@@ -654,6 +1077,7 @@ private bool TryCreateColorBitmapGlyph(
width,
height,
preferGlyphAtlas,
+ colorBitmap: true,
out var x,
out var y,
out var regionWidth,
@@ -662,8 +1086,8 @@ private bool TryCreateColorBitmapGlyph(
return false;
}
- _atlasTexture.WritePixelsSubRect(decoded.Data, x, y, width, height);
- var texelSize = 1f / _atlasSize;
+ _colorAtlasTexture.WritePixelsSubRect(decoded.Data, x, y, width, height);
+ var texelSize = 1f / _colorAtlasSize;
var bitmapScale = bitmap.PixelsPerEm > 0 ? size / bitmap.PixelsPerEm : 1f;
var bearX = bitmap.UsesHorizontalMetrics
? bitmap.BearingX
@@ -713,6 +1137,7 @@ private bool TryAllocateAtlasRegion(
uint width,
uint height,
bool preferGlyphAtlas,
+ bool colorBitmap,
out uint x,
out uint y,
out uint regionWidth,
@@ -722,28 +1147,45 @@ private bool TryAllocateAtlasRegion(
y = 0;
regionWidth = 0;
regionHeight = 0;
- if (_atlasSize <= 4 || width == 0 || height == 0)
+ uint atlasSize = colorBitmap ? _colorAtlasSize : _atlasSize;
+ List shelves = colorBitmap ? _colorShelves : _shelves;
+ uint nextShelfY = colorBitmap ? _nextColorShelfY : _nextShelfY;
+ if (atlasSize <= 4 || width == 0 || height == 0)
{
return false;
}
- if (width > _atlasSize - 4 || height > _atlasSize - 4)
+ if (width > atlasSize - 4 || height > atlasSize - 4)
{
+ if (TryGrowAtlas(colorBitmap, width, height))
+ {
+ return TryAllocateAtlasRegion(
+ width,
+ height,
+ preferGlyphAtlas,
+ colorBitmap,
+ out x,
+ out y,
+ out regionWidth,
+ out regionHeight);
+ }
+
CapacityExceeded = true;
ProGpuTextDiagnostics.WriteLine(
- $"[GlyphAtlas] Glyph {width}x{height} cannot fit in the {_atlasSize}x{_atlasSize} atlas; using vector fallback.");
+ $"[GlyphAtlas] Glyph {width}x{height} cannot fit in the {atlasSize}x{atlasSize} " +
+ $"{(colorBitmap ? "color" : "coverage")} atlas; using vector fallback.");
return false;
}
// Keep reusable size classes, but classify each axis independently. A square
// based on max(width, height) wastes most of the atlas for narrow glyphs and
// forces avoidable GPU rerasterization while scrolling through a font.
- uint allocationWidth = GetPreferredAllocationExtent(width);
- uint allocationHeight = GetPreferredAllocationExtent(height);
- for (int shelfIndex = 0; shelfIndex < _shelves.Count; shelfIndex++)
+ uint allocationWidth = GetPreferredAllocationExtent(width, atlasSize);
+ uint allocationHeight = GetPreferredAllocationExtent(height, atlasSize);
+ for (int shelfIndex = 0; shelfIndex < shelves.Count; shelfIndex++)
{
- AtlasShelf shelf = _shelves[shelfIndex];
- if (shelf.Height != allocationHeight || shelf.NextX + allocationWidth + 2 > _atlasSize)
+ AtlasShelf shelf = shelves[shelfIndex];
+ if (shelf.Height != allocationHeight || shelf.NextX + allocationWidth + 2 > atlasSize)
{
continue;
}
@@ -753,15 +1195,29 @@ private bool TryAllocateAtlasRegion(
regionWidth = allocationWidth;
regionHeight = allocationHeight;
shelf.NextX += allocationWidth + 2;
- _shelves[shelfIndex] = shelf;
+ shelves[shelfIndex] = shelf;
return true;
}
- if (_nextShelfY + allocationHeight + 2 > _atlasSize)
+ if (nextShelfY + allocationHeight + 2 > atlasSize)
{
+ if (TryGrowAtlas(colorBitmap, width, height))
+ {
+ return TryAllocateAtlasRegion(
+ width,
+ height,
+ preferGlyphAtlas,
+ colorBitmap,
+ out x,
+ out y,
+ out regionWidth,
+ out regionHeight);
+ }
+
if (preferGlyphAtlas && TryReuseLeastRecentlyUsedRegion(
allocationWidth,
allocationHeight,
+ colorBitmap,
out x,
out y,
out regionWidth,
@@ -778,32 +1234,124 @@ private bool TryAllocateAtlasRegion(
}
x = 2;
- y = _nextShelfY;
+ y = nextShelfY;
regionWidth = allocationWidth;
regionHeight = allocationHeight;
- _shelves.Add(new AtlasShelf
+ shelves.Add(new AtlasShelf
{
Y = y,
Height = allocationHeight,
NextX = x + allocationWidth + 2
});
- _nextShelfY += allocationHeight + 2;
+ nextShelfY += allocationHeight + 2;
+ if (colorBitmap)
+ {
+ _nextColorShelfY = nextShelfY;
+ }
+ else
+ {
+ _nextShelfY = nextShelfY;
+ }
+ return true;
+ }
+
+ private bool TryGrowAtlas(bool colorBitmap, uint requiredWidth, uint requiredHeight)
+ {
+ uint currentSize = colorBitmap ? _colorAtlasSize : _atlasSize;
+ uint maximumSize = colorBitmap ? _maxColorAtlasSize : _maxAtlasSize;
+ if (currentSize >= maximumSize)
+ {
+ return false;
+ }
+
+ uint requiredSize = checked(Math.Max(requiredWidth, requiredHeight) + 4);
+ uint newSize = currentSize;
+ do
+ {
+ newSize = Math.Min(maximumSize, checked(newSize * 2));
+ }
+ while (newSize < requiredSize && newSize < maximumSize);
+
+ if (newSize < requiredSize)
+ {
+ return false;
+ }
+
+ bool resumeBatch = _batchEncoder != null;
+ if (resumeBatch)
+ {
+ FlushBatchEncoder();
+ }
+
+ GpuTexture oldTexture = colorBitmap ? _colorAtlasTexture : _atlasTexture;
+ var newTexture = new GpuTexture(
+ _context,
+ newSize,
+ newSize,
+ colorBitmap ? TextureFormat.Rgba8Unorm : TextureFormat.R8Unorm,
+ TextureUsage.TextureBinding | TextureUsage.CopySrc | TextureUsage.CopyDst |
+ TextureUsage.RenderAttachment,
+ colorBitmap ? "Dynamic Color Glyph Atlas" : "Dynamic Glyph Coverage Atlas");
+ newTexture.ClearRenderTarget();
+ newTexture.CopyBaseLevelRegionFrom(oldTexture, currentSize, currentSize);
+
+ if (colorBitmap)
+ {
+ _colorAtlasTexture = newTexture;
+ _colorAtlasSize = newSize;
+ }
+ else
+ {
+ _atlasTexture = newTexture;
+ _atlasSize = newSize;
+ }
+
+ oldTexture.Dispose();
+ RefreshNormalizedTextureCoordinates(colorBitmap, newSize);
+ TextureRevision++;
+ CapacityExceeded = false;
+ if (resumeBatch)
+ {
+ CreateBatchEncoder();
+ }
return true;
}
- private uint GetPreferredAllocationExtent(uint extent)
+ private void RefreshNormalizedTextureCoordinates(bool colorBitmap, uint atlasSize)
+ {
+ float texelSize = 1f / atlasSize;
+ foreach (KeyValuePair pair in _glyphs)
+ {
+ ref CachedGlyph cached = ref CollectionsMarshal.GetValueRefOrNullRef(
+ _glyphs,
+ pair.Key);
+ ref GlyphInfo info = ref cached.Info;
+ if (info.IsColorBitmap != colorBitmap || info.Width == 0 || info.Height == 0)
+ {
+ continue;
+ }
+
+ info.TexCoordMin = new Vector2(info.X * texelSize, info.Y * texelSize);
+ info.TexCoordMax = new Vector2(
+ (info.X + info.Width) * texelSize,
+ (info.Y + info.Height) * texelSize);
+ }
+ }
+
+ private static uint GetPreferredAllocationExtent(uint extent, uint atlasSize)
{
// Eight-pixel classes retain cheap best-fit reuse without the near-2x loss
// per axis caused by powers of two. Coverage still occupies its exact width
// and height; this only controls reserved atlas residency.
extent = Math.Max(8u, extent);
uint alignedExtent = (extent + 7u) & ~7u;
- return Math.Min(alignedExtent, _atlasSize - 4);
+ return Math.Min(alignedExtent, atlasSize - 4);
}
private bool TryReuseLeastRecentlyUsedRegion(
uint width,
uint height,
+ bool colorBitmap,
out uint x,
out uint y,
out uint regionWidth,
@@ -826,6 +1374,7 @@ private bool TryReuseLeastRecentlyUsedRegion(
uint entryWidth = info.AtlasRegionWidth > 0 ? info.AtlasRegionWidth : info.Width;
uint entryHeight = info.AtlasRegionHeight > 0 ? info.AtlasRegionHeight : info.Height;
if (entry.LastUsedFrame == _frameNumber ||
+ info.IsColorBitmap != colorBitmap ||
entryWidth < width || entryHeight < height ||
entryWidth == 0 || entryHeight == 0)
{
@@ -884,25 +1433,23 @@ private static GlyphInfo CreateEmptyGlyphInfo(TtfFont font, ushort glyphIdx, flo
};
}
- private FontGpuData CreateFontGpuData()
+ private (GpuBuffer Records, GpuBuffer Segments) CreateOutlineBuffers(
+ int recordCapacity,
+ int segmentCapacity)
{
int recordSize = Marshal.SizeOf();
int segmentSize = Marshal.SizeOf();
- uint recordsSize = checked((uint)(InitialFontRecordCapacity * recordSize));
- uint segmentsSize = checked((uint)(InitialFontSegmentCapacity * segmentSize));
- return new FontGpuData
- {
- RecordsBuffer = new GpuBuffer(
+ return (
+ new GpuBuffer(
_context,
- recordsSize,
- BufferUsage.Storage | BufferUsage.CopyDst,
+ checked((uint)(recordCapacity * recordSize)),
+ BufferUsage.Storage | BufferUsage.CopyDst | BufferUsage.CopySrc,
"Incremental Glyph Records Buffer"),
- SegmentsBuffer = new GpuBuffer(
+ new GpuBuffer(
_context,
- segmentsSize,
- BufferUsage.Storage | BufferUsage.CopyDst,
- "Incremental Glyph Segments Buffer")
- };
+ checked((uint)(segmentCapacity * segmentSize)),
+ BufferUsage.Storage | BufferUsage.CopyDst | BufferUsage.CopySrc,
+ "Incremental Glyph Segments Buffer"));
}
private uint EnsureGpuGlyph(TtfFont font, ushort glyphIndex, FontGpuData data)
@@ -912,100 +1459,245 @@ private uint EnsureGpuGlyph(TtfFont font, ushort glyphIndex, FontGpuData data)
return existingSlot;
}
- int previousSegmentCount = data.Segments.Count;
- GpuGlyphRecord record;
- try
+ _glyphSegmentScratch.Clear();
+ GpuGlyphRecord record = font.AppendGpuOutlineData(glyphIndex, _glyphSegmentScratch);
+ record.StartSegment = checked(record.StartSegment + (uint)_segmentCount);
+ uint recordSlot = checked((uint)_recordCount);
+ int requiredRecordCount = checked(_recordCount + 1);
+ int requiredSegmentCount = checked(_segmentCount + _glyphSegmentScratch.Count);
+ int recordSize = Marshal.SizeOf();
+ int segmentSize = Marshal.SizeOf();
+
+ if (requiredRecordCount > _recordCapacity || requiredSegmentCount > _segmentCapacity)
{
- record = font.AppendGpuOutlineData(glyphIndex, data.Segments);
+ // Earlier dispatches in the active command encoder may still reference
+ // the current buffers. Materialize their contiguous CPU staging ranges
+ // before copying those buffers into a larger allocation.
+ FlushPendingOutlineUploads();
}
- catch
+
+ if ((_pendingRecordUploads.Count > 0 &&
+ checked((_pendingRecordUploads.Count + 1) * recordSize) > DefaultOutlineUploadChunkBytes) ||
+ (_pendingSegmentUploads.Count > 0 &&
+ checked((_pendingSegmentUploads.Count + _glyphSegmentScratch.Count) * segmentSize) >
+ DefaultOutlineUploadChunkBytes))
{
- if (data.Segments.Count > previousSegmentCount)
- {
- data.Segments.RemoveRange(
- previousSegmentCount,
- data.Segments.Count - previousSegmentCount);
- }
- throw;
+ FlushPendingOutlineUploads();
}
- uint recordSlot = checked((uint)data.Records.Count);
- data.Records.Add(record);
- int appendedSegmentCount = data.Segments.Count - previousSegmentCount;
- int recordSize = Marshal.SizeOf();
- int segmentSize = Marshal.SizeOf();
- try
+ if (requiredRecordCount > _recordCapacity)
{
- if (data.Records.Count > data.RecordCapacity)
- {
- int capacity = data.RecordCapacity;
- while (capacity < data.Records.Count)
- {
- capacity = checked(capacity * 2);
- }
+ int capacity = GrowCapacity(_recordCapacity, requiredRecordCount);
- var replacement = new GpuBuffer(
- _context,
- checked((uint)(capacity * recordSize)),
- BufferUsage.Storage | BufferUsage.CopyDst,
- "Incremental Glyph Records Buffer");
- replacement.Write(CollectionsMarshal.AsSpan(data.Records));
- ReplaceBatchBuffer(data.RecordsBuffer);
- data.RecordsBuffer = replacement;
- data.RecordCapacity = capacity;
- }
- else
- {
- data.RecordsBuffer.WriteSingle(
- record,
- checked(recordSlot * (uint)recordSize));
- }
+ var replacement = new GpuBuffer(
+ _context,
+ checked((uint)(capacity * recordSize)),
+ BufferUsage.Storage | BufferUsage.CopyDst | BufferUsage.CopySrc,
+ "Incremental Glyph Records Buffer");
+ CopyBufferContents(
+ _recordsBuffer,
+ replacement,
+ checked((uint)(_recordCount * recordSize)));
+ ReplaceBatchBuffer(_recordsBuffer);
+ _recordsBuffer = replacement;
+ _recordCapacity = capacity;
+ }
- if (data.Segments.Count > data.SegmentCapacity)
- {
- int capacity = data.SegmentCapacity;
- while (capacity < data.Segments.Count)
- {
- capacity = checked(capacity * 2);
- }
+ if (requiredSegmentCount > _segmentCapacity)
+ {
+ int capacity = GrowCapacity(_segmentCapacity, requiredSegmentCount);
- var replacement = new GpuBuffer(
- _context,
- checked((uint)(capacity * segmentSize)),
- BufferUsage.Storage | BufferUsage.CopyDst,
- "Incremental Glyph Segments Buffer");
- replacement.Write(CollectionsMarshal.AsSpan(data.Segments));
- ReplaceBatchBuffer(data.SegmentsBuffer);
- data.SegmentsBuffer = replacement;
- data.SegmentCapacity = capacity;
- }
- else if (appendedSegmentCount > 0)
- {
- data.SegmentsBuffer.Write(
- CollectionsMarshal.AsSpan(data.Segments).Slice(
- previousSegmentCount,
- appendedSegmentCount),
- checked((uint)(previousSegmentCount * segmentSize)));
- }
+ var replacement = new GpuBuffer(
+ _context,
+ checked((uint)(capacity * segmentSize)),
+ BufferUsage.Storage | BufferUsage.CopyDst | BufferUsage.CopySrc,
+ "Incremental Glyph Segments Buffer");
+ CopyBufferContents(
+ _segmentsBuffer,
+ replacement,
+ checked((uint)(_segmentCount * segmentSize)));
+ ReplaceBatchBuffer(_segmentsBuffer);
+ _segmentsBuffer = replacement;
+ _segmentCapacity = capacity;
+ }
- data.RecordSlots.Add(glyphIndex, recordSlot);
- return recordSlot;
+ if (_pendingRecordUploads.Count == 0)
+ {
+ _pendingRecordUploadStart = _recordCount;
}
- catch
+ _pendingRecordUploads.Add(record);
+ if (_glyphSegmentScratch.Count > 0)
{
- data.Records.RemoveAt(data.Records.Count - 1);
- if (data.Segments.Count > previousSegmentCount)
+ if (_pendingSegmentUploads.Count == 0)
{
- data.Segments.RemoveRange(
- previousSegmentCount,
- data.Segments.Count - previousSegmentCount);
+ _pendingSegmentUploadStart = _segmentCount;
}
- throw;
+ _pendingSegmentUploads.AddRange(_glyphSegmentScratch);
+ }
+
+ _glyphSegmentScratch.Clear();
+ ReleaseOversizedStagingCapacity(_glyphSegmentScratch, segmentSize, InitialSegmentCapacity);
+
+ _recordCount = requiredRecordCount;
+ _segmentCount = requiredSegmentCount;
+ data.RecordSlots.Add(glyphIndex, recordSlot);
+ _currentBatchNewGlyphCount++;
+ if (_batchEncoder == null)
+ {
+ FlushPendingOutlineUploads();
+ }
+ return recordSlot;
+ }
+
+ private void FlushPendingOutlineUploads()
+ {
+ if (_pendingRecordUploads.Count > 0)
+ {
+ _recordsBuffer.Write(
+ CollectionsMarshal.AsSpan(_pendingRecordUploads),
+ checked((uint)(_pendingRecordUploadStart * Marshal.SizeOf())));
+ _pendingRecordUploads.Clear();
+ ReleaseOversizedStagingCapacity(
+ _pendingRecordUploads,
+ Marshal.SizeOf(),
+ InitialRecordCapacity);
+ OutlineUploadWriteCount++;
+ }
+
+ if (_pendingSegmentUploads.Count > 0)
+ {
+ _segmentsBuffer.Write(
+ CollectionsMarshal.AsSpan(_pendingSegmentUploads),
+ checked((uint)(_pendingSegmentUploadStart * Marshal.SizeOf())));
+ _pendingSegmentUploads.Clear();
+ ReleaseOversizedStagingCapacity(
+ _pendingSegmentUploads,
+ Marshal.SizeOf(),
+ InitialSegmentCapacity);
+ OutlineUploadWriteCount++;
+ }
+ }
+
+ private static void ReleaseOversizedStagingCapacity(
+ List staging,
+ int elementSize,
+ int retainedCapacity)
+ {
+ if (staging.Count == 0 &&
+ checked((ulong)staging.Capacity * (uint)elementSize) >
+ DefaultOutlineUploadChunkBytes)
+ {
+ staging.Capacity = retainedCapacity;
+ }
+ }
+
+ private static int GrowCapacity(int current, int required)
+ {
+ int capacity = current;
+ while (capacity < required)
+ {
+ capacity = checked(capacity + Math.Max(1, capacity / 2));
+ }
+
+ return capacity;
+ }
+
+ private void CopyBufferContents(GpuBuffer source, GpuBuffer destination, uint size)
+ {
+ if (size == 0)
+ {
+ return;
}
+
+ var encoderDescriptor = new CommandEncoderDescriptor();
+ var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDescriptor);
+ _context.Api.CommandEncoderCopyBufferToBuffer(
+ encoder,
+ source.BufferPtr,
+ 0,
+ destination.BufferPtr,
+ 0,
+ size);
+ var commandBufferDescriptor = new CommandBufferDescriptor();
+ var commandBuffer = _context.Api.CommandEncoderFinish(encoder, &commandBufferDescriptor);
+ _context.Api.QueueSubmit(_context.Queue, 1, &commandBuffer);
+ _context.Api.CommandBufferRelease(commandBuffer);
+ _context.Api.CommandEncoderRelease(encoder);
+ }
+
+ private BindGroup* GetOrCreateRingBindGroup()
+ {
+ if (_ringBindGroup != null)
+ {
+ return _ringBindGroup;
+ }
+
+ var entries = stackalloc BindGroupEntry[4];
+ entries[0] = new BindGroupEntry
+ {
+ Binding = 0,
+ Buffer = _uniformRingBuffer.BufferPtr,
+ Offset = 0,
+ Size = (uint)Marshal.SizeOf()
+ };
+ entries[1] = new BindGroupEntry
+ {
+ Binding = 1,
+ Buffer = _recordsBuffer.BufferPtr,
+ Offset = 0,
+ Size = _recordsBuffer.Size
+ };
+ entries[2] = new BindGroupEntry
+ {
+ Binding = 2,
+ Buffer = _segmentsBuffer.BufferPtr,
+ Offset = 0,
+ Size = _segmentsBuffer.Size
+ };
+ entries[3] = new BindGroupEntry
+ {
+ Binding = 3,
+ Buffer = _coverageRingBuffer.BufferPtr,
+ Offset = 0,
+ Size = _coverageRingBuffer.Size
+ };
+ var descriptor = new BindGroupDescriptor
+ {
+ Layout = _computeBindGroupLayout,
+ EntryCount = 4,
+ Entries = entries
+ };
+ _ringBindGroup = _context.Api.DeviceCreateBindGroup(_context.Device, &descriptor);
+ if (_ringBindGroup == null)
+ {
+ throw new InvalidOperationException("Failed to create the glyph rasterization ring bind group.");
+ }
+
+ RasterBindGroupCreationCount++;
+ return _ringBindGroup;
+ }
+
+ private void InvalidateRingBindGroup()
+ {
+ if (_ringBindGroup == null)
+ {
+ return;
+ }
+
+ if (_batchEncoder != null)
+ {
+ _batchBindGroups.Add((nint)_ringBindGroup);
+ }
+ else
+ {
+ _context.Api.BindGroupRelease(_ringBindGroup);
+ }
+ _ringBindGroup = null;
}
private void ReplaceBatchBuffer(GpuBuffer previous)
{
+ InvalidateRingBindGroup();
if (_batchEncoder != null)
{
_batchBuffers.Add(previous);
@@ -1026,19 +1718,20 @@ public void Dispose()
EndBatch();
}
+ InvalidateRingBindGroup();
_uniformRingBuffer.Dispose();
-
- var fontGpuDataEnumerator = _fontGpuData.Values.GetEnumerator();
- while (fontGpuDataEnumerator.MoveNext())
- {
- var data = fontGpuDataEnumerator.Current;
- data.RecordsBuffer.Dispose();
- data.SegmentsBuffer.Dispose();
- }
+ _coverageRingBuffer.Dispose();
+ _recordsBuffer.Dispose();
+ _segmentsBuffer.Dispose();
_fontGpuData.Clear();
+ _pendingRecordUploads.Clear();
+ _pendingSegmentUploads.Clear();
_pipelineCache.Dispose();
+ _context.Api.PipelineLayoutRelease(_computePipelineLayout);
+ _context.Api.BindGroupLayoutRelease(_computeBindGroupLayout);
_atlasTexture.Dispose();
+ _colorAtlasTexture.Dispose();
_glyphs.Clear();
_isDisposed = true;
diff --git a/src/ProGPU.Text/OpenTypeTextShaper.cs b/src/ProGPU.Text/OpenTypeTextShaper.cs
index 3fd813d9..54e56046 100644
--- a/src/ProGPU.Text/OpenTypeTextShaper.cs
+++ b/src/ProGPU.Text/OpenTypeTextShaper.cs
@@ -389,20 +389,30 @@ internal static bool IsDefaultIgnorableCodePoint(uint codePoint) =>
public static IReadOnlyList GetFeatureTags(TtfFont font)
{
ArgumentNullException.ThrowIfNull(font);
- Typeface? typeface = font.LayoutTypeface;
- if (typeface is null)
- {
- return [];
- }
-
var tags = new HashSet(StringComparer.Ordinal);
- AddFeatureTags(typeface.GSUBTable?.FeatureList, tags);
- AddFeatureTags(typeface.GPOSTable?.FeatureList, tags);
+ AddRawFeatureTags(font, "GSUB", tags);
+ AddRawFeatureTags(font, "GPOS", tags);
var result = tags.ToList();
result.Sort(StringComparer.Ordinal);
return result;
}
+ private static void AddRawFeatureTags(TtfFont font, string tableTag, HashSet tags)
+ {
+ if (!font.TryGetTable(tableTag, out ReadOnlyMemory memory)) return;
+ ReadOnlySpan data = memory.Span;
+ if (!CanRead(data, 6, 2)) return;
+ int featureListOffset = ReadU16(data, 6);
+ if (!CanRead(data, featureListOffset, 2)) return;
+ ushort featureCount = ReadU16(data, featureListOffset);
+ for (var index = 0; index < featureCount; index++)
+ {
+ int recordOffset = featureListOffset + 2 + index * 6;
+ if (!CanRead(data, recordOffset, 6)) break;
+ tags.Add(Encoding.ASCII.GetString(data.Slice(recordOffset, 4)));
+ }
+ }
+
public static IReadOnlyList Shape(
string text,
TtfFont font,
@@ -856,17 +866,17 @@ private static TextShapingOptions CreateVerificationOptions(
ShapingBufferFlags flags,
int fragmentStart,
int fragmentEnd) => new()
- {
- Script = unicodeScript,
- Language = source.Language,
- Direction = direction,
- Features = source.Features,
- ExplicitFeatureTags = source.ExplicitFeatureTags,
- ClusterLevel = source.ClusterLevel,
- BufferFlags = flags,
- RangedFeatures = RemapVerificationFeatures(source.RangedFeatures.Span, fragmentStart, fragmentEnd),
- BaseFeatures = source.BaseFeatures
- };
+ {
+ Script = unicodeScript,
+ Language = source.Language,
+ Direction = direction,
+ Features = source.Features,
+ ExplicitFeatureTags = source.ExplicitFeatureTags,
+ ClusterLevel = source.ClusterLevel,
+ BufferFlags = flags,
+ RangedFeatures = RemapVerificationFeatures(source.RangedFeatures.Span, fragmentStart, fragmentEnd),
+ BaseFeatures = source.BaseFeatures
+ };
private static ReadOnlyMemory RemapVerificationFeatures(
ReadOnlySpan features,
@@ -919,9 +929,8 @@ private static ShapingPlan CreateShapingPlan(
ShapingDirection direction = ResolveDirection(requestedOptions.Direction, unicodeScript);
TextShapingOptions options = AddScriptFeatures(requestedOptions, script, useShaper, indicShaper);
options = AddDirectionalFeatures(options, direction);
- Typeface? typeface = font.LayoutTypeface;
- SubstitutionPlan substitution = CreateSubstitutionPlan(font, typeface?.GSUBTable, options, script);
- PositioningPlan positioning = CreatePositioningPlan(font, typeface?.GPOSTable, options, script);
+ SubstitutionPlan substitution = CreateSubstitutionPlan(font, null, options, script);
+ PositioningPlan positioning = CreatePositioningPlan(font, null, options, script);
bool hasMarkFeature = unicodeScript == "hebr" &&
GetFeatureTags(font).Contains("mark", StringComparer.Ordinal);
return new ShapingPlan(
@@ -4723,7 +4732,6 @@ private sealed class GlyphSubstitutionBuffer : IGlyphIndexList, IDisposable
];
private readonly List _glyphs;
- private readonly Typeface? _typeface;
private readonly TtfFont _font;
private readonly ReadOnlyMemory _gdefTable;
private readonly ShapingClusterLevel _clusterLevel;
@@ -4756,7 +4764,6 @@ private GlyphSubstitutionBuffer(
_preContext = preContext;
_postContext = postContext;
_ownsGlyphList = ownsGlyphList;
- _typeface = font.LayoutTypeface;
font.TryGetTable("GDEF", out _gdefTable);
if (OpenTypeGdefPolicy.IsBlocklisted(
_gdefTable.Length,
@@ -4973,17 +4980,16 @@ public void ReorderModifiedCombiningMarks(string script)
public void ApplyArabicFallback(bool enabled, TextShapingOptions options)
{
if (!enabled) return;
- Dictionary requested = CreateFeatureMap(options.Features);
ReadOnlySpan forms = ArabicFallbackData.ShapingForms;
for (var index = 0; index < _glyphs.Count; index++)
{
GlyphRecord glyph = _glyphs[index];
int form = glyph.ArabicAction switch
{
- Initial when IsEnabled(requested, "init") => 0,
- Medial when IsEnabled(requested, "medi") => 1,
- Final when IsEnabled(requested, "fina") => 2,
- Isolated when IsEnabled(requested, "isol") => 3,
+ Initial when OpenTypeTextShaper.IsFeatureEnabled(options.Features, "init") => 0,
+ Medial when OpenTypeTextShaper.IsFeatureEnabled(options.Features, "medi") => 1,
+ Final when OpenTypeTextShaper.IsFeatureEnabled(options.Features, "fina") => 2,
+ Isolated when OpenTypeTextShaper.IsFeatureEnabled(options.Features, "isol") => 3,
_ => -1
};
if (form < 0 || glyph.CodePoint < ArabicFallbackData.FirstCodePoint ||
@@ -4995,7 +5001,7 @@ Isolated when IsEnabled(requested, "isol") => 3,
Replace(index, presentationGlyph);
}
- if (!IsEnabled(requested, "rlig")) return;
+ if (!OpenTypeTextShaper.IsFeatureEnabled(options.Features, "rlig")) return;
ApplyArabicFallbackLigatures(ArabicFallbackData.ThreeComponentLigatures, 2, ignoreMarks: true);
ApplyArabicFallbackLigatures(ArabicFallbackData.TwoComponentLigatures, 1, ignoreMarks: true);
ApplyArabicFallbackLigatures(ArabicFallbackData.MarkLigatures, 1, ignoreMarks: false);
@@ -5014,9 +5020,6 @@ public void RecordArabicStretch()
}
}
- private static bool IsEnabled(Dictionary requested, string tag) =>
- requested.TryGetValue(tag, out int value) && value != 0;
-
private void ApplyArabicFallbackLigatures(
ReadOnlySpan table,
int componentCount,
@@ -5066,11 +5069,34 @@ public static int GetModifiedCombiningClass(uint codePoint)
int canonical = UnicodeCombiningClassData.GetCanonicalClass(codePoint);
return canonical switch
{
- 10 => 22, 11 => 15, 12 => 16, 13 => 17, 14 => 23, 15 => 18,
- 16 => 19, 17 => 20, 18 => 21, 19 => 14, 20 => 24, 21 => 12,
- 22 => 25, 23 => 13, 24 => 10, 25 => 11,
- 27 => 28, 28 => 29, 29 => 30, 30 => 31, 31 => 32, 32 => 33,
- 33 => 27, 84 => 4, 91 => 5, 103 => 3, 130 => 132, 132 => 131,
+ 10 => 22,
+ 11 => 15,
+ 12 => 16,
+ 13 => 17,
+ 14 => 23,
+ 15 => 18,
+ 16 => 19,
+ 17 => 20,
+ 18 => 21,
+ 19 => 14,
+ 20 => 24,
+ 21 => 12,
+ 22 => 25,
+ 23 => 13,
+ 24 => 10,
+ 25 => 11,
+ 27 => 28,
+ 28 => 29,
+ 29 => 30,
+ 30 => 31,
+ 31 => 32,
+ 32 => 33,
+ 33 => 27,
+ 84 => 4,
+ 91 => 5,
+ 103 => 3,
+ 130 => 132,
+ 132 => 131,
_ => canonical
};
}
@@ -6575,9 +6601,16 @@ private static bool IsIndicJoiner(GlyphRecord glyph) => glyph.Ligated == 0 &&
private static uint GetIndicVirama(string script) => script switch
{
- "deva" => 0x094D, "beng" => 0x09CD, "guru" => 0x0A4D, "gujr" => 0x0ACD,
- "orya" => 0x0B4D, "taml" => 0x0BCD, "telu" => 0x0C4D, "knda" => 0x0CCD,
- "mlym" => 0x0D4D, _ => 0
+ "deva" => 0x094D,
+ "beng" => 0x09CD,
+ "guru" => 0x0A4D,
+ "gujr" => 0x0ACD,
+ "orya" => 0x0B4D,
+ "taml" => 0x0BCD,
+ "telu" => 0x0C4D,
+ "knda" => 0x0CCD,
+ "mlym" => 0x0D4D,
+ _ => 0
};
private static IndicRephMode GetIndicRephMode(string script) => script switch
@@ -7392,7 +7425,7 @@ private GlyphClassKind GetGlyphClassKind(GlyphRecord record)
return (GlyphClassKind)OpenTypeTextShaper.GetGlyphClass(data, classDefOffset, record.GlyphIndex);
}
if (IsUnicodeMark(record.CodePoint)) return GlyphClassKind.Mark;
- return _typeface?.GetGlyph(record.GlyphIndex).GlyphClass ?? GlyphClassKind.Base;
+ return GlyphClassKind.Base;
}
private int GetMarkAttachmentClass(ushort glyph)
@@ -7869,7 +7902,6 @@ private sealed class GlyphPositionBuffer : IGlyphPositions, IDisposable
private GlyphRecord[] _glyphs;
private int _count;
private bool _isPooled;
- private readonly Typeface? _typeface;
private readonly ReadOnlyMemory _gdefTable;
private readonly ShapingDirection _direction;
private int _markFilteringCoverage = -1;
@@ -7882,7 +7914,6 @@ public GlyphPositionBuffer(
ShapingClusterLevel clusterLevel,
ShapingBufferFlags bufferFlags)
{
- _typeface = font.LayoutTypeface;
_direction = direction;
font.TryGetTable("GDEF", out _gdefTable);
if (OpenTypeGdefPolicy.IsBlocklisted(
diff --git a/src/ProGPU.Text/TtfFont.Variations.cs b/src/ProGPU.Text/TtfFont.Variations.cs
index c2a74299..538af127 100644
--- a/src/ProGPU.Text/TtfFont.Variations.cs
+++ b/src/ProGPU.Text/TtfFont.Variations.cs
@@ -116,8 +116,8 @@ private TtfFont(
{
_data = source._data;
_face = source._face;
+ _cffOutlineSource = source._cffOutlineSource;
_cffTypeface = source._cffTypeface;
- _layoutTypeface = source._layoutTypeface;
_tables = source._tables;
_svgColorLayerCache = source._svgColorLayerCache;
diff --git a/src/ProGPU.Text/TtfFont.cs b/src/ProGPU.Text/TtfFont.cs
index ed64eb4a..91a20919 100644
--- a/src/ProGPU.Text/TtfFont.cs
+++ b/src/ProGPU.Text/TtfFont.cs
@@ -46,12 +46,12 @@ public struct GlyphUniforms
public float YStart;
public float Scale;
public uint GlyphIndex;
-
- public uint AtlasX;
- public uint AtlasY;
+
+ public uint OutputOffsetWords;
+ public uint OutputRowWords;
public uint Width;
public uint Height;
-
+
public float SubpixelX;
public float Pad0;
public float Pad1;
@@ -128,11 +128,11 @@ public partial class TtfFont
private readonly byte[] _data;
private readonly SfntFontFace _face;
- private readonly Typeface? _cffTypeface;
+ private readonly Lazy? _cffOutlineSource;
+ private readonly Lazy? _cffTypeface;
private readonly object _asciiGlyphCacheLock = new();
private ushort[]? _asciiGlyphCache;
private byte[]? _asciiGlyphCacheStates;
- private readonly Lazy _layoutTypeface;
private readonly Dictionary _tables = new();
private readonly Dictionary?> _svgColorLayerCache = new();
@@ -210,9 +210,6 @@ public TtfFont(byte[] fontData, int faceIndex)
_data = SfntFontContainer.Normalize(fontData);
FaceIndex = faceIndex;
_face = SfntFontFace.Load(_data, faceIndex);
- _layoutTypeface = new Lazy(
- LoadLayoutTypeface,
- LazyThreadSafetyMode.ExecutionAndPublication);
FamilyName = GetName(SfntNameIds.PreferredFamilyName, SfntNameIds.FamilyName) ?? string.Empty;
SubfamilyName = GetName(SfntNameIds.PreferredSubfamilyName, SfntNameIds.SubfamilyName) ?? string.Empty;
FullName = GetName(SfntNameIds.FullName) ?? FamilyName;
@@ -227,10 +224,12 @@ public TtfFont(byte[] fontData, int faceIndex)
_tables.ContainsKey("SVG ");
if (_tables.ContainsKey("CFF "))
{
- byte[] cffFontData = _face.BaseOffset == 0
- ? _data
- : _face.CreateStandaloneFontData();
- _cffTypeface = TryLoadCffTypeface(cffFontData);
+ _cffOutlineSource = new Lazy(
+ LoadCffOutlineSource,
+ LazyThreadSafetyMode.ExecutionAndPublication);
+ _cffTypeface = new Lazy(
+ LoadCffTypeface,
+ LazyThreadSafetyMode.ExecutionAndPublication);
}
ParseHeadTable();
ParseHheaTable();
@@ -242,23 +241,19 @@ public TtfFont(byte[] fontData, int faceIndex)
ParseCpalTable();
}
- internal Typeface? LayoutTypeface => _layoutTypeface.Value;
-
- private Typeface? LoadLayoutTypeface()
+ private Typeface? LoadCffTypeface()
{
- try
- {
- byte[] data = _face.BaseOffset == 0 ? _data : _face.CreateStandaloneFontData();
- using var stream = new MemoryStream(data, writable: false);
- return new OpenFontReader().Read(stream);
- }
- catch (Exception exception) when (exception is not OutOfMemoryException)
- {
- ProGpuTextDiagnostics.WriteLine($"[TtfFont] OpenType layout tables unavailable for '{FullName}': {exception.Message}");
- return null;
- }
+ byte[] data = _face.BaseOffset == 0 ? _data : _face.CreateStandaloneFontData();
+ return TryLoadCffTypeface(data);
}
+ private Cff1OutlineSource? LoadCffOutlineSource() =>
+ _face.TryGetTable("CFF ", out ReadOnlyMemory table)
+ ? Cff1OutlineSource.TryCreate(table, NumGlyphs)
+ : null;
+
+ internal bool IsCffFallbackLoaded => _cffTypeface?.IsValueCreated == true;
+
public IReadOnlyList GetOpenTypeFeatureTags() => OpenTypeTextShaper.GetFeatureTags(this);
public TtfFont(string filePath) : this(File.ReadAllBytes(filePath))
@@ -291,9 +286,9 @@ private short ReadShort(uint offset)
private uint ReadUInt(uint offset)
{
- return (uint)((_data[offset] << 24) |
- (_data[offset + 1] << 16) |
- (_data[offset + 2] << 8) |
+ return (uint)((_data[offset] << 24) |
+ (_data[offset + 1] << 16) |
+ (_data[offset + 2] << 8) |
_data[offset + 3]);
}
@@ -1299,11 +1294,11 @@ public float GetKerning(char left, char right, float emSize)
public float GetKerning(uint left, uint right, float emSize)
{
if (!_tables.TryGetValue("kern", out var kern)) return 0;
-
+
uint offset = kern.offset;
ushort version = ReadUShort(offset);
ushort nTables = ReadUShort(offset + 2);
-
+
uint subtableOffset = offset + 4;
float scale = emSize / UnitsPerEm;
@@ -1495,12 +1490,29 @@ private void PublishGlyphOutline(ushort glyphIndex, ParsedGlyph? outline)
private ParsedGlyph? GetCffGlyphOutline(ushort glyphIndex)
{
- if (_cffTypeface is null || glyphIndex >= _cffTypeface.GlyphCount)
+ Cff1OutlineSource? source = _cffOutlineSource?.Value;
+ if (source is not null && source.TryGetOutline(glyphIndex, out PathGeometry? geometry))
+ {
+ return geometry is null
+ ? null
+ : new ParsedGlyph(geometry, Array.Empty());
+ }
+
+ return GetCffFallbackGlyphOutline(glyphIndex);
+ }
+
+ internal PathGeometry? GetCffFallbackGlyphOutlineForTesting(ushort glyphIndex) =>
+ GetCffFallbackGlyphOutline(glyphIndex)?.Geometry;
+
+ private ParsedGlyph? GetCffFallbackGlyphOutline(ushort glyphIndex)
+ {
+ Typeface? typeface = _cffTypeface?.Value;
+ if (typeface is null || glyphIndex >= typeface.GlyphCount)
{
return null;
}
- var glyph = _cffTypeface.GetGlyph(glyphIndex);
+ var glyph = typeface.GetGlyph(glyphIndex);
if (!glyph.IsCffGlyph)
{
return null;
@@ -1706,13 +1718,13 @@ private static short ToInt16Ceiling(float value) =>
int totalPoints = endPtsOfContours[numberOfContours - 1] + 1;
byte[] flags = new byte[totalPoints];
-
+
// Read Flags
for (int i = 0; i < totalPoints; i++)
{
byte flag = _data[offset++];
flags[i] = flag;
-
+
// Check if flag repeats
if ((flag & 8) != 0)
{
@@ -1725,7 +1737,7 @@ private static short ToInt16Ceiling(float value) =>
}
Vector2[] coords = new Vector2[totalPoints];
-
+
// Read X Coordinates
float lastX = 0;
for (int i = 0; i < totalPoints; i++)
@@ -2109,7 +2121,7 @@ private void ParseColrTable()
{
if (!_tables.TryGetValue("COLR", out var colr)) return;
_colrOffset = colr.offset;
-
+
ushort version = ReadUShort(_colrOffset);
if (version == 0)
{
@@ -2135,7 +2147,7 @@ private void ParseCpalTable()
// Parse default palette (palette 0)
_colorPalette = new Vector4[_numPaletteEntries];
-
+
// Check first palette record index
ushort firstPaletteRecordIndex = ReadUShort(_cpalOffset + 12);
diff --git a/src/ProGPU.Vector/PathAtlas.cs b/src/ProGPU.Vector/PathAtlas.cs
index b666a25a..8d5a32a1 100644
--- a/src/ProGPU.Vector/PathAtlas.cs
+++ b/src/ProGPU.Vector/PathAtlas.cs
@@ -18,8 +18,8 @@ public struct PathUniforms
public float ScaleX;
public float ScaleY;
public uint PathIndex;
- public uint AtlasX;
- public uint AtlasY;
+ public uint OutputOffsetWords;
+ public uint OutputRowWords;
public uint Width;
public uint Height;
public uint SampleGrid;
@@ -241,6 +241,8 @@ public unsafe class PathAtlas : IDisposable
public const uint StandardCoverageSampleGrid = 4;
public const uint HighPrecisionCoverageSampleGrid = 8;
public const uint DefaultSubpixelPhaseGrid = 64;
+ public const uint DefaultInitialAtlasSize = 512;
+ public const uint DefaultRasterStagingChunkBytes = 256 * 1024;
public const long DefaultCompiledPathCacheBudgetBytes = 8L * 1024L * 1024L;
@@ -251,8 +253,9 @@ public unsafe class PathAtlas : IDisposable
private const int ExactRecoveryCandidateBudget = 250_000;
private readonly WgpuContext _context;
- private readonly GpuTexture _atlasTexture;
- private readonly uint _atlasSize;
+ private GpuTexture _atlasTexture;
+ private uint _atlasSize;
+ private readonly uint _maxAtlasSize;
private readonly long _compiledPathCacheBudgetBytes;
private uint _currentX = 2;
@@ -368,6 +371,8 @@ private enum RecoveryPlacementHeuristic
public GpuTexture AtlasTexture => _atlasTexture;
public uint AtlasSize => _atlasSize;
+ public uint MaxAtlasSize => _maxAtlasSize;
+ public ulong TextureRevision { get; private set; }
public int CachedPathCount => _paths.Count;
public int CachedFillPathCount => _compiledFillPaths.Count;
public int CachedHitTestPathCount => _compiledHitTestPaths.Count;
@@ -375,6 +380,9 @@ 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 uint LastRasterStagingBytes { get; private set; }
+ public uint PeakRasterStagingBytes { get; private set; }
public int LastExactRecoveryNodeCount { get; private set; }
public int LastExactRecoveryCandidateCount { get; private set; }
public bool LastExactRecoveryBudgetExceeded { get; private set; }
@@ -388,19 +396,24 @@ public PathAtlas(
{
throw new ArgumentOutOfRangeException(nameof(compiledPathCacheBudgetBytes));
}
+ if (atlasSize <= 4)
+ {
+ throw new ArgumentOutOfRangeException(nameof(atlasSize));
+ }
_context = context;
- _atlasSize = atlasSize;
+ _maxAtlasSize = atlasSize;
+ _atlasSize = Math.Min(atlasSize, DefaultInitialAtlasSize);
_compiledPathCacheBudgetBytes = compiledPathCacheBudgetBytes;
_atlasTexture = new GpuTexture(
_context,
_atlasSize,
_atlasSize,
- TextureFormat.Rgba8Unorm,
+ TextureFormat.R8Unorm,
TextureUsage.TextureBinding | TextureUsage.CopyDst | TextureUsage.CopySrc |
- TextureUsage.StorageBinding | TextureUsage.RenderAttachment,
- "Dynamic Path Atlas"
+ TextureUsage.RenderAttachment,
+ "Dynamic Path Coverage Atlas"
);
ClearAtlasTexture();
@@ -456,11 +469,11 @@ public PathAtlas(
{
Binding = 3,
Visibility = ShaderStage.Compute,
- StorageTexture = new StorageTextureBindingLayout
+ Buffer = new BufferBindingLayout
{
- Access = StorageTextureAccess.WriteOnly,
- Format = TextureFormat.Rgba8Unorm,
- ViewDimension = TextureViewDimension.Dimension2D
+ Type = BufferBindingType.Storage,
+ HasDynamicOffset = false,
+ MinBindingSize = 4
}
};
@@ -1040,7 +1053,9 @@ private readonly record struct PendingRasterization(
GpuPathRecord[] Records,
GpuPathSegment[] Segments,
int RecordOffset,
- int SegmentOffset);
+ int SegmentOffset,
+ int OutputByteOffset,
+ uint OutputBytesPerRow);
private readonly record struct RasterizationDispatch(
int StartIndex,
@@ -1056,8 +1071,8 @@ private sealed class PendingRasterizationComparer : IComparer 2 ? _atlasSize - 2 : 0;
- RetryPathOrdering[] orderings = Enum.GetValues();
- RecoveryPlacementHeuristic[] heuristics = Enum.GetValues();
+ var trialFreeRectangles = new List(Math.Max(4, livePaths.Count * 2));
+ var trialPlacements = new List(livePaths.Count);
- for (int orderingIndex = 0; orderingIndex < orderings.Length; orderingIndex++)
+ for (int orderingIndex = 0;
+ orderingIndex <= (int)RetryPathOrdering.MaxSideDescending;
+ orderingIndex++)
{
- RetryPathOrdering ordering = orderings[orderingIndex];
- var orderedPaths = new List(livePaths);
- orderedPaths.Sort((left, right) => CompareRetryPaths(left, right, ordering));
+ RetryPathOrdering ordering = (RetryPathOrdering)orderingIndex;
+ SortRetryPaths(livePaths, ordering);
- for (int heuristicIndex = 0; heuristicIndex < heuristics.Length - 1; heuristicIndex++)
+ for (int heuristicIndex = 0;
+ heuristicIndex < (int)RecoveryPlacementHeuristic.ExactBranchAndBound;
+ heuristicIndex++)
{
- RecoveryPlacementHeuristic heuristic = heuristics[heuristicIndex];
- var trialFreeRectangles = new List(Math.Max(4, livePaths.Count * 2))
- {
- new AtlasFreeRectangle(2, 2, availableSize, availableSize)
- };
- var trialPlacements = new List(livePaths.Count);
+ RecoveryPlacementHeuristic heuristic = (RecoveryPlacementHeuristic)heuristicIndex;
+ trialFreeRectangles.Clear();
+ trialFreeRectangles.Add(new AtlasFreeRectangle(2, 2, availableSize, availableSize));
+ trialPlacements.Clear();
bool succeeded = true;
- for (int pathIndex = 0; pathIndex < orderedPaths.Count; pathIndex++)
+ for (int pathIndex = 0; pathIndex < livePaths.Count; pathIndex++)
{
- RetryPath retryPath = orderedPaths[pathIndex];
+ RetryPath retryPath = livePaths[pathIndex];
if (retryPath.Width == 0 || retryPath.Height == 0)
{
trialPlacements.Add(new RetryPlacement(retryPath, default));
@@ -1324,6 +1342,33 @@ private bool TryPackRecoveryPaths(
return false;
}
+ private static void SortRetryPaths(List paths, RetryPathOrdering ordering)
+ {
+ // Each comparison is non-capturing so recovery reuses the runtime-cached
+ // delegate. The total key ordering makes an in-place re-sort independent
+ // of the preceding strategy, avoiding one PathInfo-heavy array copy per
+ // attempted ordering without changing deterministic placement order.
+ switch (ordering)
+ {
+ case RetryPathOrdering.WidthDescending:
+ paths.Sort(static (left, right) =>
+ CompareRetryPaths(left, right, RetryPathOrdering.WidthDescending));
+ break;
+ case RetryPathOrdering.HeightDescending:
+ paths.Sort(static (left, right) =>
+ CompareRetryPaths(left, right, RetryPathOrdering.HeightDescending));
+ break;
+ case RetryPathOrdering.MaxSideDescending:
+ paths.Sort(static (left, right) =>
+ CompareRetryPaths(left, right, RetryPathOrdering.MaxSideDescending));
+ break;
+ default:
+ paths.Sort(static (left, right) =>
+ CompareRetryPaths(left, right, RetryPathOrdering.AreaDescending));
+ break;
+ }
+ }
+
private bool TryPackRecoveryPathsExactly(
List livePaths,
out List placements,
@@ -1890,71 +1935,101 @@ private static void SplitRecoveryFreeRectangles(
List freeRectangles,
AtlasFreeRectangle used)
{
- for (int index = freeRectangles.Count - 1; index >= 0; index--)
+ int originalCount = freeRectangles.Count;
+ AtlasFreeRectangle[] generated = ArrayPool.Shared.Rent(
+ Math.Max(4, checked(originalCount * 4)));
+ int generatedCount = 0;
+ int survivorCount = 0;
+ try
{
- AtlasFreeRectangle free = freeRectangles[index];
- if (used.X >= free.Right || used.Right <= free.X ||
- used.Y >= free.Bottom || used.Bottom <= free.Y)
+ // The incoming list is already containment-pruned. Preserve unaffected
+ // rectangles in stable order and collect only the split rectangles that
+ // can introduce new containment relationships.
+ for (int index = 0; index < originalCount; index++)
{
- continue;
- }
+ AtlasFreeRectangle free = freeRectangles[index];
+ if (used.X >= free.Right || used.Right <= free.X ||
+ used.Y >= free.Bottom || used.Bottom <= free.Y)
+ {
+ freeRectangles[survivorCount++] = free;
+ continue;
+ }
- freeRectangles.RemoveAt(index);
- if (used.X > free.X)
- {
- freeRectangles.Add(new AtlasFreeRectangle(
- free.X,
- free.Y,
- used.X - free.X,
- free.Height));
- }
- if (used.Right < free.Right)
- {
- freeRectangles.Add(new AtlasFreeRectangle(
- used.Right,
- free.Y,
- free.Right - used.Right,
- free.Height));
- }
- if (used.Y > free.Y)
- {
- freeRectangles.Add(new AtlasFreeRectangle(
- free.X,
- free.Y,
- free.Width,
- used.Y - free.Y));
+ if (used.X > free.X)
+ {
+ generated[generatedCount++] = new AtlasFreeRectangle(
+ free.X,
+ free.Y,
+ used.X - free.X,
+ free.Height);
+ }
+ if (used.Right < free.Right)
+ {
+ generated[generatedCount++] = new AtlasFreeRectangle(
+ used.Right,
+ free.Y,
+ free.Right - used.Right,
+ free.Height);
+ }
+ if (used.Y > free.Y)
+ {
+ generated[generatedCount++] = new AtlasFreeRectangle(
+ free.X,
+ free.Y,
+ free.Width,
+ used.Y - free.Y);
+ }
+ if (used.Bottom < free.Bottom)
+ {
+ generated[generatedCount++] = new AtlasFreeRectangle(
+ free.X,
+ used.Bottom,
+ free.Width,
+ free.Bottom - used.Bottom);
+ }
}
- if (used.Bottom < free.Bottom)
+
+ if (survivorCount < originalCount)
{
- freeRectangles.Add(new AtlasFreeRectangle(
- free.X,
- used.Bottom,
- free.Width,
- free.Bottom - used.Bottom));
+ freeRectangles.RemoveRange(survivorCount, originalCount - survivorCount);
}
- }
- for (int outer = freeRectangles.Count - 1; outer >= 0; outer--)
- {
- AtlasFreeRectangle candidate = freeRectangles[outer];
- for (int inner = 0; inner < freeRectangles.Count; inner++)
+ for (int generatedIndex = 0; generatedIndex < generatedCount; generatedIndex++)
{
- if (outer == inner)
+ AtlasFreeRectangle candidate = generated[generatedIndex];
+ bool contained = false;
+ for (int existingIndex = freeRectangles.Count - 1; existingIndex >= 0; existingIndex--)
{
- continue;
+ AtlasFreeRectangle existing = freeRectangles[existingIndex];
+ if (Contains(existing, candidate))
+ {
+ contained = true;
+ break;
+ }
+ if (Contains(candidate, existing))
+ {
+ freeRectangles.RemoveAt(existingIndex);
+ }
}
- AtlasFreeRectangle container = freeRectangles[inner];
- if (candidate.X >= container.X && candidate.Y >= container.Y &&
- candidate.Right <= container.Right && candidate.Bottom <= container.Bottom)
+ if (!contained)
{
- freeRectangles.RemoveAt(outer);
- break;
+ freeRectangles.Add(candidate);
}
}
}
+ finally
+ {
+ ArrayPool.Shared.Return(generated);
+ }
}
+ private static bool Contains(AtlasFreeRectangle container, AtlasFreeRectangle candidate) =>
+ candidate.X >= container.X &&
+ candidate.Y >= container.Y &&
+ candidate.Right <= container.Right &&
+ candidate.Bottom <= container.Bottom;
+
private bool HasPathsUsedInCurrentFrame()
{
foreach (var info in _paths.Values)
@@ -2137,6 +2212,10 @@ public PathInfo GetOrCreatePath(
uint gW = (uint)width;
uint gH = (uint)height;
+ while ((gW + 4 > _atlasSize || gH + 4 > _atlasSize) && TryGrowAtlas(gW, gH))
+ {
+ }
+
if (_recoveryFreeRectangles != null)
{
info = new PathInfo
@@ -2155,6 +2234,19 @@ public PathInfo GetOrCreatePath(
checked(gH + 2),
out AtlasFreeRectangle placement))
{
+ if (TryGrowAtlas(gW, gH))
+ {
+ return GetOrCreatePath(
+ path,
+ scaleX,
+ scaleY,
+ subpixelX,
+ subpixelY,
+ sampleGrid,
+ subpixelPhaseGrid,
+ quantizeScale);
+ }
+
ProGpuVectorDiagnostics.WriteLine(
"[PathAtlas] Warning: The recovery-packed atlas cannot fit a new current-frame path; preserving existing path coordinates.");
CapacityExceeded = true;
@@ -2184,6 +2276,19 @@ public PathInfo GetOrCreatePath(
if (_currentY + gH + 2 > _atlasSize)
{
+ if (TryGrowAtlas(gW, gH))
+ {
+ return GetOrCreatePath(
+ path,
+ scaleX,
+ scaleY,
+ subpixelX,
+ subpixelY,
+ sampleGrid,
+ subpixelPhaseGrid,
+ quantizeScale);
+ }
+
if (!HasPathsUsedInCurrentFrame())
{
ProGpuVectorDiagnostics.WriteLine("[PathAtlas] Texture Atlas is full! Repacking cached paths before frame compilation...");
@@ -2260,9 +2365,99 @@ public PathInfo GetOrCreatePath(
return info;
}
+ private bool TryGrowAtlas(uint requiredWidth, uint requiredHeight)
+ {
+ if (_atlasSize >= _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));
+ }
+ while (newSize < requiredSize && newSize < _maxAtlasSize);
+ if (newSize < requiredSize)
+ {
+ return false;
+ }
+
+ var newTexture = new GpuTexture(
+ _context,
+ newSize,
+ newSize,
+ TextureFormat.R8Unorm,
+ TextureUsage.TextureBinding | TextureUsage.CopyDst | TextureUsage.CopySrc |
+ TextureUsage.RenderAttachment,
+ "Dynamic Path Coverage Atlas");
+ newTexture.ClearRenderTarget();
+ newTexture.CopyBaseLevelRegionFrom(_atlasTexture, oldSize, oldSize);
+ GpuTexture oldTexture = _atlasTexture;
+ _atlasTexture = newTexture;
+ _atlasSize = newSize;
+ 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));
+ }
+
+ RefreshNormalizedTextureCoordinates();
+ TextureRevision++;
+ CapacityExceeded = false;
+ return true;
+ }
+
+ private void RefreshNormalizedTextureCoordinates()
+ {
+ float texelSize = 1f / _atlasSize;
+ foreach (KeyValuePair pair in _paths)
+ {
+ ref PathInfo info = ref CollectionsMarshal.GetValueRefOrNullRef(
+ _paths,
+ pair.Key);
+ if (info.Width == 0 || info.Height == 0)
+ {
+ continue;
+ }
+
+ info.TexCoordMin = new Vector2(
+ (info.X + info.Key.SubpixelX) * texelSize,
+ (info.Y + info.Key.SubpixelY) * texelSize);
+ info.TexCoordMax = new Vector2(
+ (info.X + info.Width + info.Key.SubpixelX) * texelSize,
+ (info.Y + info.Height + info.Key.SubpixelY) * texelSize);
+ }
+
+ 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.TexCoordMax = new Vector2(
+ (info.X + info.Width + info.Key.SubpixelX) * texelSize,
+ (info.Y + info.Height + info.Key.SubpixelY) * texelSize);
+ _pendingPaths[pendingIndex] = info;
+ }
+ }
+
public void RasterizePendingPaths()
{
if (_isDisposed) throw new ObjectDisposedException(nameof(PathAtlas));
+ LastRasterStagingBytes = 0;
if (_pendingPaths.Count == 0) return;
PendingRasterization[]? rasterizations = null;
@@ -2311,7 +2506,9 @@ public void RasterizePendingPaths()
records,
segments,
totalRecordCount,
- totalSegmentCount);
+ totalSegmentCount,
+ 0,
+ 0);
totalRecordCount = checked(totalRecordCount + records.Length);
totalSegmentCount = checked(totalSegmentCount + segments.Length);
if (diagnosticsEnabled)
@@ -2339,10 +2536,13 @@ public void RasterizePendingPaths()
for (int i = 0; i < rasterizationCount; i++)
{
var rasterization = rasterizations[i];
+ uint outputBytesPerRow = GpuCoverageUpload.GetBytesPerRow(
+ rasterization.Info.Width);
rasterizations[i] = rasterization with
{
RecordOffset = totalRecordCount,
- SegmentOffset = totalSegmentCount
+ SegmentOffset = totalSegmentCount,
+ OutputBytesPerRow = outputBytesPerRow
};
totalRecordCount = checked(totalRecordCount + rasterization.Records.Length);
totalSegmentCount = checked(totalSegmentCount + rasterization.Segments.Length);
@@ -2351,22 +2551,41 @@ public void RasterizePendingPaths()
int uniformSize = Marshal.SizeOf();
dispatches = ArrayPool.Shared.Rent(rasterizationCount);
int totalUniformBytes = 0;
+ int maxCoverageBytes = 0;
int groupStart = 0;
while (groupStart < rasterizationCount)
{
var groupInfo = rasterizations[groupStart].Info;
- uint workgroupsX = DivRoundUp(groupInfo.Width, 16);
+ uint workgroupsX = DivRoundUp(DivRoundUp(groupInfo.Width, 4), 16);
uint workgroupsY = DivRoundUp(groupInfo.Height, 16);
- int groupEnd = groupStart + 1;
+ int groupCoverageBytes = 0;
+ int groupEnd = groupStart;
while (groupEnd < rasterizationCount)
{
var candidate = rasterizations[groupEnd].Info;
- if (DivRoundUp(candidate.Width, 16) != workgroupsX ||
+ if (DivRoundUp(DivRoundUp(candidate.Width, 4), 16) != workgroupsX ||
DivRoundUp(candidate.Height, 16) != workgroupsY)
{
break;
}
+ PendingRasterization candidateRasterization = rasterizations[groupEnd];
+ int outputByteOffset = AlignUp(
+ groupCoverageBytes,
+ (int)GpuCoverageUpload.CopyRowAlignment);
+ int candidateEnd = checked(
+ outputByteOffset +
+ checked((int)(candidateRasterization.OutputBytesPerRow * candidate.Height)));
+ if (groupEnd > groupStart && candidateEnd > DefaultRasterStagingChunkBytes)
+ {
+ break;
+ }
+
+ rasterizations[groupEnd] = candidateRasterization with
+ {
+ OutputByteOffset = outputByteOffset
+ };
+ groupCoverageBytes = candidateEnd;
groupEnd++;
}
@@ -2382,8 +2601,11 @@ public void RasterizePendingPaths()
uniformByteOffset,
uniformByteSize);
totalUniformBytes = checked(uniformByteOffset + uniformByteSize);
+ maxCoverageBytes = Math.Max(maxCoverageBytes, groupCoverageBytes);
groupStart = groupEnd;
}
+ LastRasterStagingBytes = checked((uint)maxCoverageBytes);
+ PeakRasterStagingBytes = Math.Max(PeakRasterStagingBytes, LastRasterStagingBytes);
recordData = ArrayPool.Shared.Rent(totalRecordCount);
segmentData = ArrayPool.Shared.Rent(totalSegmentCount);
@@ -2423,8 +2645,8 @@ public void RasterizePendingPaths()
ScaleX = scaleX,
ScaleY = scaleY,
PathIndex = checked((uint)rasterization.RecordOffset),
- AtlasX = info.X,
- AtlasY = info.Y,
+ OutputOffsetWords = checked((uint)rasterization.OutputByteOffset / 4),
+ OutputRowWords = rasterization.OutputBytesPerRow / 4,
Width = info.Width,
Height = info.Height,
SampleGrid = info.Key.SampleGrid
@@ -2458,6 +2680,12 @@ public void RasterizePendingPaths()
"Path Rasterization Segments");
segmentsBuffer.Write(segmentData.AsSpan(0, totalSegmentCount));
_tempBuffers.Add(segmentsBuffer);
+ var coverageBuffer = new GpuBuffer(
+ _context,
+ checked((uint)maxCoverageBytes),
+ BufferUsage.Storage | BufferUsage.CopySrc,
+ "Path Coverage Staging Buffer");
+ _tempBuffers.Add(coverageBuffer);
var bindGroupEntries = stackalloc BindGroupEntry[4];
bindGroupEntries[1] = new BindGroupEntry
@@ -2477,7 +2705,9 @@ public void RasterizePendingPaths()
bindGroupEntries[3] = new BindGroupEntry
{
Binding = 3,
- TextureView = _atlasTexture.ViewPtr
+ Buffer = coverageBuffer.BufferPtr,
+ Offset = 0,
+ Size = coverageBuffer.Size
};
var encoderDescriptor = new CommandEncoderDescriptor
{
@@ -2485,10 +2715,6 @@ public void RasterizePendingPaths()
};
var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDescriptor);
SilkMarshal.Free((nint)encoderDescriptor.Label);
- var passDescriptor = new ComputePassDescriptor();
- var pass = _context.Api.CommandEncoderBeginComputePass(encoder, &passDescriptor);
- _context.Api.ComputePassEncoderSetPipeline(pass, _computePipeline);
-
for (int dispatchIndex = 0; dispatchIndex < dispatchCount; dispatchIndex++)
{
var dispatch = dispatches[dispatchIndex];
@@ -2518,16 +2744,35 @@ public void RasterizePendingPaths()
ref bindGroupToReleaseCount,
dispatchCount,
(nint)bindGroup);
+ var passDescriptor = new ComputePassDescriptor();
+ var pass = _context.Api.CommandEncoderBeginComputePass(encoder, &passDescriptor);
+ _context.Api.ComputePassEncoderSetPipeline(pass, _computePipeline);
_context.Api.ComputePassEncoderSetBindGroup(pass, 0, bindGroup, 0, null);
_context.Api.ComputePassEncoderDispatchWorkgroups(
pass,
dispatch.WorkgroupsX,
dispatch.WorkgroupsY,
checked((uint)dispatch.Count));
- }
+ _context.Api.ComputePassEncoderEnd(pass);
+ _context.Api.ComputePassEncoderRelease(pass);
- _context.Api.ComputePassEncoderEnd(pass);
- _context.Api.ComputePassEncoderRelease(pass);
+ for (int localIndex = 0; localIndex < dispatch.Count; localIndex++)
+ {
+ var rasterization = rasterizations[dispatch.StartIndex + localIndex];
+ var info = rasterization.Info;
+ GpuCoverageUpload.RecordCopy(
+ _context,
+ encoder,
+ coverageBuffer,
+ checked((uint)rasterization.OutputByteOffset),
+ rasterization.OutputBytesPerRow,
+ _atlasTexture,
+ info.X,
+ info.Y,
+ info.Width,
+ info.Height);
+ }
+ }
var commandBufferDescriptor = new CommandBufferDescriptor
{
diff --git a/src/ProGPU.WinUI/Controls/FlowDocument.cs b/src/ProGPU.WinUI/Controls/FlowDocument.cs
index 156e2d3c..17ad66c4 100644
--- a/src/ProGPU.WinUI/Controls/FlowDocument.cs
+++ b/src/ProGPU.WinUI/Controls/FlowDocument.cs
@@ -76,7 +76,7 @@ public Paragraph(params Inline[] inlines) : this()
}
}
-public class FlowDocument : FrameworkElement
+public class FlowDocument : FrameworkElement, IOwnedRenderCommandCache
{
private float _fontSize = 14f;
private int _columnCount = 2;
@@ -90,7 +90,12 @@ public class FlowDocument : FrameworkElement
private RichDocument? _document;
private readonly List _tableDecorations = new();
private readonly DrawingContext _renderCommandCache = new();
+ private readonly RichDocumentLayoutSession _layoutSession = new();
private bool _isRenderCommandCacheDirty = true;
+ private bool _isFlowLayoutDirty = true;
+ private float _lastFlowLayoutWidth = -1f;
+ private float _lastFlowLayoutHeight = -1f;
+ internal ulong FlowLayoutPassCount { get; private set; }
private Hyperlink? _cachedHoveredHyperlink;
public RichDocument? Document
@@ -102,15 +107,13 @@ public RichDocument? Document
if (_document is not null) _document.Changed -= OnDocumentChanged;
_document = value;
if (_document is not null) _document.Changed += OnDocumentChanged;
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: true);
}
}
private void OnDocumentChanged(object? sender, EventArgs e)
{
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: true);
}
protected override void OnPropertyChanged(Microsoft.UI.Xaml.DependencyProperty dp, object? oldValue, object? newValue)
@@ -118,33 +121,54 @@ protected override void OnPropertyChanged(Microsoft.UI.Xaml.DependencyProperty d
base.OnPropertyChanged(dp, oldValue, newValue);
if (dp == FontProperty || dp == FlowDirectionProperty)
{
- Invalidate();
+ InvalidateFlowLayout(invalidateShaping: true);
}
}
public float FontSize
{
get => _fontSize;
- set { _fontSize = value; Invalidate(); }
+ set
+ {
+ if (_fontSize == value) return;
+ _fontSize = value;
+ InvalidateFlowLayout(invalidateShaping: true);
+ }
}
private Brush? _foreground;
public Brush? Foreground
{
get => _foreground;
- set { _foreground = value; Invalidate(); }
+ set
+ {
+ if (ReferenceEquals(_foreground, value)) return;
+ _foreground = value;
+ InvalidateFlowLayout(invalidateShaping: true);
+ }
}
public int ColumnCount
{
get => _columnCount;
- set { _columnCount = Math.Max(1, value); Invalidate(); }
+ set
+ {
+ int normalized = Math.Max(1, value);
+ if (_columnCount == normalized) return;
+ _columnCount = normalized;
+ InvalidateFlowLayout(invalidateShaping: false);
+ }
}
public float ColumnGap
{
get => _columnGap;
- set { _columnGap = value; Invalidate(); }
+ set
+ {
+ if (_columnGap == value) return;
+ _columnGap = value;
+ InvalidateFlowLayout(invalidateShaping: false);
+ }
}
public TextAlignment TextAlignment
@@ -155,8 +179,7 @@ public TextAlignment TextAlignment
if (_textAlignment == value && _hasExplicitTextAlignment) return;
_textAlignment = value;
_hasExplicitTextAlignment = true;
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: false);
}
}
@@ -167,8 +190,7 @@ public TextReadingOrder TextReadingOrder
{
if (_textReadingOrder == value) return;
_textReadingOrder = value;
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: true);
}
}
@@ -186,15 +208,13 @@ public FlowDocument()
private void OnContentChanged()
{
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: true);
}
protected override void OnThemeChanged()
{
base.OnThemeChanged();
- Invalidate();
- InvalidateMeasure();
+ InvalidateFlowLayout(invalidateShaping: true);
}
public override void OnPointerMoved(PointerRoutedEventArgs e)
@@ -272,6 +292,23 @@ protected override void ArrangeOverride(Rect arrangeRect)
private void PerformFlowLayout(float width, float height)
{
+ if (!_isFlowLayoutDirty &&
+ Math.Abs(width - _lastFlowLayoutWidth) < 0.01f &&
+ Math.Abs(height - _lastFlowLayoutHeight) < 0.01f)
+ {
+ return;
+ }
+
+ _lastFlowLayoutWidth = width;
+ _lastFlowLayoutHeight = height;
+ _isFlowLayoutDirty = false;
+ FlowLayoutPassCount++;
+ if (Font is not { } activeFont || width <= 0f || height <= 0f)
+ {
+ ClearFlowLayoutOutput();
+ return;
+ }
+
TextLayoutEngine.LayoutMultiColumn(
Document?.Blocks ?? Blocks,
Paragraphs,
@@ -280,7 +317,7 @@ private void PerformFlowLayout(float width, float height)
Padding,
ColumnCount,
ColumnGap,
- Font!,
+ activeFont,
FontSize,
Foreground,
this.ActualTheme,
@@ -291,17 +328,36 @@ private void PerformFlowLayout(float width, float height)
RemoveChild,
TextReadingOrder,
FlowDirection,
- ResolveEffectiveTextAlignment());
+ ResolveEffectiveTextAlignment(),
+ _layoutSession);
_isRenderCommandCacheDirty = true;
}
- public override void OnRender(DrawingContext context)
+ private void InvalidateFlowLayout(bool invalidateShaping)
+ {
+ if (invalidateShaping) _layoutSession.Invalidate();
+ _isFlowLayoutDirty = true;
+ _isRenderCommandCacheDirty = true;
+ base.Invalidate();
+ InvalidateMeasure();
+ }
+
+ private void ClearFlowLayoutOutput()
+ {
+ _layoutSession.ReleaseCharacters(_positionedChars);
+ _tableDecorations.Clear();
+ while (Children.Count > 0) RemoveChild(Children[^1]);
+ _renderCommandCache.Clear();
+ _isRenderCommandCacheDirty = false;
+ }
+
+ private DrawingContext GetOrUpdateRenderCommandCache()
{
if (Font == null || _positionedChars.Count == 0)
{
_renderCommandCache.Clear();
_isRenderCommandCacheDirty = false;
- return;
+ return _renderCommandCache;
}
if (!ReferenceEquals(_cachedHoveredHyperlink, _hoveredHyperlink))
@@ -325,8 +381,15 @@ public override void OnRender(DrawingContext context)
_isRenderCommandCacheDirty = false;
}
- context.Commands.AddRange(_renderCommandCache.Commands);
+ return _renderCommandCache;
+ }
+
+ DrawingContext IOwnedRenderCommandCache.GetOrUpdateRenderCommandCache() =>
+ GetOrUpdateRenderCommandCache();
+ public override void OnRender(DrawingContext context)
+ {
+ context.Commands.AddRange(GetOrUpdateRenderCommandCache().Commands);
base.OnRender(context);
}
}
diff --git a/src/ProGPU.WinUI/Controls/MarkdownTextBlock.cs b/src/ProGPU.WinUI/Controls/MarkdownTextBlock.cs
index 99f7cf7c..0fe81e93 100644
--- a/src/ProGPU.WinUI/Controls/MarkdownTextBlock.cs
+++ b/src/ProGPU.WinUI/Controls/MarkdownTextBlock.cs
@@ -12,7 +12,7 @@
namespace Microsoft.UI.Xaml.Controls
{
- public class MarkdownTextBlock : FrameworkElement, IScrollViewportAware
+ public class MarkdownTextBlock : FrameworkElement, IScrollViewportAware, IOwnedRenderCommandCache
{
private string _markdown = string.Empty;
private float _fontSize = 14f;
@@ -582,14 +582,14 @@ private void ClearLayoutOutput()
_isRenderCommandCacheDirty = false;
}
- public override void OnRender(DrawingContext context)
+ private DrawingContext GetOrUpdateRenderCommandCache()
{
var activeFont = GetActiveFont();
if (activeFont == null || _positionedChars.Count == 0)
{
_renderCommandCache.Clear();
_isRenderCommandCacheDirty = false;
- return;
+ return _renderCommandCache;
}
if (!ReferenceEquals(_cachedHoveredHyperlink, _hoveredHyperlink))
@@ -613,8 +613,15 @@ public override void OnRender(DrawingContext context)
_isRenderCommandCacheDirty = false;
}
- context.Commands.AddRange(_renderCommandCache.Commands);
+ return _renderCommandCache;
+ }
+
+ DrawingContext IOwnedRenderCommandCache.GetOrUpdateRenderCommandCache() =>
+ GetOrUpdateRenderCommandCache();
+ public override void OnRender(DrawingContext context)
+ {
+ context.Commands.AddRange(GetOrUpdateRenderCommandCache().Commands);
base.OnRender(context);
}
}
diff --git a/src/ProGPU.WinUI/Controls/RichEditBox.cs b/src/ProGPU.WinUI/Controls/RichEditBox.cs
index d7b413cc..d5ba1afa 100644
--- a/src/ProGPU.WinUI/Controls/RichEditBox.cs
+++ b/src/ProGPU.WinUI/Controls/RichEditBox.cs
@@ -861,7 +861,7 @@ private bool TryApplySelection(int start, int length)
_selectionLength = length;
_blockView.SelectionStart = start;
_blockView.SelectionLength = length;
- _blockView.InvalidateTextRendering();
+ _blockView.InvalidateSelectionRendering();
base.Invalidate();
SelectionChanged?.Invoke(this, new RoutedEventArgs { OriginalSource = this });
return true;
@@ -903,7 +903,7 @@ public bool SelectTableCells(int anchorPosition, int activePosition)
_blockView.SelectionLength = length;
_selectedTableCells = cells;
_blockView.TableSelection = cells;
- _blockView.InvalidateTextRendering();
+ _blockView.InvalidateSelectionRendering();
CaretIndex = Math.Clamp(activePosition, 0, _buffer.Length);
_selectionAnchor = Math.Clamp(anchorPosition, 0, _buffer.Length);
base.Invalidate();
@@ -1279,7 +1279,7 @@ internal int GetCharacterIndexAt(float clickX, float clickY, out bool isTrailing
internal (int Start, int End) GetVisibleDocumentRange()
{
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
int start = int.MaxValue;
int end = 0;
float viewportTop = _scrollViewer.VerticalOffset;
@@ -1312,7 +1312,7 @@ internal int GetCharacterIndexAt(float clickX, float clickY, out bool isTrailing
internal FrameworkElement[] GetDocumentEmbeddedChildren(int start, int end)
{
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
start = Math.Clamp(start, 0, GetTotalCharacters());
end = Math.Clamp(end, 0, GetTotalCharacters());
if (end < start) (start, end) = (end, start);
@@ -1331,7 +1331,7 @@ internal FrameworkElement[] GetDocumentEmbeddedChildren(int start, int end)
internal bool TryGetDocumentRangeForChild(FrameworkElement child, out int start, out int end)
{
ArgumentNullException.ThrowIfNull(child);
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
foreach (PositionedRichChar character in _blockView.PositionedChars)
{
if (!ReferenceEquals(character.Info.EmbeddedElement, child)) continue;
@@ -1357,7 +1357,7 @@ internal int GetDocumentPositionFromPoint(Windows.Foundation.Point point, bool c
internal Rect GetDocumentClientRangeBounds(int start, int end)
{
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
int total = GetTotalCharacters();
start = Math.Clamp(start, 0, total);
end = Math.Clamp(end, 0, total);
@@ -1409,7 +1409,7 @@ internal Rect GetDocumentClientRangeBounds(int start, int end)
internal Rect[] GetDocumentClientRangeRectangles(int start, int end)
{
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
int total = GetTotalCharacters();
start = Math.Clamp(start, 0, total);
end = Math.Clamp(end, 0, total);
@@ -1593,7 +1593,7 @@ private static bool AreCaretStopsOnSameLine(RichCaretStop left, RichCaretStop ri
private List GetVisualCaretStops()
{
- _blockView.PerformRichLayout(Size.X - Padding.Horizontal);
+ PerformEditorViewportLayout();
List stops = _visualCaretStops;
stops.Clear();
int neededCapacity = _blockView.PositionedChars.Count * 2 + _blockView.EmptyParagraphCaretAnchors.Count;
@@ -1661,6 +1661,14 @@ private List GetVisualCaretStops()
return stops;
}
+ private void PerformEditorViewportLayout()
+ {
+ float width = _blockView.Size.X > 0f
+ ? _blockView.Size.X
+ : Math.Max(0f, Size.X - Padding.Horizontal);
+ _blockView.PerformRichLayout(width);
+ }
+
private RichCaretStop GetCaretStop(int textPosition, bool trailingAffinity)
{
List stops = GetVisualCaretStops();
@@ -2683,7 +2691,7 @@ private void ClearTableSelectionOverlay()
if (_selectedTableCells.Length == 0 && _blockView.TableSelection is null) return;
_selectedTableCells = Array.Empty();
_blockView.TableSelection = null;
- _blockView.InvalidateTextRendering();
+ _blockView.InvalidateSelectionRendering();
}
private static List GetTableCellStarts(string text, int start, int end)
@@ -3807,7 +3815,7 @@ internal void SetDocumentStyle(int start, int end, Func 0 ? start - 1 : start,
GetDefaultTextStyle());
_activeTypingStyle = transform(insertionStyle);
- _blockView.InvalidateTextRendering();
+ _blockView.InvalidateSelectionRendering();
return;
}
if (_selectedTableCells.Length > 0 &&
diff --git a/src/ProGPU.WinUI/Controls/RichTextBlock.cs b/src/ProGPU.WinUI/Controls/RichTextBlock.cs
index 1f4af030..2275fcac 100644
--- a/src/ProGPU.WinUI/Controls/RichTextBlock.cs
+++ b/src/ProGPU.WinUI/Controls/RichTextBlock.cs
@@ -19,7 +19,7 @@ namespace Microsoft.UI.Xaml.Controls
{
using Microsoft.UI.Xaml.Documents;
- public class RichTextBlock : FrameworkElement, IScrollViewportAware
+ public class RichTextBlock : FrameworkElement, IScrollViewportAware, IOwnedRenderCommandCache
{
private static readonly SolidColorBrush HyperlinkBrush = new SolidColorBrush(0x0078D4FF);
private static readonly SolidColorBrush HoveredHyperlinkBrush = new SolidColorBrush(0x005A9EFF);
@@ -30,10 +30,14 @@ public class RichTextBlock : FrameworkElement, IScrollViewportAware
private readonly List _tableDecorations = new();
private readonly List _emptyParagraphCaretAnchors = new();
private readonly DrawingContext _renderCommandCache = new();
+ private DrawingContext? _selectionRenderCommandCache;
private readonly RichDocumentLayoutSession _layoutSession = new();
private readonly Paragraph _layoutParagraph = new() { MarginBottom = 0f };
private readonly Block[] _layoutBlocks;
- private bool _isRenderCommandCacheDirty = true;
+ private bool _isContentRenderCommandCacheDirty = true;
+ private bool _isSelectionRenderCommandCacheDirty = true;
+ private int _tableRenderCommandCount;
+ private int _selectionRenderCommandCount;
private float _layoutHeight;
private float _lastLayoutScrollY = -1f;
private int _cachedSelectionStart = -1;
@@ -55,7 +59,7 @@ public Brush? SelectionHighlightColor
{
if (ReferenceEquals(_selectionHighlightColor, value)) return;
_selectionHighlightColor = value;
- InvalidateTextRendering();
+ InvalidateSelectionRendering();
}
}
@@ -125,7 +129,7 @@ private void OnDocumentChanged(object? sender, RichDocumentChangedEventArgs e)
if (e.InvalidateAll) _layoutSession.Invalidate();
else _layoutSession.InvalidateBlocks(e.ChangedBlocks);
_isLayoutDirty = true;
- _isRenderCommandCacheDirty = true;
+ InvalidateAllRenderCommandCaches();
base.Invalidate();
InvalidateMeasure();
}
@@ -144,17 +148,29 @@ public void InvalidateLayout()
{
_layoutSession.Invalidate();
_isLayoutDirty = true;
- _isRenderCommandCacheDirty = true;
+ InvalidateAllRenderCommandCaches();
base.Invalidate();
InvalidateMeasure();
}
public void InvalidateTextRendering()
{
- _isRenderCommandCacheDirty = true;
+ InvalidateAllRenderCommandCaches();
base.Invalidate();
}
+ internal void InvalidateSelectionRendering()
+ {
+ _isSelectionRenderCommandCacheDirty = true;
+ base.Invalidate();
+ }
+
+ private void InvalidateAllRenderCommandCaches()
+ {
+ _isContentRenderCommandCacheDirty = true;
+ _isSelectionRenderCommandCacheDirty = true;
+ }
+
public new void Invalidate()
{
InvalidateLayout();
@@ -164,7 +180,7 @@ protected override void OnThemeChanged()
{
_layoutSession.Invalidate();
_isLayoutDirty = true;
- _isRenderCommandCacheDirty = true;
+ InvalidateAllRenderCommandCaches();
base.OnThemeChanged();
}
@@ -332,7 +348,7 @@ public override void OnPointerMoved(PointerRoutedEventArgs e)
if (_hoveredHyperlink != foundLink)
{
_hoveredHyperlink = foundLink;
- _isRenderCommandCacheDirty = true;
+ _isContentRenderCommandCacheDirty = true;
base.Invalidate();
}
}
@@ -504,7 +520,7 @@ public void PerformRichLayout(float maxWidth, bool force = false)
}
_layoutSession.CollectEmptyParagraphCaretAnchors(activeBlocks, _emptyParagraphCaretAnchors);
GetScrollViewport(out _lastLayoutScrollY, out _);
- _isRenderCommandCacheDirty = true;
+ InvalidateAllRenderCommandCaches();
}
public void OnScrollViewportChanged()
@@ -570,7 +586,11 @@ private void ClearLayoutOutput()
RemoveChild(Children[^1]);
}
_renderCommandCache.Clear();
- _isRenderCommandCacheDirty = false;
+ _selectionRenderCommandCache?.Clear();
+ _isContentRenderCommandCacheDirty = false;
+ _isSelectionRenderCommandCacheDirty = false;
+ _tableRenderCommandCount = 0;
+ _selectionRenderCommandCount = 0;
}
private void SynchronizeInlineSubscriptions()
@@ -631,46 +651,96 @@ public void AccumulateInlines(Inline inline, List list, Brush defaultF
TextLayoutEngine.AccumulateInlines(inline, list, defaultFg, defaultSize, isBold, isItalic, isUnderline, this.ActualTheme, parentInline, leftIndent);
}
- public override void OnRender(DrawingContext context)
+ private DrawingContext GetOrUpdateRenderCommandCache()
{
var activeFont = ActiveFont;
if (activeFont == null || _positionedChars.Count == 0)
{
_renderCommandCache.Clear();
- _isRenderCommandCacheDirty = false;
- return;
+ _selectionRenderCommandCache?.Clear();
+ _isContentRenderCommandCacheDirty = false;
+ _isSelectionRenderCommandCacheDirty = false;
+ _tableRenderCommandCount = 0;
+ _selectionRenderCommandCount = 0;
+ return _renderCommandCache;
}
if (_cachedSelectionStart != SelectionStart ||
_cachedSelectionLength != SelectionLength ||
- !ReferenceEquals(_cachedTableSelection, TableSelection) ||
- !ReferenceEquals(_cachedHoveredHyperlink, _hoveredHyperlink))
+ !ReferenceEquals(_cachedTableSelection, TableSelection))
+ {
+ _isSelectionRenderCommandCacheDirty = true;
+ }
+ if (!ReferenceEquals(_cachedHoveredHyperlink, _hoveredHyperlink))
{
- _isRenderCommandCacheDirty = true;
+ _isContentRenderCommandCacheDirty = true;
}
- if (_isRenderCommandCacheDirty)
+ if (_isContentRenderCommandCacheDirty)
{
_renderCommandCache.Clear();
- TextLayoutEngine.Render(
+ TextLayoutEngine.RenderTableDecorations(
+ _renderCommandCache,
+ _tableDecorations);
+ _tableRenderCommandCount = _renderCommandCache.Commands.Count;
+ TextLayoutEngine.RenderSelection(
_renderCommandCache,
_positionedChars,
- _tableDecorations,
activeFont,
SelectionStart,
SelectionLength,
TableSelection,
- _hoveredHyperlink,
SelectionHighlightColor);
+ _selectionRenderCommandCount =
+ _renderCommandCache.Commands.Count - _tableRenderCommandCount;
+ TextLayoutEngine.RenderText(
+ _renderCommandCache,
+ _positionedChars,
+ activeFont,
+ _hoveredHyperlink);
_cachedSelectionStart = SelectionStart;
_cachedSelectionLength = SelectionLength;
_cachedTableSelection = TableSelection;
_cachedHoveredHyperlink = _hoveredHyperlink;
- _isRenderCommandCacheDirty = false;
+ _isContentRenderCommandCacheDirty = false;
+ _isSelectionRenderCommandCacheDirty = false;
+ }
+ else if (_isSelectionRenderCommandCacheDirty)
+ {
+ DrawingContext selectionCache =
+ _selectionRenderCommandCache ??= new DrawingContext();
+ selectionCache.Clear();
+ TextLayoutEngine.RenderSelection(
+ selectionCache,
+ _positionedChars,
+ activeFont,
+ SelectionStart,
+ SelectionLength,
+ TableSelection,
+ SelectionHighlightColor);
+ _renderCommandCache.Commands.RemoveRange(
+ _tableRenderCommandCount,
+ _selectionRenderCommandCount);
+ if (selectionCache.Commands.Count > 0)
+ _renderCommandCache.Commands.InsertRange(
+ _tableRenderCommandCount,
+ selectionCache.Commands);
+ _selectionRenderCommandCount = selectionCache.Commands.Count;
+ _cachedSelectionStart = SelectionStart;
+ _cachedSelectionLength = SelectionLength;
+ _cachedTableSelection = TableSelection;
+ _isSelectionRenderCommandCacheDirty = false;
}
- context.Commands.AddRange(_renderCommandCache.Commands);
+ return _renderCommandCache;
+ }
+ DrawingContext IOwnedRenderCommandCache.GetOrUpdateRenderCommandCache() =>
+ GetOrUpdateRenderCommandCache();
+
+ public override void OnRender(DrawingContext context)
+ {
+ context.Commands.AddRange(GetOrUpdateRenderCommandCache().Commands);
base.OnRender(context);
}
}
diff --git a/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs b/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs
index 745dc254..b6df6bbe 100644
--- a/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs
+++ b/src/ProGPU.WinUI/Controls/TextLayoutEngine.cs
@@ -20,17 +20,21 @@ public static class TextLayoutEngine
private static readonly SolidColorBrush HyperlinkBrush = new SolidColorBrush(0x0078D4FF);
private static readonly SolidColorBrush SelectionHighlightBrush = new SolidColorBrush(0x0078D435);
private static readonly SolidColorBrush HoveredHyperlinkBrush = new SolidColorBrush(0x005A9EFF);
+ private static readonly TextShapingOptions[] CommonLeftToRightShapingOptions =
+ CreateCommonShapingOptions(ShapingDirection.LeftToRight);
+ private static readonly TextShapingOptions[] CommonRightToLeftShapingOptions =
+ CreateCommonShapingOptions(ShapingDirection.RightToLeft);
public static void AccumulateInlines(
- Inline inline,
- List list,
- Brush defaultFg,
- float defaultSize,
- bool isBold,
- bool isItalic,
- bool isUnderline,
+ Inline inline,
+ List list,
+ Brush defaultFg,
+ float defaultSize,
+ bool isBold,
+ bool isItalic,
+ bool isUnderline,
ElementTheme theme,
- Inline? parentInline = null,
+ Inline? parentInline = null,
float leftIndent = 0f,
TtfFont? parentFont = null)
{
@@ -271,6 +275,16 @@ private static int GetInlinesLength(IEnumerable inlines)
_ => 1
};
+ private static int GetCachedBlockTextLength(Block block, RichBlockLayoutCache cache)
+ {
+ if (cache.LogicalTextLength < 0)
+ {
+ cache.LogicalTextLength = GetBlockTextLength(block);
+ }
+
+ return cache.LogicalTextLength;
+ }
+
private static void RebaseBlockCharacters(RichBlockLayoutCache cache, int logicalTextOffset)
{
cache.RebaseTextPositions(logicalTextOffset);
@@ -432,7 +446,7 @@ line[runEnd].Info.EmbeddedElement is null &&
ShapingBufferFlags flags = ShapingBufferFlags.None;
if (runStart == 0) flags |= ShapingBufferFlags.BeginningOfText;
if (runEnd == line.Count) flags |= ShapingBufferFlags.EndOfText;
- TextShapingOptions options = CreateShapingOptions(first, runLevel).WithBufferFlags(flags);
+ TextShapingOptions options = CreateShapingOptions(first, runLevel, flags);
string runText = runStart == 0 && runLength == logicalText.Length
? logicalText
: logicalText.Substring(runStart, runLength);
@@ -508,7 +522,10 @@ line[runEnd].Info.EmbeddedElement is null &&
}
}
- private static TextShapingOptions CreateShapingOptions(RichChar style, sbyte bidiLevel)
+ private static TextShapingOptions CreateShapingOptions(
+ RichChar style,
+ sbyte bidiLevel,
+ ShapingBufferFlags bufferFlags = ShapingBufferFlags.None)
{
ShapingDirection direction = (bidiLevel & 1) == 0
? ShapingDirection.LeftToRight
@@ -529,17 +546,49 @@ private static TextShapingOptions CreateShapingOptions(RichChar style, sbyte bid
Direction = direction,
Language = string.IsNullOrWhiteSpace(style.LanguageTag) ? null : style.LanguageTag,
Features = resolved.Features,
- ExplicitFeatureTags = resolved.ExplicitFeatureTags
+ ExplicitFeatureTags = resolved.ExplicitFeatureTags,
+ BufferFlags = bufferFlags
};
}
+
+ if (string.IsNullOrWhiteSpace(style.LanguageTag) &&
+ (bufferFlags & ~(ShapingBufferFlags.BeginningOfText | ShapingBufferFlags.EndOfText)) == 0)
+ {
+ TextShapingOptions[] common = direction == ShapingDirection.RightToLeft
+ ? CommonRightToLeftShapingOptions
+ : CommonLeftToRightShapingOptions;
+ return common[(int)bufferFlags];
+ }
+
return new TextShapingOptions
{
Direction = direction,
- Language = string.IsNullOrWhiteSpace(style.LanguageTag) ? null : style.LanguageTag
+ Language = string.IsNullOrWhiteSpace(style.LanguageTag) ? null : style.LanguageTag,
+ BufferFlags = bufferFlags
};
}
- private sealed class ParagraphShapingMetrics
+ private static TextShapingOptions[] CreateCommonShapingOptions(ShapingDirection direction) =>
+ [
+ new TextShapingOptions { Direction = direction },
+ new TextShapingOptions
+ {
+ Direction = direction,
+ BufferFlags = ShapingBufferFlags.BeginningOfText
+ },
+ new TextShapingOptions
+ {
+ Direction = direction,
+ BufferFlags = ShapingBufferFlags.EndOfText
+ },
+ new TextShapingOptions
+ {
+ Direction = direction,
+ BufferFlags = ShapingBufferFlags.BeginningOfText | ShapingBufferFlags.EndOfText
+ }
+ ];
+
+ internal sealed class ParagraphShapingMetrics
{
public ParagraphShapingMetrics(int length)
{
@@ -562,7 +611,8 @@ private static ParagraphShapingMetrics MeasureShapedParagraphAdvances(
List characters,
TtfFont activeFont,
TextReadingOrder textReadingOrder,
- FlowDirection flowDirection)
+ FlowDirection flowDirection,
+ RichDocumentLayoutSession? layoutSession = null)
{
var metrics = new ParagraphShapingMetrics(characters.Count);
int paragraphStart = 0;
@@ -584,11 +634,14 @@ private static ParagraphShapingMetrics MeasureShapedParagraphAdvances(
paragraphEnd++;
}
- var paragraph = new List(paragraphEnd - paragraphStart);
+ List paragraph = layoutSession?.GetShapingCharacterScratch() ??
+ new List(paragraphEnd - paragraphStart);
var textBuilder = new StringBuilder(paragraphEnd - paragraphStart);
for (int i = paragraphStart; i < paragraphEnd; i++)
{
- paragraph.Add(new PositionedRichChar { Info = characters[i] });
+ RichChar character = characters[i];
+ paragraph.Add(layoutSession?.RentPositionedCharacter(character) ??
+ new PositionedRichChar { Info = character });
textBuilder.Append(characters[i].Character);
}
@@ -628,6 +681,8 @@ private static ParagraphShapingMetrics MeasureShapedParagraphAdvances(
}
}
+ layoutSession?.ReleaseCharacters(paragraph);
+
paragraphStart = paragraphEnd;
}
@@ -668,12 +723,12 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa
{
float scale = baseFontSize / activeFont.UnitsPerEm;
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
float maxFontSize = baseFontSize;
foreach (var inline in paragraph.Inlines)
@@ -697,10 +752,10 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa
float avgCharWidth = maxFontSize * 0.49f;
float charsPerLine = Math.Max(10f, availableWidth / avgCharWidth);
int estimatedLines = (int)Math.Ceiling(charCount / charsPerLine);
-
+
int lineBreaks = CountLineBreaks(paragraph.Inlines);
estimatedLines = Math.Max(estimatedLines, lineBreaks + 1);
-
+
float blockLineSpacing = (activeFont.Ascender - activeFont.Descender + activeFont.LineGap) * (maxFontSize / activeFont.UnitsPerEm);
float embeddedHeight = 0f;
@@ -736,12 +791,12 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa
}
}
}
-
+
if (embeddedHeight > 0f)
{
return embeddedHeight + block.MarginBottom;
}
-
+
return estimatedLines * blockLineSpacing + block.MarginBottom;
}
else if (block is ListBlock listBlock)
@@ -772,12 +827,12 @@ private static float EstimateBlockHeight(Block block, float availableWidth, floa
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);
-
+
tableHeight += rowHeight;
}
return tableHeight + block.MarginBottom;
}
-
+
return 30f + block.MarginBottom;
}
@@ -795,6 +850,7 @@ private static void LayoutBlock(
FrameworkElement parent,
Action addChild,
HashSet encounteredChildren,
+ RichDocumentLayoutSession layoutSession,
List blockChars,
List blockDecorations,
TextWrapping textWrapping,
@@ -803,7 +859,7 @@ private static void LayoutBlock(
bool alignmentIncludesTrailingWhitespace,
bool ignoreTrailingCharacterSpacing)
{
- blockChars.Clear();
+ layoutSession.ReleaseCharacters(blockChars);
blockDecorations.Clear();
Paragraph? paragraphBlock = block as Paragraph;
@@ -855,7 +911,7 @@ private static void LayoutBlock(
});
}
- var charList = new List