diff --git a/.claude/skills/beutl-agent-source-grounding/SKILL.md b/.claude/skills/beutl-agent-source-grounding/SKILL.md index 696ed34d58..8c15ffee45 100644 --- a/.claude/skills/beutl-agent-source-grounding/SKILL.md +++ b/.claude/skills/beutl-agent-source-grounding/SKILL.md @@ -32,7 +32,7 @@ If the user explicitly forbids source-code reading, do not use this skill. Recor | Shape sizing and local drawing | `src/Beutl.Engine/Graphics/Shapes/Shape.cs`, `RectShape.cs`, `RoundedRectShape.cs`, `EllipseShape.cs` | Bounds size, stroke inflation, and draw origin. | | GeometryShape geometry positioning | `src/Beutl.Engine/Graphics/Shapes/Shape.cs` (`OnDraw`, `MeasureCore`) | The `-shapeBounds.Position` normalization is commented out and `MeasureCore` returns only `geometry.Bounds.Size`, so a path is drawn offset by `geometry.Bounds.Position`. Author paths around `(0,0)` or `measure_object_bounds` + compensate. Closed Pen-only paths render when the `Pen` brush/thickness and path bounds are valid. | | Transform numeric meaning | `src/Beutl.Engine/Graphics/Transformation/TranslateTransform.cs`, `ScaleTransform.cs`, `TransformGroup.cs`, `CanonicalTransformLayout.cs` | Whether values are absolute positions, offsets, percentages, or ordered transform children. `ScaleTransform` values are percentages (`100` = 1x), not normalized multipliers. | -| Render-node transform and bounds behavior | `src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs` | Operation bounds aggregation, bounds transformation, hit-test inversion, and density rescale. | +| Render-node transform and bounds behavior | `src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` | Operation bounds aggregation, bounds transformation, hit-test inversion, and density rescale. | | Toolkit examples and generated snippets | `src/Beutl.AgentToolkit/Schema/SchemaGenerator.cs`, `CompositionTemplates.cs` | How toolkit examples choose translate values, animation discriminators, and reusable object shapes. | | Quality analyzer assumptions | `src/Beutl.AgentToolkit/Rendering/QualityAnalyzer.cs` | How text/plate bounds, centers, foreground rect dominance, and typography overload are estimated. | | Still and motion verification | `src/Beutl.AgentToolkit/Rendering/StillRenderer.cs`, `MotionVariationAnalyzer.cs` | Which warnings should block export and how frame coverage is computed. | diff --git a/.claude/skills/beutl-filter-effect/SKILL.md b/.claude/skills/beutl-filter-effect/SKILL.md index 817ed1d11a..69d4a7821e 100644 --- a/.claude/skills/beutl-filter-effect/SKILL.md +++ b/.claude/skills/beutl-filter-effect/SKILL.md @@ -235,7 +235,65 @@ Create your own resource files inside the extension project, or pass a literal s ### SKSL (SkiaShaderLanguage) pattern -Compile the shader in the static constructor and apply it through `CustomEffect`: +> **Prefer `context.Shader(...)` for per-pixel work.** A `ShaderDefinition` recorded through +> `FilterEffectContext.Shader` is a typed fragment the planner can fuse with neighbouring shader stages +> into one GPU pass. `SKSLScriptEffect` also records supported scripts declaratively and exposes them to +> the fusion planner: `half4 main(float2 fragCoord)` becomes `WholeSource`, which can head a fusion run +> and absorb later per-pixel work but not upstream operations, while `half4 apply(half4 color)` becomes +> fully fusable `CurrentPixel` work. Scripts +> that cannot be represented declaratively—including reserved `__beutl*`/`fe*_*` names, multi-declarator +> uniforms, non-literal array lengths, or uniform types without a canonical zero value—automatically fall +> back to the legacy `CustomEffect` path, so existing scripts do not break. Using `CustomEffect` +> directly remains the right tool for raw-target allocation, sampling, or drawing, but it is opaque to the +> planner and forms a fusion boundary. + +| Authoring construct | Fusion behavior | Limit or boundary | +|---|---|---| +| `CurrentPixel` shaders; immutable opacity | Fusable | May join a compatible fusion run. | +| `WholeSource` shaders | Can be the head of a fusion run | May absorb later per-pixel work; upstream work cannot fold into it. | +| Skia image filters (`Blur`, `DropShadow`, `Dilate`, `Erode`); `CustomEffect`; Geometry; 3D; raw canvas access | Not fusable | Forms a fusion boundary. | +| Sampler/child budget | Portable: 12; Vulkan/Metal: 12 | The implicit `src` sampler consumes one slot; exceeding the cap falls back to a standalone pass. | + +Declare the shader once as a `static readonly ShaderDefinition` and record a call of it per frame. +The definition holds the shape — source, uniform and resource bindings — and `.Call(state)` supplies this +frame's values, so the planner sees a typed fragment it can fuse: + +```csharp +public partial class MosaicEffect : FilterEffect +{ + private static readonly ShaderDefinition s_definition = + ShaderDefinition.WholeSource( + """ + uniform shader src; + uniform float2 tileSize; + + half4 main(float2 fragCoord) { + float2 blockIndex = floor(fragCoord / tileSize); + float2 sampleCoord = blockIndex * tileSize + tileSize * 0.5; + return src.eval(sampleCoord); + } + """, + RenderBoundsContract.Identity, + static bindings => bindings.Uniform("tileSize", static tileSize => tileSize.ToVector2())); + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + var r = (Resource)resource; + context.Shader(s_definition.Call(r.TileSize)); + } +} +``` + +The definition callback must be pure and non-capturing: its `MethodInfo` is the shader's structural +identity, so two frames that differ only in `tileSize` reuse one compiled program. If several effects share +one source, parse it once with `SkslSource.WholeSource(...)` or `SkslSource.CurrentPixel(...)` and pass the +result to the matching factory instead of the raw string. + +### The `CustomEffect` fallback + +Reach for this only when the work cannot be expressed declaratively — raw target allocation, sampling +outside the declared input, or drawing onto the target. It is opaque to the planner and forms a fusion +boundary. The same mosaic, written the imperative way: ```csharp public partial class MosaicEffect : FilterEffect diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c26eb3df16..24cdcfd02e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,4 +1,4 @@ -name: .NET +name: .NET on: push: @@ -52,6 +52,34 @@ jobs: # SwiftShader Vulkan ICD fails to load; see GpuGoldenSuiteCanaryTests. BEUTL_REQUIRE_GPU: "1" + # A validation error names API misuse the driver is not required to diagnose - a render pass instance + # begun inside another, a handle submitted to a device that never created it - so a suite that runs + # green while reporting one has already entered undefined behaviour. The layer stays off for the run + # above because it costs time on all 7,000 tests and is only meaningful for the GPU-backed ones; this + # step turns it on for exactly those. VulkanTestEnvironment/GpuTestEnvironment compare the validation + # log around every render-thread invocation, so an error fails the test that reported it, and + # VulkanValidationGateTests fails if the layer was requested but did not load - a gate that cannot + # observe anything must not pass quietly. + - name: Install Vulkan validation layers + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends vulkan-validationlayers + + # KnownVulkanSkiaLayoutInterop is excluded: Skia and the backend track the same image's layout + # independently and drift apart, which validation reports as InvalidImageLayout. Closing it needs a + # way to read back or command the layout Skia holds, which SkiaSharp 3.119 does not expose. Those + # tests still run in the step above; see TestCategories.KnownVulkanSkiaLayoutInterop and issue #2263, + # on whose close both TestCategory!= clauses below come out. + - name: GPU tests under Vulkan validation + run: | + dotnet test tests/Beutl.UnitTests/Beutl.UnitTests.csproj --no-build -f net10.0 \ + --filter "(TestCategory=GpuPassFusionGpu|FullyQualifiedName~VulkanValidationGateTests|FullyQualifiedName~GpuGoldenSuiteCanaryTests)&TestCategory!=KnownVulkanSkiaLayoutInterop" + dotnet test tests/Beutl.Graphics3DTests/Beutl.Graphics3DTests.csproj --no-build -f net10.0 \ + --filter "TestCategory!=KnownVulkanSkiaLayoutInterop" + env: + BEUTL_REQUIRE_GPU: "1" + BEUTL_VULKAN_VALIDATION: "1" + - name: Merge coverage reports # Every test project now collects coverage (coverlet.collector is shared via # tests/Directory.Build.props), so dotnet test emits one cobertura file per test diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 8bf460496b..5e56afdb74 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -22,7 +22,7 @@ Beutl targets `net10.0` and `net10.0-windows`. Both targets must keep building. ### III. Test-First with NUnit -- Test framework is NUnit + Moq. Tests are organized under `tests/` in per-area projects (e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available. +- Test framework is NUnit + Moq. Tests are organized under `tests/` in per-area projects (e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.PublicApiContractTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.PublicApiContractTests/` is the non-friend compile gate for public authoring APIs; `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available. - New logic in `src/` is incomplete without an accompanying test. - Benchmarks (`tests/Beutl.Benchmarks`, `tests/Beutl.FFmpegBenchmarks`) use BenchmarkDotNet and are excluded from regular `dotnet test` runs. - CI quality gate: `dotnet test Beutl.slnx -f net10.0 --settings coverlet.runsettings` must pass, with the coverage threshold configured in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml) honored. diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh index fda64e7428..dd2320549a 100755 --- a/.specify/scripts/bash/common.sh +++ b/.specify/scripts/bash/common.sh @@ -264,29 +264,43 @@ get_feature_paths() { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) - # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) - # 3. Branch-name-based prefix lookup (legacy fallback) - local feature_dir + # 2. Branch-name prefix lookup, when it names a feature directory that exists + # 3. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # + # The branch outranks the pin because feature.json is a single checked-in value shared by every + # feature in the repo: whichever feature wrote it last would otherwise redirect the analysis, + # planning and task scripts on every other feature's branch to its own directory, silently. + # The pin still resolves a branch whose name says nothing about which feature it belongs to, + # which is the case it was added for. + local feature_dir='' if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then feature_dir="$SPECIFY_FEATURE_DIRECTORY" # Normalize relative paths to absolute under repo root [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - elif [[ -f "$repo_root/.specify/feature.json" ]]; then - # Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on - # missing/unparseable/unset so we fall through to the branch-prefix lookup. - local _fd - _fd=$(read_feature_json_feature_directory "$repo_root") - if [[ -n "$_fd" ]]; then - feature_dir="$_fd" - # Normalize relative paths to absolute under repo root - [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then - echo "ERROR: Failed to resolve feature directory" >&2 + else + local _branch_dir='' + if ! _branch_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then return 1 fi - elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then - echo "ERROR: Failed to resolve feature directory" >&2 - return 1 + + if [[ -d "$_branch_dir" ]]; then + feature_dir="$_branch_dir" + else + # Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on + # missing/unparseable/unset so we fall back to whatever the branch named. + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -n "$_branch_dir" ]]; then + feature_dir="$_branch_dir" + else + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + fi fi # Use printf '%q' to safely quote values, preventing shell injection diff --git a/AGENTS.md b/AGENTS.md index 8df263031c..3bdaf8549c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Beutl is a cross-platform video editing / compositing application built on Avalo - License: the main app is **MIT**; `Beutl.FFmpegWorker` alone is **GPL-3.0-or-later** (a separate process) - UI: Avalonia (XAML + ViewModel) -- Tests: NUnit + Moq under `tests/` (per-area projects, e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available — see `tests/CLAUDE.md` +- Tests: NUnit + Moq under `tests/` (per-area projects, e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.PublicApiContractTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.PublicApiContractTests/` is the non-friend compile gate for public authoring APIs; `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available — see `tests/CLAUDE.md` - E2E / headless-UI tests: `tests/Beutl.E2ETests/` (library-level) and `tests/Beutl.HeadlessUITests/` (drives the real shell, the sole test referencing `src/Beutl`) on `Avalonia.Headless.NUnit`, with shared helpers in `tests/Beutl.Testing.Headless/`. They run on headless CI without xvfb or a GPU — see `tests/CLAUDE.md` - Build: Nuke (`nukebuild/`) or `dotnet` directly diff --git a/Beutl.slnx b/Beutl.slnx index aa09fcc2f6..e3ff2a05b5 100644 --- a/Beutl.slnx +++ b/Beutl.slnx @@ -36,6 +36,7 @@ + diff --git a/docs/ai-workflow/resolution-independent-rendering.md b/docs/ai-workflow/resolution-independent-rendering.md index eb511fb817..129171572b 100644 --- a/docs/ai-workflow/resolution-independent-rendering.md +++ b/docs/ai-workflow/resolution-independent-rendering.md @@ -12,7 +12,7 @@ filter effects, brushes, and shaders. | Scale | Type | Meaning | |---|---|---| | **Output scale `s_out`** | `Renderer.OutputScale` / `RenderNodeContext.OutputScale` | the final target only: device pixels per logical unit at the root. `1.0` = logical == device. | -| **Effective scale** | `RenderNodeOperation.EffectiveScale` | the supply density an op's pixels actually exist at. Vector ops are `Unbounded`; bitmap ops report `At(scale)`. | +| **Effective scale** | `RenderFragmentHandle.EffectiveScale` (also on `RenderFragmentMetadata`) | the supply density a recorded fragment's pixels actually exist at. Vector fragments are `Unbounded`; bitmap fragments report `At(scale)`. | | **Working scale `w`** | `FilterEffectContext.WorkingScale` (+ `RenderNodeContext.ResolveWorkingScale`) | the density a buffer-allocating boundary runs at, negotiated from the inputs' supply densities (falling back to `s_out` for vector-only inputs), capped by `MaxWorkingScale`. There is no per-effect policy knob. | ## What most authors need to do: nothing @@ -52,7 +52,11 @@ the CTM handles it, and a manual `× w` would double-scale and regress the resul **no `ResolutionPolicy`**: the earlier `Inherit`/`ClampToOutput`/`Oversample(k)`/`PreserveSource` policy was removed because no built-in needed a non-default value. An effect that genuinely needs a different working scale (clamp-to-output for perf, oversample for SSAA) returns a `FilterEffectRenderNode` subclass from - `FilterEffect.Resource.CreateRenderNode()` and overrides `Process` to compute its own `w`. + `FilterEffect.Resource.CreateRenderNode()` and overrides `GetWorkingScaleContract()` to return a + `RenderScaleContract`. Overriding `Process` is for an effect that needs different topology or lowering, not + merely a different density; see + [`effect-scale-contract.md`](../specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md) + for a worked example. - **Bitmap sources.** A decoded image/video op reports its decoded density as `EffectiveScale.At(...)`, distinct from its logical footprint. Mixed-scale compositing resamples off-target bitmaps via `ImmediateCanvas.DrawRenderTargetScaled` / `DrawSurfaceScaled` (Mitchell). 003 ships only this seam; the diff --git a/docs/specs/003-resolution-independent-pipeline/checklists/requirements.md b/docs/specs/003-resolution-independent-pipeline/checklists/requirements.md index 644b448633..a6b4bbe7fa 100644 --- a/docs/specs/003-resolution-independent-pipeline/checklists/requirements.md +++ b/docs/specs/003-resolution-independent-pipeline/checklists/requirements.md @@ -44,10 +44,14 @@ - **Supply-driven scale-model refinement (2026-05-30, post-plan, maintainer-driven).** The original top-down D1 (every effect runs at the requested scale) was replaced with a **supply-driven** model after the maintainer required: (R1) a low-res proxy input is NOT upsampled by an intermediate effect; (R2) a higher-than-output input (e.g. 4K on a 1080 timeline) flows into intermediate effects at full density. Three scales now exist — **output scale `s_out`** (final target only), per-op **`EffectiveScale`** (supply density; vector = `Unbounded`, replacing the dropped `LosslessReRasterizable` bool), and computed **working scale `w`** via `ResolveWorkingScale` + a per-effect **`ResolutionPolicy`** (FR-036) under a **global ceiling** (FR-037). Maintainer choices: default `Inherit` for all effects (opt-out via `ClampToOutput`) + global memory ceiling ON. Validated by a design + adversarial-verification workflow. Edits across spec (FR-008/009/013/016/017/018/019 + new FR-036/037, Key Entities), research (D1 rewrite + new D7 + D6 cache), data-model (new `EffectiveScale`/`ResolutionPolicy` value types; `OutputScale`/`WorkingScale` renames), and contracts. A **fresh Codex review** then verified the model against code, finding only top-down leftovers + one byte-identity hazard; all six fixed (spec Assumptions cap deferral; shader uniforms `s`→`w`; quickstart/plan Slice-2 `×s`→`×w`; `ResolutionPolicy.Inherit` vector-only fallback; FR-007 filter-sink rounding preserved at `w=1.0`; `MaxWorkingScale` preview-only / export-uncapped; `PreserveSource` floor carrier; cache-creation path; D1–D7 label). - **`/speckit-plan` Codex review (2026-05-30) — two spec contradictions corrected.** A code-grounded review of the plan caught: (1) **FR-007** said origins "floor" but the code (`PixelPoint.FromPoint` `(int)` cast) truncates **toward zero** — they differ for negative origins and byte-identity depends on it; corrected FR-007 + the two edge-case lines. (2) **FR-011** required render scale in the stroke cache key, contradicting decision D3 (logical-space outlines are scale-invariant); reworded FR-011 to be outcome-based (cache stays correct, no stale-scale reuse) and note the logical-space approach. The `GraphicsContext2D` consumer inventory in `contracts/public-api.md` was completed. (The *first* Codex attempt confabulated nonexistent files and was discarded; the re-run with forced file reads produced these valid findings.) - **Coherent density model — byte-identity abolished as a design constraint (2026-06-08, maintainer-driven, BREAKING).** A context-free "cold critique" (Codex + an independent Claude pass, no byte-identity context) converged that the density model was incoherent in exactly the three places byte-identity-at-`s_out=1` mandated: **M1** decoded media always `At(1.0)`, **M2** transforms forwarding density unchanged, **M3** a `w == 1` buffer reporting the re-rasterizable `Unbounded`. The maintainer chose to **abolish the universal byte-identity-at-`s_out=1` guarantee** rather than keep papering over the model. Shipped: **(M2 / FR-019)** `TransformRenderNode` re-scales a bitmap child's `EffectiveScale` by the inverse transform scale — `At(d) → At(d / min(|sx|,|sy|))` — so a shrink raises density (R2: a 4K source dropped small keeps detail) and an enlarge lowers it (no fake-sharpening of an upscaled bitmap); anisotropic/rotated transforms project onto the most-detailed axis; a pure rotation/translation is scale 1 and leaves density unchanged. **(M3)** custom / flush / 3D buffers report their true `At(w)` density including `w = 1` (the old `Unbounded`-at-`w==1` was an R1 violation at `s_out > 1`, not just a byte-identity convenience). **M1 is unchanged** — a decoded source genuinely is `At(1)` at its native 1:1, now modulated by the transform. **Scope of the break:** byte-identity still holds for vector / Skia-filter / text / unscaled-bitmap content (the golden set) and every per-sink `w == 1` rounding/blit fast path (untouched); it breaks ONLY for a scaled bitmap source feeding a buffer boundary (effect). **SC-001 / FR-005 amended** to that narrower guarantee; the "regression anchor" / "hard invariant across every phase" framing (plan.md, tasks.md) is superseded; FR-019 carries the normative density rule. Earlier footguns C4/C5 (mixed vector + bitmap floor), C8 (Oversample escapes the preview ceiling), C7 (finite export ceiling) shipped just prior and remain. The three byte-identity-preserving guards plus this change were verified against the full golden suite + `SourceEffectiveScaleFlowTests` (rewritten `TransformRenderNode_ScalesChildDensity_*`). -- **`ResolutionPolicy` removed — working scale is supply-driven only (2026-06-09, maintainer-driven, BREAKING).** A branch-design review (7-dimension adversarial "doubt the spec" workflow) observed that the per-effect `ResolutionPolicy` (`Inherit`/`ClampToOutput`/`Oversample(k)`; `PreserveSource` already removed 2026-06-08) had **zero in-tree users** — every built-in ran the default `Inherit` — and that an effect needing a non-supply working scale can already customize its `FilterEffectRenderNode` via `FilterEffect.Resource.CreateRenderNode()` (overriding `Process`), strictly more flexible than a closed three-value enum. Per AGENTS.md ("no speculative surface; orthogonality first"), the maintainer **removed the policy concept entirely**. Shipped: deleted `ResolutionPolicy`/`ResolutionPolicyKind` (`Graphics/Rendering/ResolutionPolicy.cs`), the `virtual FilterEffect.ResolutionPolicy`, and the `policy` parameter of `RenderNodeContext.ResolveWorkingScale` — which collapses to `min(supply, MaxWorkingScale)` (supply = densest concrete input; vector-only/mixed floor at `s_out`). The `RenderNode.ResolutionPolicy` was never added (a dead duplicate). `MaxWorkingScale` (FR-037) is **retained** as the sole upper bound and the C8 "Oversample escapes the ceiling" special-case is gone with it; **export now uses a finite ceiling `max(8, 4×s_out)` (the C7 bound), not `+∞`** — the FR-037 "export uses no ceiling" wording was corrected to match the shipped `OutputViewModel`. Updated: spec (FR-009/013/016/036/037, Key Entities, Assumptions), data-model, research D7 (superseded banner), all three contracts, plan, quickstart, tasks ledger, author guide. Verified: `dotnet build` clean (Engine + UnitTests + Beutl) and the rewritten `ResolutionScaleTests` / `SourceEffectiveScaleFlowTests` (policy-specific cases dropped) green. *(The ergonomics — reducing how much of `Process` a subclass must reproduce — are deferred to a separate PR improving `FilterEffectRenderNode`'s **general customizability**; overriding the node serves more than the working scale, so a working-scale-specific `protected virtual ResolveWorkingScale` seam will **not** be added — clarified 2026-06-11.)* -- **Codex branch design-review (2026-06-09) — findings recorded.** A Codex "doubt the spec" pass over the whole branch raised seven findings. Two were concrete and fixed in place: **(5)** the `effect-scale-contract.md` custom-render-node example was self-contradictory — it computed a custom `w` then `return base.Process(context)`, but `FilterEffectRenderNode.Process` recomputes the supply-driven `w` inline and ignores any subclass value, so the example now shows that a custom-`w` effect must copy the whole `Process` body until the deferred `FilterEffectRenderNode`-customizability follow-up lands (clarified 2026-06-11: that follow-up improves the node's general customizability; a working-scale-specific `protected virtual ResolveWorkingScale` seam will not be added); **(6)** `FR-014` over-claimed "the system MUST ADD a named scale uniform (`iScale`/`uScale`)" for *both* shader languages, but as shipped only SKSL gets `iScale` — GLSL deliberately adds no push constant (ABI choice) and derives the scale from device-px `Width`/`Height`; `FR-014` was narrowed to match `shader-uniforms.md`. Two are genuine **known limitations, not yet fixed** (recorded, not silently implemented): **(1, highest-confidence)** the centralized mixed-scale compositor rule (`T035`: `RenderNodeProcessor` computes `targetScale = max(concrete child)` and regenerates `Unbounded` children via `RasterizeAt`) is **not** implemented centrally — density propagation is distributed into the container nodes (`LayerRenderNode` computes the `max`, `RenderNodeOperation`/`EffectTarget` resample via `ImmediateCanvas.Draw*Scaled`, T034). Codex's residual-gap concern (`LayerRenderNode` reports `max(child)` but `SaveLayer` captures at the current CTM density) was **re-analysed and found benign (2026-06-09)**: the layer op is a `CreateLambda` that **re-renders its children at the consumer's canvas density every draw**, so the `SaveLayer` is always at the right density and nothing is frozen lower — the `At(max child)` value is a correct "max useful density" hint. It is also **unreachable in production** (`GraphicsContext2D.PushLayer`, the only builder of a `LayerRenderNode`, is called only from a unit test). The cached-tile density concern is the separate **T025** deferral. So the centralised-`RasterizeAt` T035 work is an optional consolidation, not a correctness fix. Separately, **T045's 3D-perspective-append was probe-verified already-correct** (`Perspective3DScaleProbeTests`: a `Rotation3DTransform` child holds SSIM 0.998 at 0.5× and 1.0000 at 2× SSAA — the root `CreateScale(s)` sits at the device boundary so the `S·P≠P·S` trap is structurally avoided; no append fix needed). **(2)** US1's "preview renders faster" promise is **weaker for source-heavy scenes**: a high-density (transform-rescaled) source feeding an effect can pull the working scale back up to the `MaxWorkingScale` ceiling (`2 × s_out` preview) even at Half, so the speedup mostly comes from the final-stage downscale + vector/text shrink, not the effect intermediates; `SC-003`'s benchmark scene is vector-heavy and would not catch this. Three were framing/known: proxy seam thinner than "drops in" implies (real sources are `At(1)`; decode-scale + intrinsic-logical-size deferred), render-cache stores no working scale (tiles default `Unbounded`, T025 deferred), and "resolution-independent" is more precisely "output-scale-aware logical rendering" (FR-001 pins the logical unit to a decoded pixel at `FrameSize`). The earlier "export density has no validity domain → OOM" headline was **not** re-raised: `OutputViewModel` seeds a finite `MaxWorkingScale = max(8, 4 × s_out)` (C7) bounding every sink via `min(supply, ceiling)`, so the hazard is unreachable. +- **`ResolutionPolicy` removed — supply-driven default retained (2026-06-09, maintainer-driven, BREAKING; current hook clarified by feature 004).** A branch-design review observed that the per-effect `ResolutionPolicy` (`Inherit`/`ClampToOutput`/`Oversample(k)`; `PreserveSource` already removed 2026-06-08) had **zero in-tree users** — every built-in ran the default `Inherit` — so the closed enum, `virtual FilterEffect.ResolutionPolicy`, and the `policy` parameter of `RenderNodeContext.ResolveWorkingScale` were removed. The current working-scale-only escape hatch is `FilterEffectRenderNode.GetWorkingScaleContract()`, which preserves base isolation/lowering and folds the contract into the first real operation without an identity pass; overriding `Process` is reserved for genuinely different topology/lowering. `FilterEffectContext.TryGetWorkingScale` guards symbolic/branch-dependent authoring. `MaxWorkingScale` (FR-037) remains the global upper bound, with the per-buffer dimension clamp as the allocatability bound. Historical details about the removed enum remain in research D7 and are explicitly superseded there. + +> **Feature-004 update:** the working-scale customization part of the historical review entry below is +> superseded. Working-scale-only nodes now override `GetWorkingScaleContract()` and retain base lowering; +> `Process` is reserved for genuinely different topology/lowering. +- **Codex branch design-review (2026-06-09) — findings recorded.** A Codex "doubt the spec" pass over the whole branch raised seven findings. Two were concrete and fixed in place: **(5)** the `effect-scale-contract.md` custom-render-node example was self-contradictory — it computed a custom `w` then `return base.Process(context)`, but `FilterEffectRenderNode.Process` recomputes the supply-driven `w` inline and ignores any subclass value, so the example now shows that a custom-`w` effect must copy the whole `Process` body until the deferred `FilterEffectRenderNode`-customizability follow-up lands (clarified 2026-06-11: that follow-up improves the node's general customizability; a working-scale-specific `protected virtual ResolveWorkingScale` seam will not be added); **(6)** `FR-014` originally lacked a GLSL scale API; the shipped ABI now exposes working density as GLSL `scale`, mirroring SKSL `iScale`, and `shader-uniforms.md` gives the matching author guidance. Two are genuine **known limitations, not yet fixed** (recorded, not silently implemented): **(1, highest-confidence)** the centralized mixed-scale compositor rule (`T035`: `RenderNodeProcessor` computes `targetScale = max(concrete child)` and regenerates `Unbounded` children via `RasterizeAt`) is **not** implemented centrally — density propagation is distributed into the container nodes (`LayerRenderNode` computes the `max`, `RenderNodeOperation`/`EffectTarget` resample via `ImmediateCanvas.Draw*Scaled`, T034). Codex's residual-gap concern (`LayerRenderNode` reports `max(child)` but `SaveLayer` captures at the current CTM density) was **re-analysed and found benign (2026-06-09)**: the layer op is a `CreateLambda` that **re-renders its children at the consumer's canvas density every draw**, so the `SaveLayer` is always at the right density and nothing is frozen lower — the `At(max child)` value is a correct "max useful density" hint. It is also **unreachable in production** (`GraphicsContext2D.PushLayer`, the only builder of a `LayerRenderNode`, is called only from a unit test). The cached-tile density concern is the separate **T025** deferral. So the centralised-`RasterizeAt` T035 work is an optional consolidation, not a correctness fix. Separately, **T045's 3D-perspective-append was probe-verified already-correct** (`Perspective3DScaleProbeTests`: a `Rotation3DTransform` child holds SSIM 0.998 at 0.5× and 1.0000 at 2× SSAA — the root `CreateScale(s)` sits at the device boundary so the `S·P≠P·S` trap is structurally avoided; no append fix needed). **(2)** US1's "preview renders faster" promise is **weaker for source-heavy scenes**: a high-density (transform-rescaled) source feeding an effect can pull the working scale back up to the `MaxWorkingScale` ceiling (`2 × s_out` preview) even at Half, so the speedup mostly comes from the final-stage downscale + vector/text shrink, not the effect intermediates; `SC-003`'s benchmark scene is vector-heavy and would not catch this. Three were framing/known: proxy seam thinner than "drops in" implies (real sources are `At(1)`; decode-scale + intrinsic-logical-size deferred), render-cache stores no working scale (tiles default `Unbounded`, T025 deferred), and "resolution-independent" is more precisely "output-scale-aware logical rendering" (FR-001 pins the logical unit to a decoded pixel at `FrameSize`). The earlier "export density has no validity domain → OOM" headline was **not** re-raised: `OutputViewModel` seeds a finite `MaxWorkingScale = max(8, 4 × s_out)` (C7) bounding every sink via `min(supply, ceiling)`, so the hazard is unreachable. - **Second Codex branch design-review (2026-06-09) — 5 findings, disposed.** A fresh Codex "doubt the spec" pass (post ResolutionPolicy-removal + A-1 fix) raised five. **(1+4) Buffer allocation isn't bounded by the FR-037 `w` ceiling — FIXED.** Memory and the GPU texture limit scale with `bounds × w`, not `w`; a scalar `EffectiveScale` under an **anisotropic** transform projects onto the most-detailed axis (FR-019), inflating the stretched-axis bounds while raising density, so a downstream effect could size `ceil(bounds × w)` past the 16384 GPU limit (un-allocatable → crash) or into the multi-GiB range (a 3840×2160 source under `(0.25, 4)` → ~34560 px ≈ 1 GiB). Added `RenderNodeContext.ClampWorkingScaleToBufferBudget` (caps each buffer axis at `MaxBufferDimension = 16384`, reducing `w` to fit), applied in `FilterEffectRenderNode.Process`; inert for non-pathological bounds (byte-identity preserved); unit-tested. The scalar-vs-anisotropic density *model* limitation stays documented (FR-006 uniform-scale v1); this bounds its worst case. **(2) The supply model is not closed over brushes/opacity masks — PARTIALLY fixed + documented.** A brush-backed op (e.g. a rectangle with an `ImageBrush`) reports `Unbounded` (`RectangleRenderNode` and peers use the default `EffectiveScale`), so a bitmap brush's finite density never raises the parent op's supply, and an `ImageBrush` opacity mask never constrains the decorated op. The A-1 fix density-corrects **canvas-managed FILL/stroke/mask** brush rasters; this change additionally threads `canvas.OutputScale` into the **audio-visualizer** `BrushConstructor` callers. **Still open (documented architectural gap):** op-level `EffectiveScale` derived from a brush's bitmap density, and the effect-internal `BrushConstructor` callers (`FlatShadow`, `FilterEffectContext`, `DisplacementMap`) that fill effect buffers — both need a brush-returns-density (or brush-as-child-op) refactor, deferred. **(3) FR-008 "multiply spatial-length params by `w`" is the wrong abstraction — REFRAMED.** The real axis is the *coordinate space* a value lives in (logical-space-geometry-under-CTM = unchanged; device-buffer/device-shader = `× w` once; readback geometry = `÷ w`); the old "spatial-length" matrix invited the double-scaling already seen in Shake/Perlin/strokes/audio. `effect-scale-contract.md`'s "one rule" section rewritten around coordinate space. **(5) US3 is not yet a real proxy seam — KNOWN (framing).** Real sources hardcode `At(1)` and draw native 1:1; keeping `MediaOptions` extensible is not an architectural seam (intrinsic-logical-size + decoded-dims + dest-mapping + density propagation + cache identity all still needed). The branch is honestly "output-scale-aware preview/export rendering with a proxy-compatible *direction*", and FR-001's FrameSize-pinned logical unit does not justify a literal "resolution-independent" reading — already noted in the framing entries above. Codex also confirmed the A-1 tile-shader local-matrix (`Scale(1/s)`) is algebraically sound for all affine brush transforms (no matrix bug). - **Third Codex review (2026-06-09) — re-review of the fixes.** A fresh pass scrutinised the #1/#4 buffer clamp, the #2 audio-viz threading, and the #3 FR-008 reframe. It **confirmed** the buffer clamp applies the clamped `w` consistently (context + activator + CustomFilterEffectContext all see it — no child rasterises at the old unclamped value), the audio-viz `canvas.OutputScale` threading is correct at its call sites, and the FR-005 byte-identity narrowing is honest. Two real issues, now fixed: **(a)** `FR-037`'s "bounded worst-case **memory**" was an overclaim — a working-scale cap does not bound memory (which scales `bounds × w²`); the per-axis `16384` dimension clamp bounds only the *density-driven* blow-up at the effect boundary (a single buffer may still be ~2 GiB; non-effect sinks size by `s_out`/authored dims and are not dimension-guarded — a pre-existing huge-geometry concern). FR-037 reworded to separate the working-scale cap from the buffer-dimension clamp and name the request-scoped allocator as the complete follow-up. **(b)** the FR-008 reframe commit left **`FR-010` still saying "PerlinNoise BaseFrequency is divided by the scale"**, contradicting the corrected `effect-scale-contract.md` (leave unchanged); FR-010 fixed to match. Standing-but-known: render-cache density blindness (cache tiles default `Unbounded`, T025 deferred) and the supply model not being closed over brush-backed ops (a rect+ImageBrush reports `Unbounded`) — both documented architectural follow-ups, not regressions. Also fixed a `CLAUDE.md` trailing-blank-line hygiene nit. - **Independent code-verification review (Codex) applied.** A second pass verified the dossier's claims against the actual code and critiqued the spec. Corrections folded in: factual fixes (filter sinks use `(int)` cast not `PixelRect.FromRect`; `FromRect` rounds asymmetrically, not truncates; proxy-decode/IPC kept out of 003 scope), and four newly-found coupling sites became first-class requirements — **FR-029** (particles), **FR-030** (audio visualizers), **FR-031** (render-dispatcher atomicity), **FR-032** (scale invalidation key + source-generator impact), **FR-033** (3D as a mixed-scale op). FR/SC wording tightened (raw-frame vs encoded byte-identical, root-only FR-003, capability-flag FR-018, scoped SC-008, benchmark/manifest caveats in SC-003/SC-004). The dossier carries the corrections in its **§12** addendum. - **Claude branch ultracode review (2026-06-11) — 75 verified findings, dispositions.** A deep multi-agent review of the whole branch produced 75 verified findings. **Fixed in place:** `MaxWorkingScale` forwarding gaps in the `DrawableBrush` / particle sub-pulls; `HitTest`/`RecalculateBoundaries` scale pass-through + the 3D-picking logical-coordinate fix; the node-cache minimal density/ceiling fix (cache processor built with the renderer's `(OutputScale, MaxWorkingScale)`, replay re-tags tiles with their creation density); `CustomFilterEffectContext.CreateTarget` buffer-budget clamp + clamped-`w` consistency; allocation-failure logging; `Mosaic` absolute-origin `× w`; `FlatShadow`/`DisplacementMap`/`BlendMode` brush density; density-aware `EffectTarget` blit; copy-as-image at `s_out = 1`; `FitToPreviewer` rebuild re-render; `FrameCacheManager` size reapplication; `Composer` lifetime decoupling + dispose recheck; export-size pre-validation; UI localization; CI `BEUTL_REQUIRE_GPU`; and this docs sweep (FR-008 coordinate-space summaries everywhere, quickstart full sweep, SC-009 gate direction honesty — MAE strictly decreases, SSIM ≥ −0.01 tolerance —, "sole upper bound" qualifiers for the FR-037(b) dimension clamp, FR-031/US1 residual pre-amendment sentences, public-api ctor rows + call-site refs, explicit preview/export ceiling divergence). **Deferred follow-ups (recorded so they aren't lost):** a demand-side density term for effect-synthesized vector geometry; a ratio-dependent reconciliation resampling kernel (Mitchell aliases above ~2× downscale); anisotropic density projection via a closed-form 2×2 SVD; GPU-side supersample downscale before readback; brush shader/intermediate caching; playback-time preview-quality switching (renderer-swap ownership hardening); an `EffectiveScale` composition-algebra public API; an `ImmediateCanvas.OutputScale` rename (the two-meanings problem); a windowed SSIM metric; the T017 frozen pre-feature baseline; T025 multi-scale cache reuse; a GLSL scale uniform. -- **Working-scale floor + export-ceiling removal + density-factory hardening (2026-06-15, maintainer-driven, BREAKING behaviour).** A further shipped pass changed three load-bearing behaviours and added two robustness seams; docs swept to match. **(1) `s_out` is now the working-scale FLOOR.** `ResolveWorkingScale` is `w = min( max(s_out, densest concrete supply), MaxWorkingScale )` — a sub-output concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is floored to `w = 1.0` so the effect renders at the deliverable density (matching the pre-feature renderer), replacing the earlier "a 0.5 proxy stays 0.5 even at export, NOT upsampled" rule; an effect's working resolution is distinct from the source's available detail, so running below `s_out` only discards resolution the target can use. A reduced-scale proxy is still cheap in preview (`max(0.5, 0.5) = 0.5`). `s_out` is still **never a ceiling** (a denser supply runs above it, FR-016). The former "mixed bitmap+vector floor at `s_out`" (C4/C5) is now just an instance of the universal floor. Byte-identity anchor untouched (`max(1, 1) = 1`). Edits: FR-036, FR-016/FR-019 cross-refs, Key Entities (Output/Working scale), research D1 (R1 rewrite) + D7 (`baseline = max(s_out, supply)`), data-model `ResolveWorkingScale` rule, effect-scale-contract "Working scale" section. **(2) Export imposes NO working-scale quality ceiling — `MaxWorkingScale = +∞` on export** (`WorkingScaleCeiling.Export`). The earlier finite `max(8, 4 × s_out)` was a quality clip masquerading as an OOM backstop (it discarded detail from any source denser than the ceiling — a 4096-px logo in a 256-px box = supply 16, clipped at 8 — far below any allocation limit; the "never clips a legitimate high-resolution source" claim was false). The per-buffer **dimension** clamp (`ClampWorkingScaleToBufferBudget`, 16384 px/axis, per-buffer bounds) is the **sole** allocatability bound; a request-scoped aggregate byte/area budget is the documented OOM follow-up. Preview ceiling stays `2 × s_out`. Edits: FR-037 (+ preview/export divergence tail), research D7 ceiling para + SUPERSEDED banner, data-model (3 sites), public-api `SceneRenderer` row, effect-scale-contract "Working scale" ceiling bullet. **(3) SC-002 direction clarified** — at `s_out = 1` a **shrunk** high-density source keeps higher density → genuinely higher fidelity; an **enlarged** source is floored to `w = 1` → resolution-equivalent to the pre-feature renderer (no longer a regression). The change is not uniformly "higher-fidelity"; the release-note disclosure now scopes to the shrunk-source gain. **(4) `EffectiveScale.At` THROWS** on a non-finite/non-positive density and the pull path has no try/catch (a throw aborts the render); new **`EffectiveScale.AtOrUnbounded`** is the non-throwing pull-path factory (degrades to `Unbounded`). A plugin density override from animatable geometry MUST pre-guard or use `AtOrUnbounded`. `RenderNodeContext` sanitizes a degenerate `OutputScale` (0/NaN/∞ → 1) and `MaxWorkingScale` (NaN/≤0 → +∞) once at construction. Edits: public-api `EffectiveScale` + `RenderNodeContext` rows, new effect-scale-contract subsection. **(5) `TextureSource.Resource.GetTexture` gains additive `float renderScale = 1f`** — `DrawableTextureSource` rasterizes its re-rasterizable `Drawable` at `ceil(authorSize × surfaceDensity)` so a vector label stays crisp on a supersampled 3D surface; a decoded-bitmap source ignores it. Edits: public-api new `TextureSource` row, data-model new row. **(6) Known v1 limitation documented:** in **Fit-to-previewer** mode the preview ceiling (`2 × s_out`) floats with the window-derived `s_out`, so a concrete-source-fed resolution-sensitive effect renders at a density that changes as the editor panel is resized — recommend a fixed Full/Half/Quarter when evaluating such effects (new spec Edge Case). **(7) Footgun documented:** `w` = densest concrete input applies to the whole buffer-allocating boundary, so a single small high-density sibling raises the boundary's `w` (and area `∝ w²`); and scaling a source **down** raises its density, so it gets **more** expensive as it shrinks — per-target scoping / an area budget is the follow-up (effect-scale-contract). **(8) SC-003 perf-gate honesty:** the `< 0.6` ratio gate is loose relative to the `s² ≈ 0.25` ideal (~38% fixed overhead in the committed best case) — it proves **direction**, not the full `s²` value; the source-heavy benchmark variant (deliberate ~no preview speedup) is **required** as the regression anchor. **Renamed for orthogonality (DONE, BREAKING).** The public rename has landed in the shipped source and the docs are aligned: the 2D renderer's request output scale `IRenderer.RenderScale` / `Renderer.RenderScale` → **`OutputScale`** (matching `RenderNodeContext.OutputScale` / `GraphicsContext2D.OutputScale`), and the 3D per-surface working density `IRenderer3D.RenderScale` / `Renderer3D.RenderScale` / `RenderContext3D.RenderScale` → **`SurfaceDensity`**. The same word no longer names three different quantities, and neither collides with the unrelated app-layer UI enum `Beutl.Models.RenderScale` (Full/Half/Quarter/FitToPreviewer), which is unchanged. Pure rename, no behaviour change. +- **Working-scale floor + export-ceiling removal + density-factory hardening (2026-06-15, maintainer-driven, BREAKING behaviour).** A further shipped pass changed three load-bearing behaviours and added two robustness seams; docs swept to match. **(1) `s_out` is now the working-scale FLOOR.** `ResolveWorkingScale` is `w = min( max(s_out, densest concrete supply), MaxWorkingScale )` — a sub-output concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is floored to `w = 1.0` so the effect renders at the deliverable density (matching the pre-feature renderer), replacing the earlier "a 0.5 proxy stays 0.5 even at export, NOT upsampled" rule; an effect's working resolution is distinct from the source's available detail, so running below `s_out` only discards resolution the target can use. A reduced-scale proxy is still cheap in preview (`max(0.5, 0.5) = 0.5`). `s_out` is still **never a ceiling** (a denser supply runs above it, FR-016). The former "mixed bitmap+vector floor at `s_out`" (C4/C5) is now just an instance of the universal floor. Byte-identity anchor untouched (`max(1, 1) = 1`). Edits: FR-036, FR-016/FR-019 cross-refs, Key Entities (Output/Working scale), research D1 (R1 rewrite) + D7 (`baseline = max(s_out, supply)`), data-model `ResolveWorkingScale` rule, effect-scale-contract "Working scale" section. **(2) Export imposes NO working-scale quality ceiling — `MaxWorkingScale = +∞` on export** (`WorkingScaleCeiling.Export`). The earlier finite `max(8, 4 × s_out)` was a quality clip masquerading as an OOM backstop (it discarded detail from any source denser than the ceiling — a 4096-px logo in a 256-px box = supply 16, clipped at 8 — far below any allocation limit; the "never clips a legitimate high-resolution source" claim was false). The per-buffer **dimension** clamp (`ClampWorkingScaleToBufferBudget`, 16384 px/axis, per-buffer bounds) is the **sole** allocatability bound; a request-scoped aggregate byte/area budget is the documented OOM follow-up. Preview ceiling stays `2 × s_out`. Edits: FR-037 (+ preview/export divergence tail), research D7 ceiling para + SUPERSEDED banner, data-model (3 sites), public-api `SceneRenderer` row, effect-scale-contract "Working scale" ceiling bullet. **(3) SC-002 direction clarified** — at `s_out = 1` a **shrunk** high-density source keeps higher density → genuinely higher fidelity; an **enlarged** source is floored to `w = 1` → resolution-equivalent to the pre-feature renderer (no longer a regression). The change is not uniformly "higher-fidelity"; the release-note disclosure now scopes to the shrunk-source gain. **(4) `EffectiveScale.At` THROWS** on a non-finite/non-positive density and the pull path has no try/catch (a throw aborts the render); new **`EffectiveScale.AtOrUnbounded`** is the non-throwing pull-path factory (degrades to `Unbounded`). A plugin density override from animatable geometry MUST pre-guard or use `AtOrUnbounded`. `RenderNodeContext` sanitizes a degenerate `OutputScale` (0/NaN/∞ → 1) and `MaxWorkingScale` (NaN/≤0 → +∞) once at construction. Edits: public-api `EffectiveScale` + `RenderNodeContext` rows, new effect-scale-contract subsection. **(5) `TextureSource.Resource.GetTexture` gains additive `float renderScale = 1f`** — `DrawableTextureSource` rasterizes its re-rasterizable `Drawable` at `ceil(authorSize × surfaceDensity)` so a vector label stays crisp on a supersampled 3D surface; a decoded-bitmap source ignores it. Edits: public-api new `TextureSource` row, data-model new row. **(6) Known v1 limitation documented:** in **Fit-to-previewer** mode the preview ceiling (`2 × s_out`) floats with the window-derived `s_out`, so a concrete-source-fed resolution-sensitive effect renders at a density that changes as the editor panel is resized — recommend a fixed Full/Half/Quarter when evaluating such effects (new spec Edge Case). **(7) Footgun documented:** `w` = densest concrete input applies to the whole buffer-allocating boundary, so a single small high-density sibling raises the boundary's `w` (and area `∝ w²`); and scaling a source **down** raises its density, so it gets **more** expensive as it shrinks — per-target scoping / an area budget is the follow-up (effect-scale-contract). **(8) SC-003 perf-gate honesty (amended 2026-07-24):** the paired sign-test gate proves direction across backends by requiring 0.5× to win at least 9 of 11 interleaved pairs (`p < 0.05`); the pinned vector-heavy workload additionally requires `median(0.5)/median(1.0) < 0.85` as a minimum effect-size threshold, while the source-heavy benchmark variant (deliberate ~no preview speedup) remains the required regression anchor. **Renamed for orthogonality (DONE, BREAKING).** The public rename has landed in the shipped source and the docs are aligned: the 2D renderer's request output scale `IRenderer.RenderScale` / `Renderer.RenderScale` → **`OutputScale`** (matching `RenderNodeContext.OutputScale` / `GraphicsContext2D.OutputScale`), and the 3D per-surface working density `IRenderer3D.RenderScale` / `Renderer3D.RenderScale` / `RenderContext3D.RenderScale` → **`SurfaceDensity`**. The same word no longer names three different quantities, and neither collides with the unrelated app-layer UI enum `Beutl.Models.RenderScale` (Full/Half/Quarter/FitToPreviewer), which is unchanged. Pure rename, no behaviour change. diff --git a/docs/specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md b/docs/specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md index e25007c821..592b633cf1 100644 --- a/docs/specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md +++ b/docs/specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md @@ -2,6 +2,10 @@ **Feature**: 003 | FR-008/FR-009/FR-010/FR-011/FR-012/FR-015. Audience: authors of `FilterEffect`, `CustomEffect`, `Drawable`, brushes, and C# script effects (in-tree and plugin). +> **Superseded API note (Feature 004):** the scale semantics below remain current, but Feature 004 replaces +> `RenderNodeOperation`/`RenderNodeProcessor` with recorded `RenderNode` fragments. API examples and +> migration rules in this contract use the Feature 004 surface. + ## The rule (FR-008) — what matters is the COORDINATE SPACE, not the parameter type > **Reframed 2026-06-09 (Codex review #3).** The original "multiply every *spatial-length* parameter @@ -25,14 +29,16 @@ and the base CTM scales it to device for free. Device-space code opts out with ` - `PerlinNoiseBrush.BaseFrequency` is **left unchanged** — `SkPerlinNoiseShader` follows the CTM, so its period is logical-invariant; dividing by `w` made the reduced-scale result *worse* (the dossier's "÷w" was wrong for this CTM pipeline). Reduced-scale softness is accepted best-effort (FR-013). - Text is **re-shaped** at `Size × w` (it reads the device font size, not a CTM-scaled outline — `Hinting=Full` bakes resolution-specific grid-fitting); never matrix- or bitmap-scaled (FR-012). - **A Skia `SKImageFilter` primitive (Blur/DropShadow/Dilate/Erode) takes its length args RAW** — do NOT `× w`; it rides the `CreateScale(w)` CTM. Only **device-buffer / device-shader** code multiplies. -- **Anisotropic transforms** (FR-019): a scalar `EffectiveScale` projects onto the most-detailed axis, which can over-allocate; the buffer is bounded by `ClampWorkingScaleToBufferBudget` (FR-037 backstop). +- **Anisotropic transforms** (FR-019): a scalar `EffectiveScale` projects onto the most-detailed axis, which can over-allocate; general policy uses `RenderScaleUtilities.ClampWorkingScaleToBufferBudget`, while allocation contexts exact-check their canonical device footprint (FR-037 backstop). ## How an author reads the active scale (FR-015) -Two accessors, both default `1.0`. **They expose the `WorkingScale` `w`** (what the effect runs at), not the output scale. An effect that needs the eventual delivery target reads `FilterEffectContext.OutputScale`. +Two execution surfaces expose the `WorkingScale` `w` (what the effect runs at), not the output scale. An effect that needs the eventual delivery target reads `FilterEffectContext.OutputScale`. Author-time access is availability-checked; it never substitutes `1.0` for symbolic metadata. + +1. **`FilterEffectContext.TryGetWorkingScale(out float)` / `WorkingScale`** — for `CSharpScriptEffect` and out-of-tree `FilterEffect`s built from the context primitives. The value is author-readable only when recording has one concrete effect input. A symbolic owning-target domain or multiple concrete branches returns `false`; the `WorkingScale` getter throws instead of exposing a provisional or aggregate value as final. Record scale-independent structure in that case and use the execution-time Shader/Geometry/CustomEffect context for device math. Even when available, this is the nominal effect-input density: a later bounds-expanding operation may apply the per-buffer dimension clamp and run below it. The Skia `SKImageFilter` primitives (`Blur`/`DropShadow`/`Dilate`/`Erode`/`Transform`/`MatrixConvolution`) take their spatial-length args **raw (logical)** — they are **NOT** multiplied by `WorkingScale`; they ride the `CreateScale(w)` CTM that `FilterEffectActivator.Flush` pushes, so Skia scales them for free. An effect that forwards through them inherits scale-correctness **without multiplying anything** (multiplying would double-scale). Only **CustomEffect point-blit** code (Mosaic/InnerShadow/ColorShift/…) multiplies its absolute-length args by its execution-time working scale (those blit into a `ceil(bounds × w)` device buffer instead of riding the CTM). +2. **`CustomFilterEffectContext.WorkingScale`** — for legacy `CustomEffect` / SKSL / GLSL callbacks. `CreateTarget(bounds)` preserves the existing local-buffer rule: dimensions are `(int)` at `w == 1` and otherwise `ceil(dimension × w)`; a fractional logical origin does not add a pixel. `Open` returns that buffer's canvas **already carrying the baked base CTM `CreateScale(density)`** (the buffer's real, post-clamp density — read it from the returned target's `Scale.Value` for clamp-correct device math). So the effect draws **logical** content directly through the `ImmediateCanvas` APIs with **no manual prescale**. Code that must work in **device pixels** wraps that draw in **`canvas.PushDeviceSpace()`** and uses device-px literals (`× target.Scale.Value`). `WorkingScale` is requested density; the dimension-only per-buffer clamp may lower the created target's density. `DeviceBounds` and `RasterBounds` expose placement metadata, but do not change the legacy local allocation or `Open` origin. Renderer-owned aprons are removed before callback entry, and callback-created targets keep their local phase through direct final replay. New typed `Shader` and `Geometry` work uses the separate canonical device-footprint contract. -1. **`FilterEffectContext.WorkingScale`** — for `CSharpScriptEffect` and out-of-tree `FilterEffect`s built from the context primitives. The Skia `SKImageFilter` primitives (`Blur`/`DropShadow`/`Dilate`/`Erode`/`Transform`/`MatrixConvolution`) take their spatial-length args **raw (logical)** — they are **NOT** multiplied by `WorkingScale`; they ride the `CreateScale(w)` CTM that `FilterEffectActivator.Flush` pushes, so Skia scales them for free. An effect that forwards through them inherits scale-correctness **without multiplying anything** (multiplying would double-scale). Only **CustomEffect point-blit** code (Mosaic/InnerShadow/ColorShift/…) multiplies its absolute-length args by `WorkingScale` (those blit into a `ceil(bounds × w)` device buffer instead of riding the CTM). -2. **`CustomFilterEffectContext.WorkingScale`** — for `CustomEffect` / SKSL / GLSL. `CreateTarget(bounds)` allocates `ceil(bounds × WorkingScale)`; `Open` returns that buffer's canvas **already carrying the baked base CTM `CreateScale(density)`** (the buffer's real, post-clamp density — read it from the returned target's `Scale.Value` for clamp-correct device math). So the effect draws **logical** content directly through the `ImmediateCanvas` APIs with **no manual prescale** (the `StrokeEffect` pattern — this also routes brush fills through the canvas's density so tile/image/drawable brushes rasterize at `w`). Code that must work in **device pixels** — point-blitting another device buffer, a contour traced from the device alpha mask, a full-buffer shader rect — wraps that draw in **`canvas.PushDeviceSpace()`** (CTM → identity, density → 1) and uses device-px literals (`× WorkingScale`). **Clamp caveat (FR-037(b)) — `WorkingScale` is the REQUESTED density; the allocated buffer can be CLAMPED below it.** On a **large-bounds** frame the allocated target is reduced by `ClampWorkingScaleToBufferBudget` (FR-037(b)) to stay within the 16384-px GPU axis limit, and the created target carries that **lower** density in its `Scale.Value`. Device-pixel author math (point-blit offsets, shader resolution uniforms, absolute-px literals) multiplying by the **bare `WorkingScale`** then computes coordinates for a *denser* buffer than was allocated and **mis-registers** (the draw lands at the wrong pixels). So such code MUST read the **created target's `Scale.Value`**, not `WorkingScale`. An effect that builds device-px values **before** it holds a target to read `Scale.Value` from — the in-tree shader effects compute uniforms up front, and `GLSLShader.Apply` hands its callback the *source* target — must recompute the density `CreateTarget` will resolve: `RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, WorkingScale)` on the **same `bounds`** passed to `CreateTarget` (the one canonical clamp `CreateTarget` itself calls, so the result is identical by construction — see `Mosaic`/`ColorShift`/`Displacement`/`SKSLScriptEffect`/`GLSLScriptEffect`). When you *do* hold the created target, read its `Scale.Value` directly (the InnerShadow/BlendMode pattern). +The legacy allocation path applies the dimension-only buffer-budget clamp and records composition-grid metadata separately in `src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs`. ```csharp // out-of-tree FilterEffect example @@ -45,7 +51,7 @@ public override void ApplyTo(FilterEffectContext context) // device-pixel work (point-blit / contour / shader rect) wraps in PushDeviceSpace. context.CustomEffect(state, (d, c) => { - EffectTarget t = c.CreateTarget(bounds); // ceil(bounds * WorkingScale) device buffer + EffectTarget t = c.CreateTarget(bounds); // legacy local dimensions after the per-buffer clamp using ImmediateCanvas canvas = c.Open(t); // base CTM = CreateScale(t.Scale.Value), already baked canvas.DrawRectangle(logicalRect, brush, null); // LOGICAL — no manual prescale using (canvas.PushDeviceSpace()) // absolute device px @@ -57,7 +63,7 @@ public override void ApplyTo(FilterEffectContext context) } // An effect that needs a working scale OTHER than the supply density (clamp-to-output for perf, -// oversample for SSAA) overrides the render node instead of declaring a policy: +// oversample for SSAA) overrides the render node's declarative scale hook: public sealed partial class Resource { public override FilterEffectRenderNode CreateRenderNode() => new OversampleRenderNode(this); @@ -65,54 +71,51 @@ public sealed partial class Resource private sealed class OversampleRenderNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) { - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - // base.Process recomputes the supply-driven w and ignores any w you compute here, - // so a custom w means reproducing the Process body with your own value. Today this is - // a full copy of the base flow — only the `workingScale =` line differs (a separate PR - // improves FilterEffectRenderNode's general customizability, shrinking this copy surface): - // - // var inputScales = ...; // = context.Input[i].EffectiveScale - // float supplyW = RenderNodeContext.ResolveWorkingScale(inputScales, context.OutputScale, context.MaxWorkingScale); - // float workingScale = MathF.Min(MathF.Max(supplyW, 2f * context.OutputScale), context.MaxWorkingScale); // SSAA-on-demand - // using var feContext = new FilterEffectContext(context.CalculateBounds(), context.OutputScale, workingScale); - // ... (the rest of FilterEffectRenderNode.Process verbatim) ... - return base.Process(context); // placeholder — supply-driven; see the copy above for a custom w - } + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => MathF.Min( + MathF.Max( + RenderScaleUtilities.ResolveWorkingScale( + metadata.InputSupplies.ToArray(), + metadata.OutputScale, + metadata.MaxWorkingScale), + 2f * metadata.OutputScale), + metadata.MaxWorkingScale)); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; } ``` -> **Note (as shipped):** the `CreateRenderNode()` override **is now honoured on every push path** — `FilterEffect.Resource.Push` routes through `CreateRenderNode()` (2026-06-10), so an effect on a normal `Drawable` (not just the node-graph path) gets its custom `FilterEffectRenderNode`. **Still deferred** is the ergonomics: `FilterEffectRenderNode.Process` computes the supply-driven `w` inline, so a subclass wanting a *different* `w` must copy the whole `Process` body (`base.Process(context)` runs supply-driven and silently ignores any `w` the subclass computed). Overriding `FilterEffectRenderNode` is a general customization point — working scale is only one reason to do it — so the follow-up is a **separate PR improving the node's overall customizability** (reducing how much of `Process` a subclass must reproduce), not a working-scale-specific hook: a narrow `protected virtual float ResolveWorkingScale(RenderNodeContext)` seam was considered and **will not be added**. So today: overriding the *whole* `Process` works end-to-end, but there is no shortcut for the "only change `w`" case yet. +`FilterEffect.Resource.Push` honours `CreateRenderNode()` on every push path. `GetWorkingScaleContract()` keeps the base isolation, transactional `ApplyTo`, typed lowering, and resource transfer. Its policy is folded into the first surviving Shader, Geometry, or legacy operation; it does not create an identity map or an extra pass. A no-item result is a true pass-through and commits neither provisional isolation nor unused owned resources. The hook and resolver remain lazy when `ApplyTo` does not probe `WorkingScale`; an explicit probe can evaluate them even when no item is ultimately authored. The pure contract is reevaluated after a symbolic owning domain resolves. Override `Process` only when the effect needs genuinely different topology or lowering, not merely a different working density. ## Working scale — what scale an effect runs at -Every effect runs at the **supply-driven working scale `w`**, computed from its inputs' effective scales (there is **no per-effect policy knob**). The rule is `w = min( max(s_out, densest concrete supply), MaxWorkingScale )` *(amended 2026-06-15 — `s_out` is the FLOOR; the earlier "a 0.5 proxy stays 0.5" wording is superseded)*: +Built-in effects use the **standard supply-driven working scale `w`** from `RenderScaleContract.MaterializeAtWorkingScale` unless a backend limit requires a narrower declarative contract. There is no closed per-effect policy enum. The standard rule is `w = min( max(s_out, densest concrete supply), MaxWorkingScale )`: - `w` is **floored at `s_out`** (the deliverable density) and **raised by the densest concrete (bitmap) input above it**. A 2.0 source runs at 2.0 (no downsample — `s_out` is **not** a ceiling). A **sub-output** concrete supply — an enlarged / low-density bitmap, `At(0.5)` — feeding an effect at a `1.0` export is **floored to `w = 1.0`** (rendering at the deliverable density, matching the pre-feature renderer), **not** held at 0.5. Why: an effect's own working resolution (its blur kernel / shadow / shader grid) is distinct from the source's available *detail* — running it below `s_out` only discards resolution the delivery target can use, without fabricating source detail. A genuine reduced-scale proxy is still cheap in **preview**: at a `0.5` preview a `0.5` proxy gives `max(0.5, 0.5) = 0.5`. - vector-only inputs (`Unbounded`) impose no supply → `w` stays at the `s_out` floor; a mixed bitmap+vector boundary likewise lands at `s_out` when no concrete input exceeds it, so crisp vector siblings are not dragged down to a low-density bitmap — now an instance of the universal floor, no longer a special case. -- `w` is finally capped by the global ceiling `MaxWorkingScale` (FR-037; **preview `2 × s_out`, export `+∞`** — export imposes no working-scale quality ceiling, *amended 2026-06-15*). This is the sole **global** ceiling (`FilterEffectRenderNode` passes `context.MaxWorkingScale`); additionally the per-buffer **dimension** clamp (FR-037(b), `RenderNodeContext.ClampWorkingScaleToBufferBudget`, 16384 px per axis) is the sole **allocatability** bound and may further reduce `w` at the effect boundary — at the node level and again per target at `Flush` against the post-effect-inflated bounds. Two distinct bounds; do not conflate them. +- `w` is finally capped by the global ceiling `MaxWorkingScale` (FR-037; **preview `2 × s_out`, export `+∞`** — export imposes no working-scale quality ceiling, *amended 2026-06-15*). This is the sole **global** ceiling (`FilterEffectRenderNode` passes `context.MaxWorkingScale`); additionally the per-buffer **dimension** clamp (FR-037(b), 16384 px per axis) is the sole **allocatability** bound and may further reduce `w` at the effect boundary — at the node level and again per target at `Flush` against the post-effect-inflated canonical device footprint. General calculations use `RenderScaleUtilities.ClampWorkingScaleToBufferBudget`; `CustomEffect` authors use `CustomFilterEffectContext.ResolveTargetDensity` for the allocation they are about to make. Two distinct bounds; do not conflate them. -**Every built-in runs supply-driven** — including the FR-013 resolution-sensitive set (`PixelSort`, contour `Stroke`/`FlatShadow`/`PartsSplit`, `AutoClip`, `Dilate`, `Erode`, `Mosaic`, custom SKSL/GLSL, image-map `Displacement`), since running at the supply density already keeps a high source's density through them. The working scale MUST NOT change the `s_out = 1.0` output. +**Every built-in uses the supply-driven result** — including `Blur`, `DropShadow`, and the FR-013 resolution-sensitive set (`PixelSort`, contour `Stroke`/`FlatShadow`/`PartsSplit`, `AutoClip`, `Dilate`, `Erode`, `Mosaic`, custom SKSL/GLSL, image-map `Displacement`). No built-in overrides the working-scale hook; running at the supply density keeps a high-resolution source's density through the effect. The common `MaxWorkingScale` and per-buffer 16 384-axis clamps still apply. -**Need a different working scale?** An effect that genuinely needs clamp-to-output (perf) or oversampling (SSAA) returns a `FilterEffectRenderNode` subclass from `FilterEffect.Resource.CreateRenderNode()` and overrides `Process` to compute its own `w` (see the example above). There is intentionally no declarative `ResolutionPolicy` — no built-in needed one, and a custom render node is more flexible than a closed enum. *(Earlier drafts had an `Inherit`/`ClampToOutput`/`Oversample(k)`/`PreserveSource` policy; it was removed.)* +**Need a different working scale?** An effect that genuinely needs intentional sub-output rendering, a backend-stability limit, clamp-to-output (performance), or oversampling (SSAA) returns a `FilterEffectRenderNode` subclass from `FilterEffect.Resource.CreateRenderNode()` and overrides `GetWorkingScaleContract()` (see the example above). An explicit `Custom` result is capped by `MaxWorkingScale` and the relevant buffer bounds but is not raised to the standard `s_out` floor, so returning `0.5` remains `0.5` in a `1.0` delivery. The callback is invoked independently for each surviving branch with exactly one `InputSupplies` item and that branch's isolated effect-input bounds as `OutputBounds`. A legacy multi-input segment takes the densest concrete mapped result; only an all-`Unbounded` result falls back to `s_out`. There is intentionally no closed `ResolutionPolicy` enum. The contract is a narrow declarative hook that preserves the base lowering; override `Process` only for genuinely different topology or lowering. *(Earlier drafts had an `Inherit`/`ClampToOutput`/`Oversample(k)`/`PreserveSource` policy; it was removed.)* -> **Footgun — `w` is per-boundary, not per-op; and shrinking a source makes it *more* expensive (2026-06-15).** `w` = the **densest concrete input** applies to the **whole buffer-allocating boundary**, so a single small high-density sibling raises the working scale — and thus the buffer **area** (`∝ w²`) — of the *entire* boundary, not just its own region. Example: a 4K logo shrunk into a corner carries `At(16)` density; under a shared effect (or any container allocating one buffer for the group) it lifts the whole boundary to `w = 16`, allocating a `16×`-denser buffer for mostly-low-density content. Because density is *backing pixels per logical unit*, scaling a high-resolution source **down** **raises** its density — so the source gets **more** expensive the smaller you draw it (inverting the usual "smaller = cheaper" intuition). The per-buffer **dimension** clamp (`ClampWorkingScaleToBufferBudget`) keeps such a buffer *allocatable* but does not stop it from dominating the boundary's cost. **Follow-up (deferred):** per-target (per-region) `w` scoping and a request-scoped area/byte budget — so a small dense sibling raises only its own region's density — are the proper fix. +> **Footgun — legacy multi-input `w` is shared, while allocation topology can change.** A dense sibling can still raise the nominal working scale used by every surviving branch, so shrinking a high-resolution source can make the effect more expensive. Before an opaque `CustomEffect`, a forced compatibility `Flush` removes renderer-owned aprons; callback-created buffers then use the historical dimension-only allocation and retain their local raster phase. `CustomEffect` exposes no combine/split topology, so the planner unions the callback's transformed branch results and conservatively tracks subsequent footprints in that aggregate domain. Typed Shader/Geometry and pure Skia branch paths remain per-branch and use canonical physical footprints. ## Resolution-sensitive effects (FR-013) `PixelSort`, contour-based `Stroke`/`FlatShadow`/`PartsSplit`, `AutoClip`, integer `Dilate`/`Erode`, `Mosaic`, Perlin-driven, and custom per-texel shaders **still follow the coordinate-space rule above** (their Skia-`SKImageFilter` args — e.g. the Dilate/Erode radius — ride the CTM unchanged; `PerlinNoiseBrush.BaseFrequency` is left unchanged; only their device-buffer / point-blit code converts `× w` once and contour readback converts `÷ w`), but their reduced-scale preview is a **best-effort approximation** (not bit-identical), full-fidelity only at export `s_out=1.0`. Running supply-driven already keeps a higher-resolution source through them (downsampled only at the final stage). No force-full-scale subtree mechanism and no warning UI in v1. Their tests assert (a) byte-equality at `s_out=1.0` and (b) a documented structural invariant at a reduced scale (e.g. `mosaic tile == ceil(tileSize × w)` device px), not SSIM. -## Reporting a density: `EffectiveScale.At` throws — use `AtOrUnbounded` on the pull path (2026-06-15) +## Reporting a density: `EffectiveScale.At` throws — use `AtOrUnbounded` while recording (2026-06-15) -A custom `RenderNodeOperation` / `EffectiveScale` override that *reports* a supply density (e.g. a plugin op whose density is `sourcePixels / logicalWidth`) must respect the two factories: +A custom `RenderNode` that records a materialized source or maps an input supply (for example, a plugin whose density is `sourcePixels / logicalWidth`) must respect the two factories and use `RenderScaleContract.MapInputSupply` when the result derives from an input: -- **`EffectiveScale.At(scale)` THROWS** (`ArgumentOutOfRangeException`) on a non-finite / non-positive density. A density derived from **animatable geometry** can momentarily go degenerate — `0/0 = NaN` on a collapsed bound, `x/0 = ∞` on an off-screen clip. **The render pull path has no try/catch, so a throw from `At` aborts the whole render** (the export frame, not just that op). -- **`EffectiveScale.AtOrUnbounded(scale)`** is the **non-throwing pull-path factory**: it returns `At(scale)` for a positive-finite density and **degrades a bad density to `Unbounded`** (the safe re-rasterizable default — the op then rasterizes at the consumer's working scale). +- **`EffectiveScale.At(scale)` THROWS** (`ArgumentOutOfRangeException`) on a non-finite / non-positive density. A density derived from **animatable geometry** can momentarily go degenerate — `0/0 = NaN` on a collapsed bound, `x/0 = ∞` on an off-screen clip. A throw while recording or resolving the request aborts the whole render (the export frame, not just that fragment). +- **`EffectiveScale.AtOrUnbounded(scale)`** is the **non-throwing recording factory**: it returns `At(scale)` for a positive-finite density and **degrades a bad density to `Unbounded`** (the safe re-rasterizable default — the fragment then rasterizes at the consumer's working scale). So a plugin override that derives a density from animatable geometry MUST either **pre-guard the quotient** (as `TransformRenderNode.RescaleDensity` does — clamp the factor, then re-check the quotient is finite-positive) **or use `AtOrUnbounded`**. Reserve `At` for densities already proven finite-positive. -Relatedly, `RenderNodeContext` **sanitizes degenerate inputs once at construction** so downstream consumers (effects, particles, 3D) inherit a safe density without re-validating: a degenerate `OutputScale` (`0` / `NaN` / `∞`) becomes `1`, and a degenerate `MaxWorkingScale` (`NaN` / `≤ 0`) becomes `+∞` (no ceiling — it can never NaN-propagate into `w` or pull it to zero). `ResolveWorkingScale` and `ClampWorkingScaleToBufferBudget` harden the same way (a non-finite/non-positive `outputScale` is treated as `1`; a non-finite `w`/bounds passes through unchanged). +Relatedly, `RenderNodeContext` **sanitizes degenerate inputs once at construction** so downstream consumers (effects, particles, 3D) inherit a safe density without re-validating: a degenerate `OutputScale` (`0` / `NaN` / `∞`) becomes `1`, and a degenerate `MaxWorkingScale` (`NaN` / `≤ 0`) becomes `+∞` (no ceiling — it can never NaN-propagate into `w` or pull it to zero). `RenderScaleUtilities.ResolveWorkingScale` and `RenderScaleUtilities.ClampWorkingScaleToBufferBudget` harden the same way (a non-finite/non-positive `outputScale` is treated as `1`; a non-finite `w`/bounds passes through unchanged). ## Mechanism summary -Centralized scaling lives in the `FilterEffectContext` primitives (covers built-ins and their forwarders for free); the per-effect read accessor (`WorkingScale`) is the escape hatch for pixel-reading custom/shader/script effects, and a custom `FilterEffectRenderNode` is the escape hatch for *what scale* the effect runs at. A plugin effect that touches neither still renders correctly at `s_out=1.0` (supply-driven + `Unbounded` inputs → `w=1.0`); it simply runs supply-driven and won't drive oversampling until it adopts this contract. +Centralized scaling lives in the `FilterEffectContext` primitives (covers built-ins and their forwarders for free); `TryGetWorkingScale` is the guarded author-time probe, execution contexts expose operation-specific density for pixel work, and `GetWorkingScaleContract()` is the escape hatch for *what scale* the effect runs at. A plugin effect that touches neither still renders correctly at `s_out=1.0` (supply-driven + `Unbounded` inputs → `w=1.0`); it simply runs supply-driven and won't drive oversampling until it adopts this contract. diff --git a/docs/specs/003-resolution-independent-pipeline/contracts/public-api.md b/docs/specs/003-resolution-independent-pipeline/contracts/public-api.md index 43085fde3e..cdc0ced28d 100644 --- a/docs/specs/003-resolution-independent-pipeline/contracts/public-api.md +++ b/docs/specs/003-resolution-independent-pipeline/contracts/public-api.md @@ -2,6 +2,14 @@ **Feature**: 003 | Consumers: `Beutl.NodeGraph`, `Beutl.Extensibility`, out-of-tree plugin drawables/effects, `Beutl.ProjectSystem`, `Beutl` (editor). +> **Superseded API note (Feature 004):** the symbol table, call-site inventory, and downstream +> migration summary below record the Feature 003 API at the time it shipped. Feature 004 removes +> `RenderNodeOperation`/`RenderNodeProcessor` and revises the filter-effect compatibility surface. +> Use Feature 004's [breaking-change contract](../../004-gpu-pass-fusion/contracts/breaking-changes.md) +> and [public API contract](../../004-gpu-pass-fusion/contracts/public-api.md) for current migration +> instructions. For current author guidance, use +> [Resolution-independent rendering](../../../ai-workflow/resolution-independent-rendering.md). + This feature ships as a **breaking change**: `refactor!:` / `feat!:` with a `BREAKING CHANGE:` footer naming `Beutl.Engine`, `Beutl.NodeGraph`, `Beutl.ProjectSystem`. **No `[Obsolete]` shims** (AGENTS.md); all in-tree call sites updated in the same change. Route through `beutl-design-reviewer` (FR-028). **No file-format change** (FR-001/SC-002). ## Breaking symbols @@ -11,14 +19,14 @@ This feature ships as a **breaking change**: `refactor!:` / `feat!:` with a `BRE | `RenderNodeContext` ctor | `Rendering/RenderNodeContext.cs:3` | `ctor(RenderNodeOperation[] input)` | `ctor(input, float outputScale = 1f, float maxWorkingScale = +∞)` + `float OutputScale { get; }` + `float MaxWorkingScale { get; }` + `static float ResolveWorkingScale(inputs, outputScale, maxWorkingScale)` (FR-036 floor rule `min(max(s_out, supply), max)`, **no `policy` param**). The ctor **sanitizes degenerate inputs once** so downstream consumers (effects, particles, 3D) need not re-validate: a non-finite / non-positive `OutputScale` (`0`/`NaN`/`∞`) → `1`, a `NaN` / non-positive `MaxWorkingScale` → `+∞` (no ceiling). | Sole construction at `RenderNodeProcessor.cs:176`; reaches ~28 `Process` overrides (FR-004) | | `RenderNodeOperation` | `Rendering/RenderNodeOperation.cs:6-64` | 4 static factories, `Bounds`/`Render`/`HitTest` | + `EffectiveScale EffectiveScale { get; }` (**value type**, default `Unbounded`); factories gain `EffectiveScale effectiveScale = default`. **No `LosslessReRasterizable` bool** — `IsUnbounded` subsumes it | Subclasses + `FilterEffectRenderNode.cs:47,73`, `RenderNodeProcessor.cs:102`, `ParticleRenderNode.cs:83`, `SceneDrawable.cs:185` | | `EffectiveScale` (new type) | `Rendering/EffectiveScale.cs` | — | **As shipped:** a non-positional `readonly record struct` with a private inverted `_bounded` flag (NOT the positional `(float Value, bool IsUnbounded)` form — that would make `default` wrongly `At(0)`); `Unbounded`/`At(float)`/**`AtOrUnbounded(float)`**/`IsUnbounded`/`Value`; `default == Unbounded`. **`At(scale)` THROWS** `ArgumentOutOfRangeException` on a non-finite / non-positive density and the render pull path has **no try/catch** (a throw aborts the render), so a plugin `RenderNodeOperation`/`EffectiveScale` override deriving a density from animatable geometry MUST pre-guard the quotient or use the non-throwing **`AtOrUnbounded`** (degrades a bad density to `Unbounded`). | FR-018; produced by flushed effect buffers (`At(w)`), `CreateTarget`, 3D surfaces; consumed by `EffectTarget.Draw` + the op factories | -| ~~`ResolutionPolicy` (new type)~~ **NOT shipped** | — | — | **Removed.** Earlier drafts added a `readonly record struct ResolutionPolicy` (`Inherit`/`ClampToOutput`/`Oversample(k)`, `PreserveSource` already dropped) + a `virtual FilterEffect.ResolutionPolicy`. No built-in needed a non-default value and a custom `FilterEffectRenderNode` is strictly more flexible, so the type, the virtual, the never-added `RenderNode.ResolutionPolicy`, and the `ResolveWorkingScale` `policy` param were all removed. An effect needing a non-supply `w` overrides `Process` in a node from `FilterEffect.Resource.CreateRenderNode()`. | FR-036 | +| ~~`ResolutionPolicy` (new type)~~ **NOT shipped** | — | — | **Removed.** Earlier drafts added a `readonly record struct ResolutionPolicy` (`Inherit`/`ClampToOutput`/`Oversample(k)`, `PreserveSource` already dropped) + a `virtual FilterEffect.ResolutionPolicy`. No built-in needed a non-default value, so the type, the virtual, the never-added `RenderNode.ResolutionPolicy`, and the `ResolveWorkingScale` `policy` param were removed. An effect needing a non-supply `w` overrides `GetWorkingScaleContract()` in a node from `FilterEffect.Resource.CreateRenderNode()`; overriding `Process` is reserved for genuinely different topology/lowering. | FR-036 | | `RenderNodeProcessor` ctor | `Rendering/RenderNodeProcessor.cs:6` | `ctor(RenderNode root, bool useRenderCache)` | `ctor(root, useRenderCache, float outputScale = 1f, float maxWorkingScale = +∞)` + `float OutputScale { get; }` + `float MaxWorkingScale { get; }`; rasterization sinks rasterize at `OutputScale` (`PixelRect.FromRect(bounds, OutputScale)`); the per-input working scale is resolved at `FilterEffectRenderNode` | `Renderer.cs:214`, `NodeGraphFilterEffectRenderNode.cs:45`, `ParticleRenderNode.cs:144` | | `Renderer` ctor | `Rendering/Renderer.cs:45` | `ctor(int width, int height)` | `ctor(int width, int height, float renderScale = 1f, float maxWorkingScale = float.PositiveInfinity)` (the output scale `s_out` + the FR-037 ceiling) + `OutputScale`/`DeviceSize` getters; width/height **logical**, surface `ceil(FrameSize×s_out)` | FR-003/FR-026/FR-037; `SceneDrawable.cs:181`, `EditViewModel.cs:75`, `OutputViewModel.cs:283` | | `IRenderer` | `Rendering/IRenderer.cs:7` | — | + `float OutputScale { get; }`, + `PixelSize DeviceSize { get; }` (default-interface-impl → `1f`/`FrameSize` to soften third-party impls, mirroring `GetBoundary` default at `:30`) | third-party `IRenderer` implementers | | `SceneRenderer` ctor | `ProjectSystem/SceneRenderer.cs:10` | `ctor(Scene scene, bool disableResourceShare = false)` | `ctor(Scene scene, float renderScale = 1f, bool disableResourceShare = false, float maxWorkingScale = float.PositiveInfinity)` | `EditViewModel.cs:75` (passes `maxWorkingScale: 2f * s_out`), `OutputViewModel.cs:283` (passes `+∞` — export imposes no working-scale quality ceiling, *amended 2026-06-15*; was `max(8, 4 × s_out)`) | | `GraphicsContext2D` ctor + `Size` | `Rendering/GraphicsContext2D.cs:9` | `ctor(ContainerRenderNode, PixelSize canvasSize = default)`; `PixelSize Size` | **As shipped:** `ctor(ContainerRenderNode, Size canvasSize = default, float outputScale = 1f)` + `OutputScale`; **`Size` is now an exact logical `Size`** (float), not a rounded `PixelSize` — it feeds `Drawable.Render`'s `MeasureCore`/`GetTransformMatrix` where a fractional viewport changes placement, so readers drop the `.ToSize(1)`. `DrawBackdrop` uses `new Rect(canvasSize)` | FR-021; **most-consumed changed ctor** — full call-site list below | | `ImmediateCanvas` / `ICanvas` (**density-aware logical surface**) | `Graphics/ImmediateCanvas.cs`, `Graphics/ICanvas.cs` | `ctor(RenderTarget, float outputScale = 1f, float maxWorkingScale = +∞)`; `PixelSize Size`; `float OutputScale`; public `Matrix Transform { get; set; }` | **As shipped (base-CTM redefinition):** the canvas **bakes** the base CTM `CreateScale(density)` at construction (true no-op at density 1). `ctor(RenderTarget, float density = 1f, float maxWorkingScale = +∞, Size logicalSize = default)`; `ICanvas.Size` → **`LogicalSize` (`Size`) + `DeviceSize` (`PixelSize`)**; `OutputScale` → **`SurfaceDensity`** (immutable, drives base CTM + snapshot capture) + **`Density`** (current, push/pop, drives brush fills + nested pulls); new **`PushDeviceSpace()`** (CTM → identity, density → 1) on `ICanvas`; `Transform` **setter is `internal`** (getter stays public and now includes the base) | feature 003 base-CTM redefinition; all `FilterEffect`/`CustomEffect` authors + `ICanvas` implementers | -| `FilterEffectContext` | `FilterEffects/FilterEffectContext.cs:39` | no scale | + ctor `(outputScale, workingScale)` + `float WorkingScale { get; }` + `float OutputScale { get; }`; **CustomEffect point-blit** code × **`WorkingScale`**, Skia `SKImageFilter` primitives ride the `CreateScale(w)` CTM (NOT multiplied) | FR-009/FR-015; `FilterEffectRenderNode.cs:30` | +| `FilterEffectContext` | `FilterEffects/FilterEffectContext.cs:39` | no scale | + ctor `(outputScale, workingScale)` + `bool TryGetWorkingScale(out float)` + `float WorkingScale { get; }` + `float OutputScale { get; }`; author-time working scale is unavailable for symbolic/branch-dependent inputs (`TryGet... == false`, getter throws), and available values are nominal before operation-specific bounds clamps; **CustomEffect point-blit** code uses its execution-time density, while Skia `SKImageFilter` primitives ride the `CreateScale(w)` CTM (NOT multiplied) | FR-009/FR-015; `FilterEffectRenderNode.cs:30` | | `CustomFilterEffectContext` | `FilterEffects/CustomFilterEffectContext.cs` | `CreateTarget` `(int)bounds.W/H` | + `float WorkingScale { get; }` + `float OutputScale { get; }` (forwarded for nested re-application); `CreateTarget` `ceil(bounds×WorkingScale)`; **`Open` returns a canvas with the baked base CTM `CreateScale(target.Scale.Value)`** (the author no longer pushes `CreateScale(WorkingScale)`); `DeviceBufferSize(bounds, w)` is public so custom effect authors can compute shader/device uniforms with the same scale-1.0-sensitive rounding as `CreateTarget` | FR-009/FR-015; migrate off `(int)` cast (scale-1.0-sensitive) | | `FilterEffectActivator` | `FilterEffects/FilterEffectActivator.cs` | `ctor(targets, builder, workingScale = 1f)` | + `float outputScale = 1f` **and `float maxWorkingScale = +∞`** ctor params + `OutputScale` / `MaxWorkingScale` getters (forwarded into the nested `FilterEffectContext`/`CustomFilterEffectContext` so nested pulls stay under the FR-037 ceiling); `Flush` sizes `ceil(×w)`, the **flatten canvas bakes the base CTM `CreateScale(w)`** (translation-only push), tags buffers `At(w)` | FR-009/FR-015/FR-019/FR-037; scale-1.0-sensitive | | `EffectTarget` | `FilterEffects/EffectTarget.cs:6` | `Empty`/`Size` (obsolete) | + `EffectiveScale Scale { get; set; }` (default `Unbounded`); **remove** `Empty`/`Size` | FR-019; LayerEffect mixed-scale detection (dossier §4.5) | diff --git a/docs/specs/003-resolution-independent-pipeline/contracts/shader-uniforms.md b/docs/specs/003-resolution-independent-pipeline/contracts/shader-uniforms.md index 8e2637557b..626f68f668 100644 --- a/docs/specs/003-resolution-independent-pipeline/contracts/shader-uniforms.md +++ b/docs/specs/003-resolution-independent-pipeline/contracts/shader-uniforms.md @@ -14,9 +14,13 @@ Existing uniforms **keep their device-pixel meaning** = the size of the *scaled* |---|---|---| | `width`, `height` | target size, device px | `ceil(logicalBounds.W/H × w)` — smaller at reduced preview, larger when oversampled | | `iResolution` | `(width, height)` — a 2-component `float2` (bound as an `SKPoint`, `SKSLScriptEffect.cs`); declare it `uniform float2 iResolution`, NOT `float3` | as above | -| `fragCoord` | device pixel coord | ranges over the scaled target | +| `fragCoord` | device pixel coord | spans `[0, iResolution]` over the effect's **complete** output, independent of the region the renderer was asked for | | **`iScale`** *(new)* | working scale `w` | `w` (default `1.0`) | +A whole-source stage is evaluated over its complete output even when only part of it is required, so +`fragCoord / iResolution` is a true normalized coordinate and an absolute anchor (a mirror axis, a tile +grid origin) stays put when the renderer clips the request to the frame. + Author rule: a UV-normalized shader (`fragCoord / iResolution`) auto-corrects across scales; a shader with an absolute pixel literal multiplies it by `iScale`, e.g. `float radius = 10.0 * iScale;`. Per-texel kernels (blur/edge/sharpen) are inherently resolution-sensitive (FR-013): reduced-scale preview is best-effort, full fidelity at export (`w=1`, i.e. `s_out=1.0` over a unit-scale input). Migration rule for existing SKSL scripts: if pre-003 code treated `width`, `height`, `iResolution`, or diff --git a/docs/specs/003-resolution-independent-pipeline/data-model.md b/docs/specs/003-resolution-independent-pipeline/data-model.md index 1ee39c2bc7..0105532d9d 100644 --- a/docs/specs/003-resolution-independent-pipeline/data-model.md +++ b/docs/specs/003-resolution-independent-pipeline/data-model.md @@ -21,9 +21,9 @@ The user-facing preview scale selection. Lives in `Beutl` (editor) or `Beutl.Eng ### Render scales (`float`) *(supply-driven — three scales)* All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for the FR-006 widening path): -- **`s_out`** (output scale) — render-request final target only (`RenderNodeContext.OutputScale`); never clamps an intermediate (FR-036). +- **`s_out`** (output scale) — render-request final target only (`RenderNodeContext.OutputScale`); never upper-clamps a denser intermediate, and floors only the standard `MaterializeAtWorkingScale` policy (FR-036). - **`e`** (effective scale) — per-op supply density (`EffectiveScale`, below). -- **`w`** (working scale) — computed per buffer-allocating boundary via `ResolveWorkingScale` (FR-036); the scale an effect runs at — device-buffer dimensions and device-space shader uniforms convert once (`× w`), logical-space geometry rides the CTM unchanged, readback geometry converts back (`÷ w`) (FR-008). +- **`w`** (working scale) — computed for a standard buffer-allocating boundary via `ResolveWorkingScale`, or selected by an explicit custom filter scale contract (FR-036); the scale an effect runs at — device-buffer dimensions and device-space shader uniforms convert once (`× w`), logical-space geometry rides the CTM unchanged, readback geometry converts back (`÷ w`) (FR-008). > **Glossary (naming)**: `Renderer.OutputScale` (on the renderer) `==` `RenderNodeContext.OutputScale` (on the context) `== s_out` — the same render-request output scale under two names (the context calls it `OutputScale` to stress it is *not* the working scale). The editor-facing **`RenderScale` enum** (`Full`/`Half`/`Quarter`/`FitToPreviewer`, FR-035) is a **distinct type** that *resolves to* `s_out` via `ToFloat`. @@ -33,18 +33,19 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th |---|---| | `Unbounded` (static) | vector/lossless op — re-rasterizable at any target; **excluded from the supply `max`**. `default(EffectiveScale) == Unbounded` (byte-identity anchor: a plugin op ignoring the new param is safe). | | `At(float scale)` (static) | a concrete bitmap density. | -- **Replaces** the first draft's separate `RenderNodeOperation.LosslessReRasterizable` bool — `IsUnbounded` subsumes it (one concept, one member; no contradictory "lossless but `e=2.0`" state). +- **Historical migration note**: the first draft attached `LosslessReRasterizable` to the now-removed `RenderNodeOperation`. `EffectiveScale.IsUnbounded` subsumes that distinction in the recorded-fragment pipeline (one concept, one member; no contradictory "lossless but `e=2.0`" state). -### Working-scale rule (FR-036) — no `ResolutionPolicy` type -**There is no resolution-policy value type.** Every buffer-allocating boundary runs at the **densest concrete supply, floored at `s_out`**, capped only by the global ceiling. The one rule (amended 2026-06-15: `s_out` floors **every** boundary, not only the vector-only/mixed cases) is: +### Working-scale contracts (FR-036) — no `ResolutionPolicy` type +**There is no closed resolution-policy value type.** Every built-in/default filter-effect materialization starts with `MaterializeAtWorkingScale`: it runs at the **densest concrete supply, floored at `s_out`**, then is capped by the global ceiling and clamped against the concrete allocation footprint. The one standard rule (amended 2026-06-15: `s_out` floors **every standard materializing** boundary, not only the vector-only/mixed cases) is: -`RenderNodeContext.ResolveWorkingScale(ReadOnlySpan inputs, float outputScale, float maxWorkingScale = +∞) → float`: +`RenderScaleUtilities.ResolveWorkingScale(ReadOnlySpan inputs, float outputScale, float maxWorkingScale = +∞) → float`: - `supply = outputScale` (the floor), then `supply = max(supply, e.Value)` over each **concrete** (non-`Unbounded`) input. - return `min(supply, maxWorkingScale)`. -- Equivalently: `w = min( max(s_out, densest concrete supply), maxWorkingScale )`. `s_out` is the **floor**, **never** a ceiling — a denser concrete supply runs above it (FR-016), and a sub-output concrete supply (`At(0.5)` at a `1.0` export) is lifted to `s_out`. The former special-cased "vector-only fallback" and "C4/C5 mixed bitmap+vector floor at `s_out`" are now instances of this universal floor (conclusions unchanged — they still land at `s_out`). At `s_out = 1.0` with unit-scale / vector inputs `w = max(1, 1) = 1`, so byte-identity is untouched. +- Equivalently for `MaterializeAtWorkingScale`: `w = min( max(s_out, densest concrete supply), maxWorkingScale )`. Within this standard policy, `s_out` is the **floor**, **never** an upper ceiling — a denser concrete supply runs above it (FR-016), and a sub-output concrete supply (`At(0.5)` at a `1.0` export) is lifted to `s_out`. The former special-cased "vector-only fallback" and "C4/C5 mixed bitmap+vector floor at `s_out`" are instances of this standard floor (conclusions unchanged — they still land at `s_out`). At `s_out = 1.0` with unit-scale / vector inputs `w = max(1, 1) = 1`, so byte-identity is untouched. -- **`ResolutionPolicy` removed (and `FilterEffect.ResolutionPolicy`, `RenderNode.ResolutionPolicy`)**: earlier drafts declared a per-effect policy (`Inherit` / `ClampToOutput` / `Oversample(k)` / `PreserveSource`) to pick `w`. No built-in ever needed a non-default value (all are supply-driven), and a custom `FilterEffectRenderNode` (from `FilterEffect.Resource.CreateRenderNode()`, overriding `Process` to compute `w` directly) is more flexible than a closed three-value enum. So the policy enum, `virtual FilterEffect.ResolutionPolicy`, `RenderNode.ResolutionPolicy` (never added — a dead duplicate), the `policy` parameter of `ResolveWorkingScale`, and the earlier `PreserveSource` floor / `preserveFloor` channel were all removed. -- **As shipped: the FR-037 ceiling IS wired** — `FilterEffectRenderNode` passes `context.MaxWorkingScale`; the editor preview seeds it at `2 × s_out` and **export seeds `+∞`** (no working-scale quality ceiling — *amended 2026-06-15*; the earlier finite `max(8, 4 × s_out)` was removed as a quality clip, see FR-037 / `WorkingScaleCeiling.Export` / `OutputViewModel`). The preview ceiling is the sole **global** upper bound on `w`. Separately, the per-buffer **dimension** clamp (FR-037(b), `RenderNodeContext.ClampWorkingScaleToBufferBudget`, 16384 px per axis — applied at the `FilterEffectRenderNode` node level and re-applied per target in `FilterEffectActivator.Flush` against the post-effect-inflated bounds) is the sole **allocatability** bound and may further reduce `w` at an effect boundary to keep the buffer allocatable. Two distinct bounds — do not conflate them (FR-037). +- **Explicit custom filter contract**: `FilterEffectRenderNode.GetWorkingScaleContract()` returns `null` for the standard policy. An override may return `RenderScaleContract.Custom`; its resolver MUST return a finite value greater than zero and MAY intentionally return a density below `s_out`. That value is not raised to the standard floor, but it is capped by `MaxWorkingScale` and clamped against each concrete allocation footprint's 16 384-pixel axis limit. Invalid values fail instead of falling back to `s_out`. No built-in uses this hook. +- **`ResolutionPolicy` removed (and `FilterEffect.ResolutionPolicy`, `RenderNode.ResolutionPolicy`)**: earlier drafts declared a per-effect policy (`Inherit` / `ClampToOutput` / `Oversample(k)` / `PreserveSource`) to pick `w`. No closed policy type was needed, so the enum, `virtual FilterEffect.ResolutionPolicy`, `RenderNode.ResolutionPolicy` (never added — a dead duplicate), the `policy` parameter of `ResolveWorkingScale`, and the earlier `PreserveSource` floor / `preserveFloor` channel were removed. The narrow escape hatch is `FilterEffectRenderNode.GetWorkingScaleContract()` from a node returned by `FilterEffect.Resource.CreateRenderNode()`; overriding `Process` is reserved for genuinely different topology/lowering. +- **As shipped: the FR-037 ceiling IS wired** — `FilterEffectRenderNode` passes `context.MaxWorkingScale`; the editor preview seeds it at `2 × s_out` and **export seeds `+∞`** (no working-scale quality ceiling — *amended 2026-06-15*; the earlier finite `max(8, 4 × s_out)` was removed as a quality clip, see FR-037 / `WorkingScaleCeiling.Export` / `OutputViewModel`). The preview ceiling is the sole **global** upper bound on `w`. Separately, the per-buffer **dimension** clamp (FR-037(b), `RenderScaleUtilities.ClampWorkingScaleToBufferBudget`, 16384 px per axis — applied at the `FilterEffectRenderNode` node level and re-applied per target in `FilterEffectActivator.Flush` against the post-effect-inflated bounds) may further reduce `w` at an effect boundary. It is only a per-axis safeguard: aggregate byte/area/live-buffer budgeting and backend-reported limits remain out of scope, so it is not a complete OOM or allocatability guarantee. Two distinct bounds — do not conflate them (FR-037). ### Shared rounding helper (FR-007) **Decision: no new helper type — the canonical helper *is* `PixelRect.FromRect(Rect, float scale)` / `PixelSize.FromSize(Size, float)`** (`PixelRect.cs:391`, `PixelSize.cs:209`), which already "ceil sizes (ceil'd bottom-right), toward-zero origins". The work is *adopting* the `× w` scaling at every sink with a consistent convention, not writing a new helper. @@ -54,40 +55,25 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th ## Changed core render-graph types -### `RenderNodeContext` *(changed)* — `Graphics/Rendering/RenderNodeContext.cs` -| Member | Change | Rule | -|---|---|---| -| `OutputScale` | **+ `float OutputScale { get; }`** (get-only, default `1f`; renamed from the first draft's `Scale`) | The render-request final target `s_out` (D1). Seeded once in `RenderNodeProcessor.Pull` from `RenderNodeProcessor.OutputScale`; propagated like `IsRenderCacheEnabled`. **Consumed only at the root final stage and as the fallback/floor term of `ResolveWorkingScale` — never as an intermediate's working scale.** Get-only. | -| `ResolveWorkingScale` | **+ `static float ResolveWorkingScale(ReadOnlySpan inputs, float outputScale, float maxWorkingScale = +∞)`** (static only — no instance overload, **no `policy` parameter**) | The one supply-driven working-scale rule (`min( max(s_out, densest concrete supply), maxWorkingScale )` — `s_out` is the floor, FR-036) incl. the global ceiling (FR-037), which `FilterEffectRenderNode` supplies via `context.MaxWorkingScale` (preview `2 × s_out`, export `+∞`). The `policy` and `preserveFloor` parameters were removed with the `ResolutionPolicy` type. | +Feature 004 replaced the executable operation pipeline after feature 003 shipped. The active scale contract is carried by the recorded pipeline below; names from the original feature-003 implementation are retained only in the historical note at the end of this section. -### `RenderNodeOperation` *(changed)* — `Graphics/Rendering/RenderNodeOperation.cs` -| Member | Change | Rule | -|---|---|---| -| `EffectiveScale` | **+ `EffectiveScale EffectiveScale { get; }`** (read-only value type, default `Unbounded`) | The supply density `e` (D1). `Unbounded` = vector/lossless (regenerate at any `w`); `At(s)` = concrete bitmap density. Set for bitmap-backed ops (`CreateFromRenderTarget`/`CreateFromSurface`, cached tiles, decoded media, 3D & nested-scene surfaces) = their `w`. | -| factory params | factories (`CreateLambda`/`CreateFromRenderTarget`/`CreateFromSurface`/`CreateDecorator`) gain `EffectiveScale effectiveScale = default` (default = `Unbounded`) | | +### `RenderNodeContext` *(changed again by feature 004)* — `Graphics/Rendering/RenderNodeContext.cs` -- **`LosslessReRasterizable` (bool) is removed** — `EffectiveScale.IsUnbounded` subsumes it (one concept, one member). -- **Relationships**: each buffer-allocating boundary resolves `targetScale = max(concrete input EffectiveScale)` via `RenderNodeContext.ResolveWorkingScale` (Unbounded inputs excluded); off-target bitmap ops are Mitchell-resampled and `Unbounded` ops re-rasterized at `targetScale`, once at the `DrawSurface`/`DrawRenderTarget` blit (FR-017). Reconciliation is **distributed across the boundaries, not a central composite pass** (FR-016 clarification 2026-06-10). The cap to `s_out` is **deferred to the root** (FR-016/FR-036). -- **Non-abstract (compat-critical)**: `EffectiveScale` is a **non-abstract** member defaulting to `Unbounded` (via the base ctor / factory param) — **never `abstract`**. The base already has three abstract members (`Bounds`/`Render`/`HitTest`, `RenderNodeOperation.cs:11-15`); an *abstract* scale member would break every in-tree subclass (the private `LambdaRenderNodeOperation`) **and every out-of-tree plugin op**. With the `Unbounded` default, a plugin op that ignores scale re-rasterizes at `w` and is byte-identical at `s_out=1.0`. -- **SaveLayer-based containers carry NO scale**: `OpacityRenderNode`/`BlendModeRenderNode`/`OpacityMaskRenderNode` are `CreateDecorator` wrappers that `PushOpacity`/`PushBlendMode`/`SaveLayer` at **render time** (`OpacityRenderNode.cs:21-28`, `ImmediateCanvas.cs:369-377`); they do **not** allocate a node-owned `RenderTarget` from `RenderNodeContext`, so they need no scale field: the `SaveLayer` captures at the current device CTM (which already carries the root `s`), and any genuinely mixed-scale child is resampled at *its own* `DrawSurface`/`DrawRenderTarget` blit inside the layer (FR-017). Only nodes that **allocate** an intermediate from `RenderNodeContext` (filter targets, brush/tile intermediates, cache tiles, nested-scene/3D surfaces) carry and consume scale. +`RenderNodeContext` is now the sealed, engine-created transaction recorder for one `void RenderNode.Process(RenderNodeContext)` call. `OutputScale` and `MaxWorkingScale` come from the current `RenderRequestOptions`; the context records descriptions and publishes ordered `RenderFragmentHandle` streams but never executes or owns an operation. Working-scale helpers live on the independent `RenderScaleUtilities` type so planning, brushes, 3D, and export policy use the same rule without a recorder instance. -### `RenderNodeProcessor` *(changed)* — `Graphics/Rendering/RenderNodeProcessor.cs` -| Member | Change | Rule | -|---|---|---| -| ctor | **+ `float outputScale = 1f`**; **+ `float OutputScale { get; }`** | Seeded from `Renderer`. | -| rasterization sinks (`RasterizeAt`/`Rasterize`/`RasterizeAndConcat`) | rasterize at `w = OutputScale` (the root / cache / thumbnail sinks operate at the request's `s_out`, not a per-input negotiated scale); `PixelRect.FromRect(op.Bounds, w)`; the `ImmediateCanvas` **bakes the base CTM `CreateScale(w)`** at construction (the sink only pushes a translation-only matrix). **`w == 1` short-circuit**: a true no-op base (no scale matrix, no Save), preserving byte-identity. The per-input supply-driven working scale (FR-036) is resolved at the effect boundary (`FilterEffectRenderNode`), not here. | Identity at `w=1.0` → byte-equal. | -| `Pull` (`:167`) | `new RenderNodeContext(input, OutputScale, MaxWorkingScale)` | Single production construction site; reaches all overrides. | -| `RasterizeAt(op, w)` | **+ internal seam** generalizing `RasterizeToRenderTargets` (`:20-44`) to re-rasterize an `Unbounded` subtree at a chosen `w` | feeds FR-017 regenerate. | +### `RenderFragmentHandle` *(feature-004 replacement)* — `Graphics/Rendering/RenderFragmentHandle.cs` + +The non-executable, non-disposable handle carries fragment cardinality, contribution, and value-input eligibility. `TryGetMetadata(out RenderFragmentMetadata)` exposes the resolved recording-time `(Bounds, EffectiveScale)` pair only when it is concrete; owning-target-dependent metadata remains symbolic until graph-wide analysis. Concrete values preserve `EffectiveScale.At(w)`, while vector/lossless values use `EffectiveScale.Unbounded`. Materialization and density reconciliation are recorded declaratively and performed later by the planner/executor. + +### `RenderNodeRenderer` *(feature-004 replacement)* — `Graphics/Rendering/RenderNodeRenderer.cs` + +`RenderNodeRenderer` owns repeated complete-request recording, metadata/ROI analysis, cache substitution, execution planning, target pooling, and execution. Its options seed `OutputScale`, `MaxWorkingScale`, intent, requested region, and cache policy. `Rasterize()` returns one owned `RenderNodeRasterization`; there is no public pull of executable operations. ### `RenderNodeCache` — `Graphics/Rendering/Cache/RenderNodeCache.cs` -**Minimally changed in 003 (multi-scale REUSE deferred — T025 `[~]`).** FR-020 scale consistency rests on a density-aware minimal fix (2026-06-11) plus manager-level invalidation: the cache helper receives the renderer's `(OutputScale, MaxWorkingScale)`, rasterizes the cache tiles at that density (forwarding the ceiling), records the creation density, and cache **replay re-tags the tiles with that density** so a downstream boundary reconciles them like any other concrete-density input — a tile is never blitted 1:1 at the wrong density. **I4 fix (2026-06-15):** because the cache rasterizes at `outputScale`, a subtree whose output carries a concrete supply density **above** `outputScale` (a transform-densified high-resolution source — `At(4)` on a 1080 timeline) would collapse that detail into the `outputScale` tile and re-tag it `At(outputScale)`, silently lowering a downstream effect's working scale once the (render-count-driven) cache kicks in — an FR-018 violation. `CreateDefaultCache` therefore **refuses to cache** any such subtree (keeping it uncached at its true supply density); the density-preserving alternative (rasterize each tile at its own working scale + a per-tile density) is the deferred T025 reuse work below. Additionally, `EditViewModel` rebuilds a fresh `SceneRenderer` **and** `FrameCacheManager` when the resolved `(FrameSize, OutputScale)` changes (`DistinctUntilChanged` + `DisposePreviousValue`; two independent UI-thread swaps — FR-031), and the per-renderer `RenderNode` cache is discarded with its renderer. The supply-aware **reuse** below was specced but **not shipped**: -> *Deferred (supply-aware reuse, defense-in-depth):* a `CachedWorkingScale` on `RenderNodeCache` + a `workingScale` param on `StoreCache` + `RenderCacheRules.Match` thresholds `÷ CachedWorkingScale²`, so an SSAA export could **reuse** a high preview cache (Mitchell-downsample) and **miss** only when it lacks detail (D6). Lands with the scale-keyed reuse work in a follow-up, not 003. (`RenderNodeCacheHelper.CreateDefaultCache` no longer builds its cache processor scale-blind as `new RenderNodeProcessor(node, false)`; it passes the renderer's `(OutputScale, MaxWorkingScale)` and the replayed tiles carry their creation density — but a tile is still never *reused across* scales.) +Cache lookup and publication now occur after complete-request metadata and density demands are known. Cached values retain their concrete `EffectiveScale`; reuse is accepted only when the retained density satisfies the resolved demand, and the final blit reconciles that supply with the consuming target. -### `ImmediateCanvas` *(changed)* — `Graphics/ImmediateCanvas.cs` -| Member | Change | Rule | -|---|---|---| -| `DrawSurface`/`DrawRenderTarget` (`:106-126`) | **+ `(src, dest, SKSamplingOptions)` resample path** via `Canvas.DrawImage(...,Mitchell)` for the FR-017 mixed-scale blit. **Branch on exact `srcScale == destScale` → today's bare 1:1 `Canvas.DrawSurface(...,paint)`** (byte-identity); only the `≠` case routes through the resampler. | FR-017; byte-identity-critical short-circuit. | +> **Historical feature-003 implementation:** the first implementation represented each result as an executable `RenderNodeOperation` and drove it through `RenderNodeProcessor`. Feature 004 removed both public surfaces rather than keeping compatibility wrappers. Any operation factories, `Pull`/`RasterizeAt`, processor-owned cache behavior, or `RenderNodeOperation.EffectiveScale` described in earlier revisions are historical implementation details, not active APIs. --- @@ -129,7 +115,7 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th ### `FilterEffectContext` *(changed)* — `Graphics/FilterEffects/FilterEffectContext.cs` | Member | Change | Rule | |---|---|---| -| ctor | **+ `float outputScale, float workingScale`** | `workingScale` = the negotiated `w` (from `FilterEffectRenderNode` via `ResolveWorkingScale`); `outputScale` = `s_out`. | +| ctor | **+ `float outputScale, float workingScale`** | `workingScale` = the negotiated `w` from `FilterEffectRenderNode`'s standard or explicit custom contract; `outputScale` = `s_out`. | | `WorkingScale` | **+ `float WorkingScale { get; }`** | FR-015 read accessor — the `w` the effect runs at. | | `OutputScale` | **+ `float OutputScale { get; }`** | the eventual delivery target, for effects that need it. | | Skia `SKImageFilter` primitives (Blur/DropShadow/Dilate/Erode/MatrixConvolution/Transform) | **NOT** multiplied by `WorkingScale` — they ride the `CreateScale(w)` CTM in `FilterEffectActivator.Flush`, so Skia scales their params for free; multiplying here would double-scale. Only **CustomEffect point-blit** code (InnerShadow, Mosaic, ColorShift, …) multiplies absolute-length args by `WorkingScale` | FR-009. | @@ -141,7 +127,7 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th | `CreateTarget(Rect)` | size `ceil(bounds × WorkingScale)` for `w ≠ 1.0`, keeping component-wise `(int)` at `w = 1.0` (byte-identity); `Open` returns a canvas with the **baked base CTM `CreateScale(density)`** where `density = target.Scale.Value`, or `WorkingScale` when the target is `Unbounded` (e.g. a plugin-built target with no Scale set); the author draws logical content directly (no manual prescale) | FR-009/FR-007. | ### `FilterEffectActivator` *(changed)* — `Graphics/FilterEffects/FilterEffectActivator.cs` -`Flush` (`:23`) sizes targets `ceil(OriginalBounds × w)` for `w ≠ 1.0`, **keeping the current component-wise `(int)Width`/`(int)Height` truncation at `w = 1.0`** (byte-identity); the flatten `ImmediateCanvas` **bakes the base CTM `CreateScale(w)`** (the flush pushes a translation-only matrix) and tags each flushed buffer `EffectiveScale.At(w)`. `w`, `s_out` **and `maxWorkingScale`** are supplied to the ctor (from `FilterEffectRenderNode` via `ResolveWorkingScale`), not derived from the targets, and exposed as `OutputScale` / `MaxWorkingScale` getters forwarded into the nested `FilterEffectContext`/`CustomFilterEffectContext` (so nested pulls stay under the request's FR-037 ceiling). Scale-1.0-sensitive (golden-tested). +`Flush` (`:23`) sizes targets `ceil(OriginalBounds × w)` for `w ≠ 1.0`, **keeping the current component-wise `(int)Width`/`(int)Height` truncation at `w = 1.0`** (byte-identity); the flatten `ImmediateCanvas` **bakes the base CTM `CreateScale(w)`** (the flush pushes a translation-only matrix) and tags each flushed buffer `EffectiveScale.At(w)`. `w`, `s_out` **and `maxWorkingScale`** are supplied to the ctor (from `FilterEffectRenderNode` after its standard or explicit custom contract and allocation-footprint clamp), not derived from the targets, and exposed as `OutputScale` / `MaxWorkingScale` getters forwarded into the nested `FilterEffectContext`/`CustomFilterEffectContext` (so nested pulls stay under the request's FR-037 ceiling). Scale-1.0-sensitive (golden-tested). ### `EffectTarget` *(changed)* — `Graphics/FilterEffects/EffectTarget.cs` | Member | Change | Rule | @@ -149,7 +135,7 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th | `Scale` | **+ `EffectiveScale Scale { get; set; }`** (default `Unbounded`) | Per-intermediate supply density, set from the producing op's `e`, so divergent-scale inputs normalize to `w` before a shared filter/flatten (FR-019; LayerEffect/DelayAnimation/InnerShadow/Blend/Mosaic). Propagated through `Clone`/flush re-wrap. | | `Empty`/`Size` | **removed** (obsolete) | Per AGENTS.md no-shim policy. | -`EffectTargets`: no scale accessor — `w` is resolved once by `RenderNodeContext.ResolveWorkingScale` and threaded through the activator, so the targets do not derive it. (Earlier drafts' `MaxScale()`/`ResolveScale(...)` were both dropped.) `CalculateBounds` (`:27`) stays logical (scale-invariant). +`EffectTargets`: no scale accessor — `w` is selected once by `FilterEffectRenderNode` through the standard or explicit custom contract and threaded through the activator, so the targets do not derive it. (Earlier drafts' `MaxScale()`/`ResolveScale(...)` were both dropped.) `CalculateBounds` (`:27`) stays logical (scale-invariant). --- @@ -190,7 +176,7 @@ All `float` for v1 (the `Beutl.Graphics.Vector` primitive overloads exist for th | `EffectiveScale` (value) | FR-018 per-op supply density | `Unbounded`, `At(float)`, `Value`, `IsUnbounded` | | `MaxWorkingScale` | FR-037 ceiling, threaded `Renderer → RenderNodeContext` | **preview `2 × s_out`, export `+∞`** (no export quality ceiling — *amended 2026-06-15*) | -*(No `ResolutionPolicy` type and no `FilterEffect.ResolutionPolicy` — removed; the working scale is supply-driven, and an effect that needs a different one overrides `Process` in a custom `FilterEffectRenderNode`. See FR-036.)* +*(No `ResolutionPolicy` type and no `FilterEffect.ResolutionPolicy` — removed. The default working scale is supply-driven through `MaterializeAtWorkingScale`; an effect that needs a different one overrides `GetWorkingScaleContract()` in a custom `FilterEffectRenderNode`, where an explicit `RenderScaleContract.Custom` may choose a finite positive density below `s_out` before the common ceiling and footprint clamp. `Process` remains the escape hatch for genuinely different topology/lowering. See FR-036.)* --- diff --git a/docs/specs/003-resolution-independent-pipeline/plan.md b/docs/specs/003-resolution-independent-pipeline/plan.md index eb7ae295a9..4d94e5efd2 100644 --- a/docs/specs/003-resolution-independent-pipeline/plan.md +++ b/docs/specs/003-resolution-independent-pipeline/plan.md @@ -6,7 +6,7 @@ ## Summary -Thread render scale through Beutl's 2D render-node tree so the *same project* renders at different resolutions: reduced-scale preview for cheap editing, full-scale (or supersampled) export for delivery — the foundation for a future proxy/optimized-media workflow. Today `1 logical unit == 1 device pixel` is hard-wired (`ToSize(1)`, `(int)bounds.Width`, `PixelRect.FromRect(bounds)`); the feature makes all drawable/effect properties **logical** and **supply-driven**: the render request carries an **output scale `s_out`** (the *final* target only); each operation carries an **`EffectiveScale`** (the density its pixels exist at; vector = `Unbounded`); each effect computes a **working scale `w`** from its inputs, supply-driven above an `s_out` floor: `w = min( max(s_out, densest concrete supply), MaxWorkingScale )` (a 2.0 source stays 2.0; a sub-output `At(0.5)` supply is floored up to `s_out`; `s_out` never *clamps* an intermediate from above — a denser supply runs higher (FR-016) — but an effect never runs below `s_out`; *amended 2026-06-15, the earlier "a 0.5 proxy stays 0.5" wording is superseded*; no per-effect policy — an effect needing a different `w` overrides `Process` in a custom `FilterEffectRenderNode`). It applies the FR-008 coordinate-space rule at `w`: logical-space geometry under the CTM is unchanged, device-buffer dimensions and device-space shader uniforms convert once (`× w`), readback-derived geometry converts back (`÷ w`). The root surface becomes `ceil(FrameSize × s_out)` with one `Matrix.CreateScale(s_out)`; an op whose `e ≠ s_out` is resampled once at the final-stage blit. A global ceiling `MaxWorkingScale` bounds **`w`**, not buffer memory — memory scales `area × w²` and is unbounded by design (FR-037); a separate per-buffer dimension clamp `ClampWorkingScaleToBufferBudget` bounds the per-axis device size. **At `s_out = 1.0`, vector / Skia-filter / unscaled-bitmap output is byte-identical to today** (the regression anchor for that content). *(Amended 2026-06-08: byte-identity is no longer a universal design constraint — the density model is now coherent, so a transform re-scales a bitmap's density and a scaled bitmap into an effect is intentionally not byte-identical; see FR-019 and the requirements.md amendment log.)* Design decisions (D1–D7) in [research.md](./research.md); types in [data-model.md](./data-model.md); breaking surface and author contracts in [contracts/](./contracts/). +Thread render scale through Beutl's 2D render-node tree so the *same project* renders at different resolutions: reduced-scale preview for cheap editing, full-scale (or supersampled) export for delivery — the foundation for a future proxy/optimized-media workflow. Today `1 logical unit == 1 device pixel` is hard-wired (`ToSize(1)`, `(int)bounds.Width`, `PixelRect.FromRect(bounds)`); the feature makes all drawable/effect properties **logical** and **supply-driven**: the render request carries an **output scale `s_out`** (the *final* target only); each operation carries an **`EffectiveScale`** (the density its pixels exist at; vector = `Unbounded`); each built-in/default filter materialization uses `MaterializeAtWorkingScale` to compute a **working scale `w`** from its inputs above an `s_out` floor: `w = min( max(s_out, densest concrete supply), MaxWorkingScale )` (a 2.0 source stays 2.0; a sub-output `At(0.5)` supply is floored up to `s_out`; `s_out` never *clamps* an intermediate from above — a denser supply runs higher (FR-016) — and this standard contract does not run below `s_out`; *amended 2026-06-15, the earlier "a 0.5 proxy stays 0.5" wording is superseded*). There is no closed per-effect policy enum: a custom `FilterEffectRenderNode` needing a different `w` overrides `GetWorkingScaleContract()` and may return a `RenderScaleContract.Custom` finite positive density, including one below `s_out`; that density is not raised to the standard floor but is still capped by `MaxWorkingScale` and clamped against each concrete allocation footprint. `Process` is reserved for different topology/lowering. The pipeline applies the FR-008 coordinate-space rule at the selected `w`: logical-space geometry under the CTM is unchanged, device-buffer dimensions and device-space shader uniforms convert once (`× w`), and readback-derived geometry converts back (`÷ w`). The root surface becomes `ceil(FrameSize × s_out)` with one `Matrix.CreateScale(s_out)`; an op whose `e ≠ s_out` is resampled once at the final-stage blit. `MaxWorkingScale` bounds **`w`**, not buffer memory — memory scales `area × w²` and is unbounded by design (FR-037); the separate per-buffer dimension clamp `ClampWorkingScaleToBufferBudget` bounds the per-axis device size. **At `s_out = 1.0`, vector / Skia-filter / unscaled-bitmap output is byte-identical to today** (the regression anchor for that content). *(Amended 2026-06-08: byte-identity is no longer a universal design constraint — the density model is now coherent, so a transform re-scales a bitmap's density and a scaled bitmap into an effect is intentionally not byte-identical; see FR-019 and the requirements.md amendment log.)* Design decisions (D1–D7) in [research.md](./research.md); types in [data-model.md](./data-model.md); breaking surface and author contracts in [contracts/](./contracts/). ## Technical Context @@ -22,7 +22,7 @@ Thread render scale through Beutl's 2D render-node tree so the *same project* re **Project Type**: desktop application + engine library (single repo; module-boundary map in AGENTS.md) -**Performance Goals**: reduced-scale preview render-stage time scales ~`s²` for the rasterization-bound portion (SC-003 gate: `median(0.5)/median(1.0) < 0.6`, ratio-based/hardware-independent — note 2026-06-15: the gate is **loose** relative to the `s² ≈ 0.25` ideal because ~38% fixed overhead sits outside the rasterization-bound portion in the committed best case, so it proves **direction**, not the full `s²` value; a **required** source-heavy benchmark variant anchors the supply-driven model's deliberate non-speedup); reduced-scale "exact" effects SSIM ≥ 0.985 vs 1.0 (SC-004); `s=1.0` byte-identical for vector / Skia-filter / unscaled-bitmap content (SC-001) +**Performance Goals**: reduced-scale preview render-stage time scales ~`s²` for the rasterization-bound portion (SC-003 gate: pinned seed `20040719` produces a counterbalanced, seeded permutation of 11 paired 0.5×/1.0× samples—five 0.5×/1.0× orders, five 1.0×/0.5× orders, plus one seed-selected unmatched order; 0.5× is faster in at least 9 pairs, an exact tie is reported and counted as not a 0.5× win, the one-sided exact sign test has `p < 0.05`, and the pinned vector-heavy workload has `median(0.5)/median(1.0) < 0.85`; report the seed, realized order, tie count, ratio, and ~0.25 target without asserting that fixed overhead scales with `s²`; a **required** source-heavy benchmark variant anchors the supply-driven model's deliberate non-speedup); reduced-scale "exact" effects SSIM ≥ 0.985 vs 1.0 (SC-004); `s=1.0` byte-identical for vector / Skia-filter / unscaled-bitmap content (SC-001) **Constraints**: `s=1.0` raw-frame byte-identical to the pre-feature renderer for vector / Skia-filter / unscaled-bitmap content (RgbaF16, zero epsilon) — *not* for a scaled bitmap into an effect (FR-019, 2026-06-08 amendment); origins round **toward-zero** (not floor); uniform `float` scale v1 (Vector primitives pre-exist for later widening); no MIT→GPL boundary crossing; no `[Obsolete]` shims; preview scale per-edit-view, non-persisted @@ -110,6 +110,6 @@ Each slice is golden-testable (render at `s`, compare to `s=1.0` within the gate - **RgbaF16 zero-epsilon reproducibility** across MoltenVK/SwiftShader/native — validate empirically on the chosen golden backend; fall back to a tiny ULP tolerance only if required. - **`RenderScale` value-type shape** (record struct vs enum+float) and where Fit-to-previewer reads the preview surface size (`PlayerViewModel._maxFrameSize` vs Image bounds) — pin in tasks. - **Supersample factor surfacing** in `OutputViewModel`/encoder preset UI (cap at 2× + Mitchell per research.md). -- **Built-in working scale** (FR-036) — **as shipped, every built-in is supply-driven** (no per-effect knob; runs at the input supply density, which keeps a high-res source through the effect). The `ResolutionPolicy` type (`Inherit`/`ClampToOutput`/`Oversample`/`PreserveSource`) was removed entirely — an effect needing a non-supply `w` overrides `Process` in a custom `FilterEffectRenderNode`. The working scale MUST NOT change `s_out=1.0` output. -- **Global working-scale ceiling value** `MaxWorkingScale` (FR-037) — **as shipped:** preview `2 × s_out` (interactive backstop), **export `+∞`** (no working-scale quality ceiling — *amended 2026-06-15*; the earlier finite `max(8, 4 × s_out)` was removed as a quality clip). Export allocatability comes from the per-buffer dimension clamp (`ClampWorkingScaleToBufferBudget`, 16384 px/axis) plus the request-scoped byte/area budget follow-up. Configured in `WorkingScaleCeiling` (`Beutl.Editor`), seeded by `EditViewModel` (preview) / `OutputViewModel` (export). +- **Built-in working scale** (FR-036) — **as shipped, every built-in is supply-driven** (no closed per-effect policy enum; runs at the input supply density, which keeps a high-res source through the effect). The `ResolutionPolicy` type (`Inherit`/`ClampToOutput`/`Oversample`/`PreserveSource`) was removed entirely — an effect needing a non-supply `w` overrides `GetWorkingScaleContract()` in a custom `FilterEffectRenderNode`; `Process` remains for different topology/lowering. At `s_out = 1.0`, the representative FR-005 golden set with vector, Skia-filter, text, and unscaled/unit-density bitmap inputs MUST remain byte-identical. Transform-rescaled or scaled-bitmap-into-effect scenes retain the explicit FR-005/FR-019 exemption. +- **Global working-scale ceiling value** `MaxWorkingScale` (FR-037) — **as shipped:** preview `2 × s_out` (interactive backstop), **export `+∞`** (no working-scale quality ceiling — *amended 2026-06-15*; the earlier finite `max(8, 4 × s_out)` was removed as a quality clip). `RenderScaleUtilities.ClampWorkingScaleToBufferBudget` provides only a per-buffer, 16384-px-per-axis clamp. It does not bound aggregate bytes, area, live-buffer count, or a backend-specific image limit, so the request-scoped aggregate budget and backend-reported limit remain explicitly out of scope for feature 003. Configured in `WorkingScaleCeiling` (`Beutl.Editor`), seeded by `EditViewModel` (preview) / `OutputViewModel` (export). - **`IRenderer.RenderScale`/`DeviceSize`** as hard breaking members vs default-interface-impls — `beutl-design-reviewer` call. diff --git a/docs/specs/003-resolution-independent-pipeline/quickstart.md b/docs/specs/003-resolution-independent-pipeline/quickstart.md index 59e970c731..1d730f9f9f 100644 --- a/docs/specs/003-resolution-independent-pipeline/quickstart.md +++ b/docs/specs/003-resolution-independent-pipeline/quickstart.md @@ -4,7 +4,7 @@ ## The mental model in one paragraph -The 2D pipeline has no scale today: `1 logical unit == 1 device pixel`, hard-wired as `ToSize(1)` / `(int)bounds.Width` / `PixelRect.FromRect(bounds)`. This feature is **supply-driven** with three scales: the render request carries an **output scale `s_out`** (the *final* target only — `RenderNodeContext.OutputScale`); each operation carries an **`EffectiveScale`** = the density its pixels exist at (vector = `Unbounded`); each effect computes a **working scale `w` = `ResolveWorkingScale(inputs, s_out, maxWorkingScale)`**, with **no per-effect policy** (the `ResolutionPolicy` type was removed; FR-036): `w = min(max(s_out, densest concrete input density), MaxWorkingScale)`. So `s_out` is a **floor** — an effect never runs below the deliverable density — and never a **ceiling** (a 2.0 source stays 2.0); a sub-output supply is lifted to `s_out` (a 0.5 proxy stays 0.5 only in a ≤0.5 preview, where the floor coincides); vector-only falls back to `s_out`. The FR-008 coordinate-space rule applies at `w`: logical-space geometry under the CTM is unchanged, device-buffer dimensions and device-space shader uniforms convert once (`× w`), readback-derived geometry converts back (`÷ w`). The root surface is `ceil(FrameSize × s_out)` with one `Matrix.CreateScale(s_out)`; an op whose `e ≠ s_out` is resampled once at the final-stage blit; `MaxWorkingScale` caps `w` (FR-037: preview `2 × s_out`; export imposes **no** quality ceiling — `+∞`, with the per-buffer dimension clamp as the sole allocatability bound). Preview uses `s_out ≤ 1` (Full/Half/Quarter/Fit); export uses `1.0` or `s_out > 1` supersampling. At `s_out = 1.0` vector / Skia-filter / unscaled-bitmap content is byte-identical to today; *(2026-06-08 amendment)* a transform re-scaling a bitmap's density, and a scaled bitmap into an effect, are intentionally not byte-identical (coherent density model, FR-019); *(2026-06-15 amendment)* `w` is floored at `s_out`, so an **enlarged** sub-output bitmap into an effect renders at the deliverable density (matching the pre-feature renderer), not below it. +The 2D pipeline has no scale today: `1 logical unit == 1 device pixel`, hard-wired as `ToSize(1)` / `(int)bounds.Width` / `PixelRect.FromRect(bounds)`. This feature is **supply-driven** with three scales: the render request carries an **output scale `s_out`** (the *final* target only — `RenderNodeContext.OutputScale`); each operation carries an **`EffectiveScale`** = the density its pixels exist at (vector = `Unbounded`); and each buffer-allocating boundary chooses a **working scale `w`**. The standard `RenderScaleContract.MaterializeAtWorkingScale` used by every built-in effect computes `w = min(max(s_out, densest concrete input density), MaxWorkingScale)`, so `s_out` is its floor but never its ceiling. There is no closed `ResolutionPolicy` enum, but a custom `FilterEffectRenderNode.GetWorkingScaleContract()` may declare different semantics; in particular an explicit `Custom` contract may intentionally choose `w < s_out`. A 0.5 proxy under the standard policy runs at 0.5 in a 0.5 preview and at 1.0 in a 1.0 delivery, while an explicit custom 0.5 contract remains 0.5 in either request (subject to `MaxWorkingScale` and per-buffer bounds clamps). Vector-only standard inputs fall back to `s_out`. The FR-008 coordinate-space rule applies at `w`: logical-space geometry under the CTM is unchanged, device-buffer dimensions and device-space shader uniforms convert once (`× w`), readback-derived geometry converts back (`÷ w`). The root surface is `ceil(FrameSize × s_out)` with one `Matrix.CreateScale(s_out)`; an op whose `e ≠ s_out` is resampled once at the final-stage blit; `MaxWorkingScale` caps `w` (FR-037: preview `2 × s_out`; export imposes **no** quality ceiling — `+∞`). `RenderScaleUtilities.ClampWorkingScaleToBufferBudget` is only a per-buffer 16384-px-per-axis safeguard; it does not provide an aggregate byte/area/live-buffer or backend-specific OOM budget, which remains out of scope. Preview uses `s_out ≤ 1` (Full/Half/Quarter/Fit); export uses `1.0`, or `s_out > 1` for supersampling. At `s_out = 1.0`, vector / Skia-filter / unscaled-bitmap content is byte-identical to today; *(2026-06-08 amendment)* a transform re-scaling a bitmap's density, and a scaled bitmap into an effect, are intentionally not byte-identical (coherent density model, FR-019). ## Validate it (the acceptance loop) @@ -20,7 +20,7 @@ dotnet test Beutl.slnx -f net10.0 --filter FullyQualifiedName~ResolutionScaleTes # regenerate golden baselines after an INTENTIONAL change (writes .bin instead of asserting) BEUTL_GOLDEN_UPDATE=1 dotnet test Beutl.slnx -f net10.0 --filter FullyQualifiedName~Rendering.Golden -# benchmark (explicit; ratio gate, not in default CI) +# benchmark (explicit; paired significance gate, not in default CI) dotnet test Beutl.slnx -f net10.0 --filter "Category=Benchmark" ``` @@ -76,4 +76,4 @@ Export supersampling (FR-034): `OutputViewModel` passes a supersample factor (Of - **Filter-effect sinks** (`FilterEffectActivator`/`CustomFilterEffectContext`) use component-wise `(int)` truncation that differs from `FromRect`; migrating them is a scale-1.0 behavior change → golden-test it. - **Don't fold render scale into the artistic matrix** (`Transform.CreateMatrix`/`TransformGroup`) — keep it the appended root scale, or `Matrix.TryDecomposeTransform`, editor handles, and serialized transforms corrupt (FR-027). For perspective, **append** scale, never prepend (the `S·P ≠ P·S` rule). - **Cache is invalidated by renderer rebuild on scale change.** The node cache rasterizes at the renderer's `OutputScale` (with `MaxWorkingScale` forwarded), and replay tags each cached tile with its creation density, so a stale-density tile is never blitted 1:1. Multi-scale cache **reuse** (`CachedWorkingScale`: reuse-with-downsample when `≥` the required scale, miss when `<`) is **not shipped** — deferred to a follow-up (T025). -- **Effects multiply by the working scale `w`, not the output scale `s_out`.** `w` is supply-driven: a 0.5 proxy runs at 0.5 (no upsample); a 2.0 source runs at 2.0 (downscaled only at the final stage). There is **no per-effect policy** — every effect keeps a high-res source's density for free, capped only by the global memory ceiling (FR-036/FR-037). An effect needing a different `w` (clamp for perf, oversample for SSAA) overrides `Process` in a custom `FilterEffectRenderNode`. *(An earlier `Inherit`/`ClampToOutput`/`Oversample`/`PreserveSource` policy was removed.)* +- **Effects multiply by the working scale `w`, not the output scale `s_out`.** Under the standard materializing contract, a 0.5 proxy runs at 0.5 in a 0.5 preview and at 1.0 in a 1.0 delivery; a 2.0 source runs at 2.0 and is downscaled only when it enters a lower-density target. There is **no closed per-effect policy enum**. An effect needing a different `w` (intentional sub-output rendering, clamp for performance, or SSAA) overrides `GetWorkingScaleContract()` in a custom `FilterEffectRenderNode`; an explicit `Custom` result is not raised to the standard `s_out` floor. Override `Process` only for genuinely different topology/lowering. *(An earlier `Inherit`/`ClampToOutput`/`Oversample`/`PreserveSource` policy was removed.)* diff --git a/docs/specs/003-resolution-independent-pipeline/research.md b/docs/specs/003-resolution-independent-pipeline/research.md index 9b2e55ed55..d5aff377e5 100644 --- a/docs/specs/003-resolution-independent-pipeline/research.md +++ b/docs/specs/003-resolution-independent-pipeline/research.md @@ -10,17 +10,17 @@ Resolves the spec's six Open Questions for Planning into implementable decisions > **Refined after maintainer review — supersedes the original top-down D1.** The first draft propagated a single top-down render scale on `RenderNodeContext.Scale` that every effect multiplied by and sized buffers at. The maintainer requires **supply-driven** behavior: an intermediate effect runs at its *input's* density — never **above** what the input supplies (a 2.0 / 4K source on a 1080 timeline is not downsampled, so quality effects can use the detail) — and the output scale is applied **only at the final stage** ("最終的な部分でスケールを調整する"). See **D7** for the negotiation rule. > -> **Amended 2026-06-15 — `s_out` is the working-scale FLOOR; supply density is the floor only for a denser source.** The early "a 0.5 proxy runs the effect at 0.5, do NOT upsample" half is **replaced**. The working scale is now `w = min( max(s_out, densest concrete supply), maxWorkingScale )`: `s_out` is a **floor** an effect never runs below, and a denser concrete supply runs **above** it. Source *detail* and the effect's own *working resolution* (blur kernel / shadow / shader grid) are distinct: running the effect below `s_out` only discards resolution the deliverable can use without fabricating source detail, so a **sub-output** concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is **floored to `w = 1.0`** (matching the pre-feature renderer) rather than staying at `0.5`. A genuine proxy is still cheap in **preview**: at a `0.5` preview a `0.5` proxy gives `max(0.5, 0.5) = 0.5`. `s_out` is still **never a ceiling** (FR-016 preserved). See **D7** for the rule body and **FR-019/FR-036**. +> **Amended 2026-06-15 — `s_out` is the working-scale FLOOR; supply density is the floor only for a denser source.** The early "a 0.5 proxy runs the effect at 0.5, do NOT upsample" half is **replaced**. The working scale is now `w = min( max(s_out, densest concrete supply), maxWorkingScale )`: `s_out` is the **pre-ceiling floor**, and a denser concrete supply raises that pre-ceiling result above it; an authoritative positive `MaxWorkingScale` may then reduce either value. Source *detail* and the effect's own *working resolution* (blur kernel / shadow / shader grid) are distinct: running the effect below `s_out` only discards resolution the deliverable can use without fabricating source detail, so a **sub-output** concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is **floored to `w = 1.0`** (matching the pre-feature renderer) rather than staying at `0.5`. A genuine proxy is still cheap in **preview**: at a `0.5` preview a `0.5` proxy gives `max(0.5, 0.5) = 0.5`. `s_out` is still **never a ceiling** (FR-016 preserved). See **D7** for the rule body and **FR-019/FR-036**. **Decision**: Three scales, three owners. -1. **Output scale `s_out`** — `float`, get-only on the render request: `Renderer` ctor → `RenderNodeProcessor.OutputScale` → **`RenderNodeContext.OutputScale`** (renamed from the first draft's `Scale`; seeded once at `RenderNodeProcessor.Pull`, `RenderNodeProcessor.cs:121`, the sole production construction site; test code constructs it directly and is updated alongside). Preview 0.5/0.25, export 1.0, export-SSAA 2.0. **It is the final normalization target only** — consumed structurally at the root composite, entering intermediate math only as the fallback/ceiling term of the working-scale rule (D7). It never sizes an intermediate buffer directly. -2. **Effective scale `e`** — read-only **`EffectiveScale`** value type on `RenderNodeOperation` (the maintainer's literal "scale on `RenderNodeOperation`"), flowing bottom-up: the density the op's pixels actually exist at. Vector/lossless ops report **`EffectiveScale.Unbounded`** (re-rasterizable at any target); bitmap-backed ops (decoded media, cached tiles, nested-scene/3D surfaces, flushed effect targets) report `EffectiveScale.At(scale)`. **`LosslessReRasterizable` (the first draft's bool) is dropped — `IsUnbounded` subsumes it** (no contradictory "lossless but `e=2.0`" state). -3. **Working scale `w`** — computed, not stored on the context: each buffer-allocating boundary (the three sinks `RenderNodeProcessor.cs:26,52,75`; `FilterEffectActivator.Flush` `:29`; `CustomFilterEffectContext.CreateTarget` `:52`; brush/tile intermediates; `ParticleRenderNode`) computes `w = RenderNodeContext.ResolveWorkingScale(inputs, OutputScale)` (D7), sizes `ceil(bounds × w)`, opens an `ImmediateCanvas` that **bakes the base CTM `CreateScale(w)`** at construction (feature 003 — no manual push), and tags its emitted op `e = w`. Sub-processor spawn sites forward `OutputScale` (`ReferencesChildRenderNode.cs:25`, `NodeGraphFilterEffectRenderNode.cs:42`, `ParticleRenderNode.cs:144`). +1. **Output scale `s_out`** — `float`, get-only on the render request: `Renderer` supplies `RenderNodeRendererOptions.OutputScale` and `MaxWorkingScale`; `RenderNodeRenderer` snapshots both into `RenderRequestOptions`, and each recording transaction exposes them through `RenderNodeContext` (`src/Beutl.Engine/Graphics/Rendering/Renderer.cs:409-422`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs:694-763`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs:24-32`). Preview 0.5/0.25, export 1.0, export-SSAA 2.0. **It is the final normalization target only** — consumed structurally at the root composite, entering intermediate math as the fallback/floor term of the working-scale rule (D7). It never sizes an intermediate buffer directly. +2. **Effective scale `e`** — read-only **`EffectiveScale`** recorded fragment metadata, flowing bottom-up: the density the fragment's pixels actually exist at. Vector/lossless fragments report **`EffectiveScale.Unbounded`** (re-rasterizable at any target); bitmap-backed fragments (decoded media, cached tiles, nested-scene/3D surfaces, flushed effect targets) report `EffectiveScale.At(scale)`. **`LosslessReRasterizable` (the first draft's bool) is dropped — `IsUnbounded` subsumes it** (no contradictory "lossless but `e=2.0`" state). +3. **Working scale `w`** — computed, not stored on the context: each buffer-allocating boundary computes `w = RenderScaleUtilities.ResolveWorkingScale(inputs, OutputScale, MaxWorkingScale)` (`src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs:19-35`) and applies its per-buffer clamp. Typed Shader/Geometry boundaries allocate canonical composition-device footprints. Legacy `CustomFilterEffectContext.CreateTarget` instead preserves its dimension-only local-buffer rule while recording placement metadata separately. Both open an `ImmediateCanvas` that **bakes the base CTM `CreateScale(w)`** at construction and tag emitted values `e = w`. Nested requests forward both request scales. -The model: ops carry their own scale `e`; intermediates run at `w` derived from their input supply; `s_out` adjusts only at the final part. +The model: recorded fragments carry their own scale `e`; intermediates run at `w` derived from their input supply; `s_out` adjusts only at the final part. -**Rationale**: One rule (D7) satisfies both **R1** (a reduced-scale proxy stays cheap in preview — at a `0.5` preview a `0.5` proxy floors to `max(0.5, 0.5) = 0.5`; *amended 2026-06-15:* the same proxy at a `1.0` export floors to `1.0`, rendering at the deliverable density — `s_out` is a floor, not a cap) and **R2** (2.0 source input → effect runs at 2.0: no forced downsample, high res available to intermediate quality effects), with the only forced resample being the single final-stage normalization to `s_out`. **Byte-identical at the default**: `s_out=1.0` with all inputs `Unbounded`/`At(1.0)` → `w = max(1, 1) = 1.0` everywhere; every new branch is gated on `e ≠ w`, which never fires. `default(EffectiveScale) = Unbounded`, so a plugin op that ignores the new param is safe. +**Rationale**: One rule (D7) satisfies both **R1** (a reduced-scale proxy stays cheap in preview — at a `0.5` preview a `0.5` proxy floors to `max(0.5, 0.5) = 0.5`; *amended 2026-06-15:* the same proxy at a `1.0` export floors to `1.0`, rendering at the deliverable density — `s_out` is a floor, not a cap) and **R2** (a 2.0 source input lets the effect run at 2.0 when `MaxWorkingScale` does not intervene, keeping high resolution available to intermediate quality effects). The final stage normalizes to `s_out`; an authoritative finite ceiling may deliberately resample an intermediate earlier. **Byte-identical at the default**: `s_out=1.0` with all inputs `Unbounded`/`At(1.0)` → `w = max(1, 1) = 1.0` everywhere; every new branch is gated on `e ≠ w`, which never fires. `default(EffectiveScale) = Unbounded`, so a plugin op that ignores the new param is safe. **Alternatives rejected**: - *Top-down `s_out` as the working scale (the original D1)* — forces every effect to the requested scale: upsamples proxies (synthetic detail, no perf win) and downsamples high-res sources before quality effects can use them. Rejected per the supply-driven requirement. @@ -76,7 +76,7 @@ The model: ops carry their own scale `e`; intermediates run at `w` derived from - **Best-effort effects (FR-013 list)**: no SSIM floor at 0.5; assert (a) scale-1.0 byte-equality and (b) a per-effect **structural invariant** at 0.5 (e.g. mosaic tiles = `ceil(tileSize×0.5)` device px; ColorShift bounds inflate by `round(offset×0.5)`) on op `Bounds`/metadata via `[TestCaseSource]` over the FR-009 manifest. - **Mixed-scale (SC-005)**: exact gate (SSIM ≥ 0.985, MAE ≤ 0.02) vs full-scale reference, plus a **seam check** (max per-pixel delta along the composite boundary rows/cols ≤ 0.05). - **Supersample (SC-009)**: factors `s ∈ {2.0}` required first-class, `{1.5, 4.0}` additionally tested. Assert post-downscale `encodedBuffer.Width/Height == ceil(FrameSize)` exactly (FR-026). The enforced gate is MAE-to-ground-truth strictly decreases versus `s = 1` plus an SSIM no-degradation tolerance: `SSIM(s≥2) − SSIM(s=1) ≥ −0.01` *(amended to match spec.md SC-009 and the shipped `ExportSupersampleTests`; the original `≥ 0.01` was an improvement margin, the corrected `≥ −0.01` is a degradation tolerance)*. -- **Benchmark (SC-003)**: committed `bench.scene` (1920×1080, ~12 vector shapes w/ fills+strokes, 3 TextBlocks, 2 gradients, Blur+DropShadow chain, 1 nested scene, 1 particle, 1 audio visualizer, 1 3D scene — doubles as the SC-001 representative set). Measure `Render+Snapshot` wall-clock only, warm cache excluded (discard frame 1). The **ratio** `median(0.5)/median(1.0)` is the gate (`< 0.6`, hardware-independent; ~0.25× documented as the target, not hard-asserted). Lives in `[Explicit][Category("Benchmark")]` NUnit + a `tests/Beutl.Benchmarks` entry, **not** in the default CI gate. +- **Benchmark (SC-003)**: committed `bench.scene` (1920×1080, ~12 vector shapes w/ fills+strokes, 3 TextBlocks, 2 gradients, Blur+DropShadow chain, 1 nested scene, 1 particle, 1 audio visualizer, 1 3D scene — doubles as the SC-001 representative set). Measure `Render+Snapshot` wall-clock only, warm cache excluded (discard frame 1). Pinned seed `20040719` creates 11 paired samples: exactly five 0.5×-then-1.0× orders, five 1.0×-then-0.5× orders, and one unmatched order selected by the seed, followed by a seeded permutation of all 11 pairs. Require 0.5× to win at least 9 pairs, which is a one-sided exact sign test with `p < 0.05`; an exact tie is reported and conservatively counts as not a 0.5× win rather than being discarded or rerun. Require the pinned vector-heavy workload to satisfy `median(0.5)/median(1.0) < 0.85`; report the seed, realized order, tie count, ratio, and the ~0.25× rasterization-bound target. Lives in `[Explicit][Category("Benchmark")]` NUnit + a `tests/Beutl.Benchmarks` entry, **not** in the default CI gate. - **Harness shape**: new `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/` — `GoldenImageHarness`, `GoldenThresholds`, `ImageMetrics`. Reuses the **existing Vulkan gate** (`VulkanTestEnvironment.EnsureAvailable()` → `Assert.Ignore` on GPU-less CI; `InvokeOnRenderThread`), exactly like `ImmediateCanvasVulkanTests`/`PixelSortEffectTests`. `BEUTL_GOLDEN_UPDATE=1` regenerates baselines. **SC-008** (no `ToSize(1)`) was planned as a separate non-GPU search test but was **deferred** (T007): a naive scan false-positives on the load-bearing logical-`ToSize(1)` / `(int)`-at-`w=1` sites, so it needs an annotated allowlist; SC-008 was reframed to "no NEW unguarded truncation" with completeness carried by the behavioural buffer-activation goldens. **Rationale**: F16-linear surfaces make SSIM/SSAA correct in linear light; the byte-equality gate on raw F16 is the strongest possible regression anchor; the ratio-based benchmark is hardware-independent. @@ -103,7 +103,7 @@ The model: ops carry their own scale `e`; intermediates run at `w` derived from ## D7 — Resolution policy & working-scale negotiation (refinement) -> **SUPERSEDED (2026-06-09): the `ResolutionPolicy` type was removed.** This section records the original design — a declarative per-effect policy choosing the working scale. The shipped pipeline has **no policy**: every boundary runs **supply-driven** (`w` = the densest concrete input, vector/mixed floor at `s_out`, capped by `MaxWorkingScale`), which is exactly the former `Inherit` branch — the only branch any built-in ever used. `ClampToOutput`/`Oversample(k)`/`PreserveSource` had zero in-tree users, and a custom `FilterEffectRenderNode` (from `FilterEffect.Resource.CreateRenderNode()`, overriding `Process`) is strictly more flexible than a closed enum, so the type, the `virtual FilterEffect.ResolutionPolicy`, and the `policy` parameter of `ResolveWorkingScale` were all deleted. Read the rest of this section as the rationale for the supply-driven *rule* (still current); treat every mention of a *policy enum / declaration point / precedence* as historical. The normative rule is **FR-036**; the global ceiling (preview `2 × s_out`, export `+∞` — no quality ceiling, *amended 2026-06-15*) is **FR-037**. +> **SUPERSEDED (2026-06-09): the `ResolutionPolicy` type was removed.** This section records the original design — a declarative per-effect policy choosing the working scale. The shipped default is **supply-driven** (`w = max(s_out, densest concrete input)`, capped by `MaxWorkingScale`): the `s_out` floor applies at every standard materializing boundary, including concrete-only, vector-only, and mixed inputs. That rule is exactly the former `Inherit` branch — the only branch any built-in ever used. `ClampToOutput`/`Oversample(k)`/`PreserveSource` had zero in-tree users, so the enum, `virtual FilterEffect.ResolutionPolicy`, and the `policy` parameter of `ResolveWorkingScale` were deleted. The current narrow escape hatch is `FilterEffectRenderNode.GetWorkingScaleContract()`; `Process` is overridden only for genuinely different topology/lowering. Read the rest of this section as historical rationale for the supply-driven *rule*; treat every mention of a *policy enum / declaration point / precedence* as superseded. The normative rule is **FR-036**; the global ceiling (preview `2 × s_out`, export `+∞` — no quality ceiling, *amended 2026-06-15*) is **FR-037**. **Decision (historical)**: A declarative `ResolutionPolicy` per effect/node drives one shared working-scale rule. @@ -120,7 +120,7 @@ The model: ops carry their own scale `e`; intermediates run at `w` derived from **Default = `Inherit` for ALL effects, built-in and plugin** *(maintainer choice)*. Every effect preserves its input's density by default; **heavy effects opt OUT** with `ClampToOutput`; **resolution-sensitive effects** (FR-013) declare `PreserveSource` so an ancestor clamp can't strip the resolution they exist to use; `Oversample(k)` is the SSAA-on-demand opt-in. Out-of-tree plugins default to `Inherit` (a supply-driven passthrough, byte-identical at `s_out=1.0`). *(The maintainer rejected the design panel's cheaper-built-ins-auto-clamp default in favor of pure `Inherit` + the global ceiling.)* -**Global working-scale ceiling `MaxWorkingScale`** *(maintainer choice: ceiling ON for preview)*: a configurable, **per-render-request** cap applied as the last step of `ResolveWorkingScale`, so no combination of `Inherit`/`Oversample`/preserved high-res sources can blow up worst-case **preview** memory (RgbaF16 is 8 bytes/px; `w²` memory). It caps **only the high side**: never pulling `w` below a proxy's supply (R1 unaffected) and inert at `s_out=1.0` with 1.0 inputs (byte-identity unaffected). **As shipped (preview default `2 × s_out`; export `+∞` — no quality ceiling).** *(Value history: the original D7 said export `+∞`, then narrowed to `max(8, 4 × s_out)`; that finite export ceiling was **removed again 2026-06-15** as a quality clip masquerading as an OOM backstop — it silently discarded detail from any source denser than the ceiling (e.g. a 4096-px logo in a 256-px box = supply 16, clipped at 8) far below any allocation limit. Export now imposes **no** working-scale quality ceiling; allocatability on export is the per-buffer **dimension** clamp (`ClampWorkingScaleToBufferBudget`, 16384 px/axis, using each buffer's own bounds) plus the documented request-scoped aggregate byte/area budget follow-up — see FR-037.)* The preview ceiling stays a tight, interactive backstop. +**Global working-scale ceiling `MaxWorkingScale`** *(maintainer choice: ceiling ON for preview)*: a configurable, **per-render-request** cap applied as the last step of `ResolveWorkingScale`, so no combination of `Inherit`/`Oversample`/preserved high-res sources can blow up worst-case **preview** memory (RgbaF16 is 8 bytes/px; `w²` memory). It is an authoritative upper bound: `w = min(max(s_out, supply), MaxWorkingScale)`, so a positive ceiling may reduce `w` below either the input supply or `s_out`. The default preview ceiling `2 × s_out` remains inert at `s_out=1.0` with 1.0 inputs (byte-identity unaffected). **As shipped (preview default `2 × s_out`; export `+∞` — no quality ceiling).** *(Value history: the original D7 said export `+∞`, then narrowed to `max(8, 4 × s_out)`; that finite export ceiling was **removed again 2026-06-15** as a quality clip masquerading as an OOM backstop — it silently discarded detail from any source denser than the ceiling (e.g. a 4096-px logo in a 256-px box = supply 16, clipped at 8) far below any allocation limit. Export now imposes **no** working-scale quality ceiling; allocatability on export is the per-buffer **dimension** clamp (`ClampWorkingScaleToBufferBudget`, 16384 px/axis, using each buffer's own bounds) plus the documented request-scoped aggregate byte/area budget follow-up — see FR-037.)* The preview ceiling stays a tight, interactive backstop. **Declaration points & precedence**: `FilterEffect.ResolutionPolicy` (virtual, default `Inherit`) governs that effect's own intermediates; `RenderNode.ResolutionPolicy` (virtual, default `Inherit`) governs a container's composite allocation. They never apply to the same buffer (no conflict): a `ClampToOutput` container over an `Oversample(2)` child means the child oversamples its own intermediate, then is resampled down at the container's blit (FR-017). A `PreserveSource` floor is the one cross-boundary scalar (a per-pull `max(floors)`, inert unless a `PreserveSource` effect is present). diff --git a/docs/specs/003-resolution-independent-pipeline/spec.md b/docs/specs/003-resolution-independent-pipeline/spec.md index ab48df5fc6..a2dd9cd9dc 100644 --- a/docs/specs/003-resolution-independent-pipeline/spec.md +++ b/docs/specs/003-resolution-independent-pipeline/spec.md @@ -16,8 +16,8 @@ Today Beutl's 2D rendering pipeline has **no concept of render scale**. The inva This feature makes the pipeline **resolution-independent** using a **supply-driven, three-scale model**: -- **Output scale (`s_out`)** — the single per-renderer delivery density (preview quality or export supersampling factor). It sets the floor: no intermediate is ever rendered below `s_out`. -- **Working scale (`w`)** — the per-effect-boundary rendering density, resolved at each `FilterEffectRenderNode` as `max(s_out, densest concrete input)`. It is supply-driven: the source content's native density flows upward, not a top-down uniform multiplier. A global ceiling (`MaxWorkingScale`) and a per-buffer dimension clamp (16 384 px per axis) bound `w` to keep allocations feasible. +- **Output scale (`s_out`)** — the single per-renderer delivery density (preview quality or export supersampling factor). It is the fallback and floor of the standard `MaterializeAtWorkingScale` contract, while an explicit `RenderScaleContract.Custom` filter contract may intentionally choose a lower intermediate density. +- **Working scale (`w`)** — the per-effect-boundary rendering density. Every built-in effect uses `MaterializeAtWorkingScale` and resolves it as `max(s_out, densest concrete input)`. A custom `FilterEffectRenderNode` may declare another finite positive density, including one below `s_out`. It is supply-driven by default: the source content's native density flows upward, not a top-down uniform multiplier. A global ceiling (`MaxWorkingScale`) and per-allocation-footprint dimension clamps (16 384 px per axis) bound either result to keep allocations feasible. - **Effective scale (`e`)** — the per-operation annotation carried by each render-node output. Vector/text operations are `Unbounded` (infinitely re-rasterizable); bitmap-backed operations report `At(density)`. Drawable and effect properties remain **logical** sizes; the base CTM baked into `ImmediateCanvas` maps logical coordinates to device pixels at the working density. Effects do not multiply spatial parameters by a scale factor — the coordinate space already *is* scaled (see FR-008). This unlocks rendering the *same project* at different resolutions — a reduced-scale preview for cheap editing, a full-scale export for delivery — and lays the **foundation for a future proxy / optimized-media workflow** without the decoder-level changes. @@ -56,7 +56,7 @@ Drawable and effect properties remain **logical** sizes; the base CTM baked into A video editor working on a heavy scene switches the preview to a reduced render scale (e.g. 0.5×). The editor canvas renders noticeably faster because vector content — shapes, text, gradients, and Skia-filter effects over them — is rasterized at half resolution. When the editor exports at full scale, the delivered frames are **identical to what Beutl produces today** for vector / text / Skia-filter / unscaled-bitmap content (a scaled bitmap feeding an effect instead renders at its coherent supply density — FR-019); the reduced-scale preview changed only the preview, never the document or the export. -> **Honest scope (the preview win is content-dependent — S1, 2026-06-15):** the model is **supply-driven** (FR-036), so an effect fed by a **concrete bitmap source** runs at that source's supply density — bounded only by the preview working-scale ceiling `2 × s_out` (FR-037), **not** by the reduced `s_out`. A **source-heavy** effect chain (e.g. a 4K image/video through Mosaic/Stroke/a shader) does **not** shrink with the preview scale; its effect intermediates stay at the source density, so the preview speed-up there comes from the vector/text shrink and the final-stage downscale, not the effect passes. The headline "rasterized at half resolution" holds for **vector / text / Skia-filter-heavy** scenes, not source-heavy effect chains (see SC-003 and FR-036). Preview is **fidelity-first**, not uniformly reduced-cost. +> **Honest scope (the preview win is content-dependent — S1, 2026-06-15):** under the standard built-in **supply-driven** policy (FR-036), an effect fed by a **concrete bitmap source** runs at that source's supply density — bounded only by the preview working-scale ceiling `2 × s_out` (FR-037), **not** by the reduced `s_out`. A **source-heavy** effect chain (e.g. a 4K image/video through Mosaic/Stroke/a shader) therefore does **not** shrink with the preview scale; its effect intermediates stay at the source density, so the preview speed-up there comes from the vector/text shrink and the final-stage downscale, not the effect passes. The headline "rasterized at half resolution" holds for **vector / text / Skia-filter-heavy** scenes, not source-heavy built-in effect chains (see SC-003 and FR-036). Preview is **fidelity-first**, not uniformly reduced-cost. **Why this priority**: This is the core deliverable and immediate user-visible payoff. For **vector / Skia-filter content** it is also the regression anchor — "export at scale 1.0 is unchanged" guards every other change. *(Amended 2026-06-08: the anchor covers vector/unscaled content only; a scaled bitmap into an effect renders at its coherent density — FR-019.)* @@ -72,7 +72,7 @@ A video editor working on a heavy scene switches the preview to a reduced render ### User Story 2 - Resolution-independent properties with correct per-effect and mixed-scale behavior (Priority: P1) -Every drawable and effect property is a **logical** size. A blur of "10" looks like the same blur whether the frame is rendered at full scale or half scale — the engine multiplies the blur's pixel-magnitude parameters by the render scale. When a full-resolution shape is composited over a half-resolution nested scene (or, later, a proxy video), the result composites correctly at the higher scale rather than dragging the sharp content down. +Every drawable and effect property retains its authored logical meaning. A blur of "10" looks like the same blur whether the frame is rendered at full scale or half scale: logical geometry and Skia image-filter arguments remain unchanged under the scaled CTM, while only device-buffer dimensions, device-space shader values, and pixel indexing convert once by the working scale. When a full-resolution shape is composited over a half-resolution nested scene (or, later, a proxy video), the result composites correctly at the negotiated working scale rather than dragging the sharp content down. **Why this priority**: Without a uniform per-effect contract and a defined mixed-scale rule, reduced-scale rendering produces visibly wrong output (clipped blurs, detached shadows, mis-sized mosaics, soft sharp content). The maintainer explicitly asked that *every* effect and the mixed-scale case be handled. @@ -80,9 +80,9 @@ Every drawable and effect property is a **logical** size. A blur of "10" looks l **Acceptance Scenarios**: -1. **Given** an effect with a spatial-length parameter (blur sigma, shadow offset, dilate radius, mosaic tile size, color-shift offset, stroke thickness), **When** rendered at scale s, **Then** that parameter's effect is scaled by s and magnitude-invariant parameters (color, angle, percentage, ratio, relative coordinate, blend mode, count) are unchanged. -2. **Given** ops with different effective scales in one container, **When** composited, **Then** compositing happens in logical space at the **maximum concrete child scale** (lossless ops regenerate at the target; the output-scale cap applies only at the final root normalization, with FR-037's two bounds — the global working-scale ceiling plus the per-buffer dimension clamp — as the only intermediate bounds), with off-target bitmap ops resampled (Mitchell) exactly once at the blit boundary. -3. **Given** an inherently resolution-sensitive effect (e.g. PixelSort, contour-based stroke/flat-shadow/parts-split, AutoClip, mosaic, custom SKSL/GLSL shader), **When** previewed at reduced scale, **Then** its pixel-magnitude parameters are still multiplied by the scale and the reduced-scale result is accepted as a best-effort approximation, while at export scale 1.0 it is full-fidelity. +1. **Given** an effect with authored logical geometry, device-space shader or buffer values, readback-derived geometry, and magnitude-invariant values, **When** rendered at working scale `w`, **Then** logical geometry and Skia image-filter arguments remain unchanged under the CTM, device-buffer dimensions and device-space shader values convert exactly once (`× w`), readback-derived geometry converts back exactly once (`÷ w`), and magnitude-invariant values remain unchanged, as required by FR-008. +2. **Given** ops with different effective scales at a standard materializing boundary, **When** composited, **Then** compositing happens in logical space at `max(s_out, maximum concrete child scale)` (lossless ops regenerate at the target; the output-scale upper normalization applies only at the final root, with FR-037's global working-scale ceiling and per-buffer dimension clamp bounding the intermediate), with off-target bitmap ops resampled (Mitchell) exactly once at the blit boundary. An explicit custom filter scale contract may select another finite positive density before the same ceiling and footprint clamp are applied. +3. **Given** an inherently resolution-sensitive effect (e.g. PixelSort, contour-based stroke/flat-shadow/parts-split, AutoClip, mosaic, custom SKSL/GLSL shader), **When** previewed at reduced scale, **Then** it follows the same FR-008 coordinate-space conversions and the reduced-scale result is accepted as a best-effort approximation, while at export scale 1.0 it is full-fidelity within the FR-005/FR-019 byte-identity scope. --- @@ -125,7 +125,7 @@ Selecting a layer, dragging a transform handle, and hit-testing all behave ident - **Independent nested-raster paths** — nested scenes, `DrawableBrush`, the particle renderer (a hard-coded fixed buffer today), audio-visualizer drawables, and 3D sub-renders — each currently render at their own independent resolution and composite 1:1; under a global render scale they must inherit the outer scale (or be resampled at the blit boundary). - **Render-scale change concurrent with rendering**: rendering is dispatcher-affine and export frames are produced on a background task; a scale change and its cache invalidation must never let a single frame composite from mixed-scale stale state — satisfied by rebuild-by-replacement (FR-031: two independent UI-thread swaps, read fresh inside the serial render-dispatcher closure; the narrow tear window is self-healing), not by an atomic dispatcher swap. - **Inherently resolution-sensitive effects** cannot be bit-identical at reduced scale; their reduced-scale preview is best-effort by parameter scaling, full-fidelity at export. -- **Fit-to-previewer + concrete-source-fed resolution-sensitive effect (known v1 limitation, added 2026-06-15)**: in Fit-to-previewer mode `s_out` is derived from the editor panel size, so the preview working-scale ceiling `2 × s_out` (FR-037) **floats as the user resizes the panel**. A resolution-sensitive effect (Mosaic, PixelSort, contour Stroke/FlatShadow/PartsSplit, AutoClip, Dilate/Erode, custom SKSL/GLSL) fed by a **concrete** (bitmap) source then renders at a working scale that **changes as the panel is resized**, so its preview is not stable across window sizes. To visually evaluate such an effect, select a **fixed Full / Half / Quarter** scale instead. This is a documented v1 limitation, not a defect; export (`+∞` ceiling) is unaffected. +- **Fit-to-previewer + concrete-source-fed resolution-sensitive effect (known v1 limitation, added 2026-06-15)**: in Fit-to-previewer mode `s_out` is derived from the editor panel size, so the preview working-scale ceiling `2 × s_out` (FR-037) **floats as the user resizes the panel**. A resolution-sensitive effect using the standard built-in policy (Mosaic, PixelSort, contour Stroke/FlatShadow/PartsSplit, AutoClip, Dilate/Erode, custom SKSL/GLSL) and fed by a **concrete** (bitmap) source then renders at a working scale that **changes as the panel is resized**, so its preview is not stable across window sizes. To visually evaluate such an effect, select a **fixed Full / Half / Quarter** scale instead. This is a documented v1 limitation, not a defect; export (`+∞` ceiling) is unaffected. - **Export buffer size**: the encoder's source size must be derived from the actual rendered surface size, asserted equal before encode, so a scale change cannot cause a stride/size mismatch. - **Supersampling (s > 1, export)**: export may render at `s > 1` and downscale to the output resolution for anti-aliasing (FR-034) — the one case that reintroduces an explicit final-resample stage. `s > 1` is a first-class export path, not merely "must not break". - **Text at reduced scale**: glyphs must be **re-shaped** at the device scale, not matrix- or bitmap-scaled (hinting bakes resolution-specific grid-fitting). @@ -146,8 +146,8 @@ Selecting a layer, dragging a transform handle, and hit-testing all behave ident **Per-effect / brush / pen / text scale contract** -- **FR-008**: The system MUST apply a single uniform contract keyed on the **coordinate space** a value lives in (not merely on whether it is a "length"). *(Reframed 2026-06-09 — Codex review #3; the original "multiply every spatial-length parameter by `w`" wording caused double-scaling because most lengths are logical-space geometry the CTM already scales — see `contracts/effect-scale-contract.md`.)* **Logical-space geometry drawn under the root `CreateScale(w)` CTM** (shape/pen/text-layout/Skia-`SKImageFilter` args) is **left unchanged**; **device-buffer dimensions and device-space shader uniforms / pixel indexing** (`CustomEffect` buffer size, SKSL `iScale`/`fragCoord`, absolute-px literals, the tile/drawable intermediate raster) are **converted once (`× w`)**; **readback-derived geometry** (contour vertices traced from the device mask) is converted **device→logical (`÷ w`)**; **magnitude-invariant** parameters (color, angle, percentage, ratio, relative coordinate, 0..1 value, blend mode, count, enum) are left unchanged. The working scale `w` is the supply-driven scale the effect runs at (FR-036, NOT the output scale `s_out`). -- **FR-009**: For each built-in effect, the system MUST scale exactly the parameters enumerated in the per-effect matrix in `notes/rendering-analysis.md`, including parameters that also drive bounds math (e.g. a blur's `sigma × 3` inflation MUST use the scaled sigma). Pixel-magnitude scaling SHOULD be centralized in the effect context primitives so forwarding effects inherit it. The dossier matrix is **not yet exhaustive** — it omits the particle and audio-visualizer property sets (see FR-029/FR-030); `/speckit-plan` MUST complete it into a per-item test manifest before treating it as the source of truth. Each built-in effect runs at the supply-driven **working scale `w`** (FR-036) with no per-effect knob, and that scale MUST NOT change the `s_out = 1.0` output (golden-gated). +- **FR-008**: The system MUST apply a single uniform contract keyed on the **coordinate space** a value lives in (not merely on whether it is a "length"). *(Reframed 2026-06-09 — Codex review #3; the original "multiply every spatial-length parameter by `w`" wording caused double-scaling because most lengths are logical-space geometry the CTM already scales — see `contracts/effect-scale-contract.md`.)* **Logical-space geometry drawn under the root `CreateScale(w)` CTM** (shape/pen/text-layout/Skia-`SKImageFilter` args) is **left unchanged**; **device-buffer dimensions and device-space shader uniforms / pixel indexing** (`CustomEffect` buffer size, SKSL `iScale`/`fragCoord`, absolute-px literals, the tile/drawable intermediate raster) are **converted once (`× w`)**; **readback-derived geometry** (contour vertices traced from the device mask) is converted **device→logical (`÷ w`)**; **magnitude-invariant** parameters (color, angle, percentage, ratio, relative coordinate, 0..1 value, blend mode, count, enum) are left unchanged. The working scale `w` is the standard supply-driven result or the explicit custom filter-contract result at which the effect runs (FR-036), not necessarily the output scale `s_out`. +- **FR-009**: For each built-in effect, the system MUST scale exactly the parameters enumerated in the per-effect matrix in `notes/rendering-analysis.md`, including parameters that also drive bounds math (e.g. a blur's `sigma × 3` inflation MUST use the scaled sigma). Pixel-magnitude scaling SHOULD be centralized in the effect context primitives so forwarding effects inherit it. The dossier matrix is **not yet exhaustive** — it omits the particle and audio-visualizer property sets (see FR-029/FR-030); `/speckit-plan` MUST complete it into a per-item test manifest before treating it as the source of truth. Each built-in effect runs at the supply-driven **working scale `w`** (FR-036) with no per-effect knob, and that scale MUST NOT change the `s_out = 1.0` representative FR-005 golden set; transformed/scaled bitmap inputs retain the FR-005/FR-019 exemption. - **FR-010**: Brush parameters MUST follow the coordinate-space contract (FR-008): relative/percentage/0..1 brush parameters are unchanged (only the bounds they resolve against are device-scaled), and the **tile/image/drawable intermediate raster resolution is multiplied by the working scale** (a device-buffer dimension — A-1, `BrushConstructor`). `PerlinNoiseBrush.BaseFrequency` is **left unchanged** — `SkPerlinNoiseShader` follows the CTM so its period is already logical-invariant; dividing it by the scale was empirically shown to make the reduced-scale result *worse* (2026-06-09), so the earlier "÷ scale" rule was dropped. *(Reframed 2026-06-09 — Codex review #4: this previously said "BaseFrequency is divided by the scale", contradicting the corrected `effect-scale-contract.md`; PerlinNoise is a best-effort resolution-sensitive brush, FR-013.)* - **FR-011**: Pen stroke width, offset, and dash lengths MUST scale with render scale so strokes look identical at any scale; `MiterLimit`, caps/joins/alignment, and `Trim*` are unchanged. Cached stroke geometry MUST remain correct across render scales and MUST NOT reuse a stale-scale path — satisfied **either** by a scale-invariant logical outline (no scale in the cache key) **or** by keying the cache on scale. (The chosen design outlines strokes in logical space and scales them via the canvas transform, so the cached outline is scale-invariant and `PenHelper` is unchanged — research.md D3.) - **FR-012**: Text MUST be **re-shaped** at the device scale (font size, spacing, stroke thickness, and inline rich-text overrides scaled together); matrix-scaling or bitmap-upscaling shaped text is NOT permitted. The text shaping cache MUST be scale-aware. Hit-test paths (fill/stroke geometry) MUST stay in logical space. @@ -157,15 +157,28 @@ Selecting a layer, dragging a transform handle, and hit-testing all behave ident **Mixed-scale compositing** -- **FR-016**: Compositing MUST happen in logical coordinate space. Reconciliation is **distributed across the buffer-allocating boundaries, not a single central composite pass**: each effect / buffer boundary resolves its working scale `w = max(concrete input effective scales)` via `ResolveWorkingScale` (lossless / `Unbounded` inputs excluded — they regenerate at `w`; FR-036), and each off-`w` operation is reconciled at **its own** blit (FR-017). The output-scale cap applies **only at the root composite (final normalization)** — no built-in clamps an intermediate to `s_out` (FR-036), so an intermediate composite MAY exceed `s_out` (a preserved high-resolution source / SSAA) and MAY stay below `s_out` (a preserved proxy). The only **global** upper bound on an intermediate is the working-scale ceiling `MaxWorkingScale` (FR-037); additionally the per-buffer **dimension** clamp (FR-037(b), `ClampWorkingScaleToBufferBudget`, 16384 px per axis) may further reduce `w` at an effect boundary to keep the buffer allocatable — two distinct bounds, do not conflate them. *(Clarified 2026-06-10 — Codex branch review: the earlier "enforced at a single point (the render processor)" / "a container MUST composite at `max(concrete child)`" wording described a centralized composite the shipped pipeline does NOT have — `RenderNodeProcessor` Pulls and renders ops sequentially. The `max(concrete supply)` rule lives in `ResolveWorkingScale` at each boundary; per-op reconciliation is the `DrawSurface`/`DrawRenderTargetScaled` Mitchell blit. The only code that ever computed a container-wide `max(concrete child)` was `LayerRenderNode` — a SaveLayer flatten that allocates no buffer, is unreachable from any built-in, and now correctly reports `Unbounded` like the other SaveLayer wrappers. The logical-space invariant is unchanged; only the "single point" framing was inaccurate.)* +- **FR-016**: Compositing MUST happen in logical coordinate space. Reconciliation is **distributed across the buffer-allocating boundaries, not a single central composite pass**: a standard materializing effect / buffer boundary resolves `w = max(s_out, concrete input effective scales)` through `ResolveWorkingScale`, while an explicit custom filter contract may choose another finite positive `w` (lossless / `Unbounded` inputs impose no concrete supply and regenerate at the selected `w`; FR-036); each off-`w` operation is then reconciled at **its own** blit (FR-017). The output-scale upper normalization applies **only at the root composite** — no built-in clamps a denser intermediate down to `s_out` (FR-036), so an intermediate composite MAY exceed `s_out` (a preserved high-resolution source / SSAA), while a preserved proxy or explicit custom contract MAY stay below `s_out`. Every concrete working density remains subject to `MaxWorkingScale` (FR-037) and the per-buffer **dimension** clamp (FR-037(b), `ClampWorkingScaleToBufferBudget`, 16384 px per axis), which may further reduce `w` at an effect boundary to keep its actual footprint allocatable — two distinct bounds, do not conflate them. *(Clarified 2026-06-10 — Codex branch review: the earlier "enforced at a single point (the render processor)" / "a container MUST composite at `max(concrete child)`" wording described a centralized composite the shipped pipeline does NOT have — `RenderNodeProcessor` Pulls and renders ops sequentially. The standard `max(concrete supply)` rule lives in `ResolveWorkingScale` at each materializing boundary; per-op reconciliation is the `DrawSurface`/`DrawRenderTargetScaled` Mitchell blit. The only code that ever computed a container-wide `max(concrete child)` was `LayerRenderNode` — a SaveLayer flatten that allocates no buffer, is unreachable from any built-in, and now correctly reports `Unbounded` like the other SaveLayer wrappers. The logical-space invariant is unchanged; only the "single point" framing was inaccurate.)* - **FR-017**: An operation whose effective scale differs from the composite target MUST be reconciled exactly once, at the blit boundary where it enters the composite: a lossless (`Unbounded`) op is **regenerated** at the target; a bitmap op is **resampled** (Mitchell). Never per-effect and never per-child repeatedly. - **FR-018**: Each render operation MUST expose its **effective scale** as either **`Unbounded`** (vector content — shapes, geometry, text, Skia-filter results — re-rasterizable at any target) or a **concrete density** (images, video, decoded/proxy media, cached tiles, snapshots). `Unbounded` ops are regenerated at the composite target; concrete-scale ops are resampled (FR-017) and are never requested above their own density. The compositor keys on this value, not on a hard-coded type list. *(Replaces the earlier separate `LosslessReRasterizable` boolean — `Unbounded` subsumes it.)* - **FR-019**: Effect intermediates MUST carry a per-target **effective scale** (FR-018) so divergent-scale inputs are normalized to the negotiated working scale `w` exactly once (FR-017) before any shared filter, union, or flatten step (covering LayerEffect, DelayAnimationEffect, InnerShadow/Blend/Mosaic custom targets). Today's scale-blind `Union` of mixed-density targets is a correctness bug this fixes. **Coherent density model (2026-06-08 amendment):** (a) a **transform re-scales** a bitmap input's effective scale by the inverse of its scale factor — enlarging content lowers the density it affords, shrinking raises it (a 4K source dropped into a small box carries its extra detail into a downstream effect; an upscaled bitmap is not fake-sharpened) — projecting an anisotropic/rotated transform onto its most-detailed axis; (b) a bitmap-backed buffer (custom/flush/3D target) MUST report its **true `At(w)` density including `w = 1`**, never the re-rasterizable `Unbounded`. These make the model internally consistent at the cost of the former universal byte-identity-at-`s_out=1` guarantee (see SC-001/FR-005). **Working-scale negotiation (supply-driven)** -- **FR-036** *(amended 2026-06-15 — `s_out` is a working-scale FLOOR)*: Each buffer-allocating boundary (effect, container, sink) MUST compute a **working scale `w`** from its inputs' effective scales and run/allocate at `w`. The rule is **supply-driven on the high side, floored at the deliverable density**: `w = min( max(s_out, densest concrete input density), MaxWorkingScale )`. So a **denser** concrete supply runs **above** `s_out` (a 2.0 / 4K source stays 2.0 — `s_out` is **not** a ceiling, FR-016 preserved), and a **sub-output** concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is **floored to `w = 1.0`** so the effect renders at the deliverable density (matching the pre-feature renderer), not below it. Rationale: an effect's own working resolution (its blur kernel / shadow / shader grid) is a quantity distinct from the source's available detail — running it below `s_out` only discards resolution the delivery target can use, without fabricating any source detail. A genuine reduced-scale proxy is still cheap in **preview** (at a `0.5` preview a `0.5` proxy gives `max(0.5, 0.5) = 0.5`). Vector / `Unbounded` inputs impose no supply, so an all-vector boundary stays at the `s_out` floor; the former special-case "mixed bitmap+vector floor at `s_out`" is now just an instance of this universal floor (the conclusion is unchanged — crisp vector siblings are not dragged down — but it is no longer a special case). At `s_out = 1.0` with unit-scale / vector inputs `w = max(1, 1) = 1.0`, so the byte-identity anchor is untouched. `w` is bounded by the global working-scale ceiling `MaxWorkingScale` plus, at effect boundaries, the per-buffer dimension clamp (FR-037 — two distinct bounds, do not conflate them). There is **no per-effect resolution-policy knob** — every built-in runs supply-driven, and an effect that genuinely needs a different working scale (clamp-to-output for perf, oversample for SSAA) overrides `Process` in a `FilterEffectRenderNode` subclass returned from `FilterEffect.Resource.CreateRenderNode()`, computing `w` itself. *(Earlier drafts declared a per-effect `ResolutionPolicy` — `Inherit` / `ClampToOutput` / `Oversample(k)` / `PreserveSource`; no built-in ever needed a non-default value, and a custom render node is strictly more flexible, so the policy type was removed. Earlier drafts of this FR also said "a 0.5 proxy stays 0.5"; that pre-floor wording is superseded.)* Out-of-tree effects are supply-driven by default and MUST render byte-identically at `s_out = 1.0`. -- **FR-037** *(export ceiling removed 2026-06-15 — see below)*: A configurable **global working-scale ceiling** MAY cap `w` (`w = min(w, MaxWorkingScale)`) on the high side (it never reduces `w` below a proxy's supply) and MUST be inert at `s_out = 1.0` with unit-scale inputs (byte-identity preserved). It is a **per-render-request** value: the **preview default is `2 × s_out`** (seeded at the editor preview `SceneRenderer`) to bound interactive working scale; **export imposes NO working-scale quality ceiling — it is `+∞`** (`MaxWorkingScale = float.PositiveInfinity`, seeded at `OutputViewModel`), so a deliberately-authored high-density source exports at full fidelity (FR-013). *(Amended 2026-06-15: the earlier finite export ceiling `max(8, 4 × s_out)` was **removed** — it was a quality clip masquerading as an OOM backstop, silently discarding detail from any source **denser than the ceiling** (e.g. a 4096-px logo in a 256-px box has supply ≈ 16, far above the `8` floor of the old `max(8, ·)` and well below any allocation limit). The "high enough never to clip a legitimate high-resolution source" claim was therefore false; export now never clips on quality grounds.)* **OOM-safety on export** is provided by the per-buffer **dimension** clamp (below) plus the documented request-scoped aggregate byte/area budget (follow-up), **not** a working-scale ceiling. **Two distinct bounds, do not conflate them** *(clarified 2026-06-09 — Codex reviews #1/#4; sharpened 2026-06-10: the two quantities scale by different powers of `w`)*: (a) `MaxWorkingScale` bounds the **working scale** `w` itself (preview only; export `+∞`); (b) a separate per-buffer **dimension** clamp (`RenderNodeContext.ClampWorkingScaleToBufferBudget`, `MaxBufferDimension = 16384`) is the **sole allocatability bound** and bounds the **per-axis device dimension** `axis_px = logical_axis × w` — what makes a buffer un-allocatable past the GPU 2D-image limit. It uses **each buffer's own bounds**, so a small dense element exports at full fidelity while only a genuinely over-large buffer is reduced. An **anisotropic** transform projected onto its most-detailed axis (FR-019) inflates the stretched-axis dimension while raising density, so this clamp reduces `w` at the **effect boundary** (`FilterEffectRenderNode` / its `FilterEffectActivator.Flush`, the only sink that allocates by a transform-rescaled per-op density — re-clamped there against the post-effect-inflated bounds so a downstream blur/shadow cannot re-inflate past the limit) so the effect buffer stays allocatable; if allocation still fails, preview logs and drops the target, while delivery/export (`MaxWorkingScale = +∞`) fails fast instead of producing a layer-missing artifact. Buffer **memory** is a separate quantity — it scales as `area × w² × bytes/px`, which the dimension clamp does **not** bound. This bounds the density-driven *allocatability* blow-up; it is **not** a total cross-sink memory budget (a single buffer may still approach `16384² × 8 ≈ 2 GiB`). **Non-effect sink status (updated 2026-06-15 — S4):** the other sinks are NOT uniformly unguarded, and 003 — not "pre-existing" alone — added the `× w` / `× s_out` multiplier that made several of them growable: **particles** (`ParticleRenderNode`) and **3D** (`Scene3DRenderNode`) now apply `ClampWorkingScaleToBufferBudget` at the same `MaxBufferDimension`, so they degrade (softer sprites / a dropped 3D frame) instead of silently vanishing or crashing — 3D additionally wraps its `Renderer3D` allocation in a try/catch (its `CreateTexture2D` throws past the GPU limit, unlike the 2D path); **brush/tile intermediates** (`BrushConstructor`) allocate through `RenderTarget.Create`; preview degrades to a logged solid-white fill on failure, while delivery/export (`MaxWorkingScale = +∞`) fails fast (not clamped — the `s`/`1/s`/content-density coupling makes an in-place clamp risky); **`RenderNodeProcessor` rasterization** and the **root surface** size by `s_out` / authored dimensions and allocate through `RenderTarget.Create`'s try/catch (→ null degrade), with the export root additionally pre-validated in `OutputViewModel`. The remaining true gap is that `MaxBufferDimension` is a hard-coded `16384` rather than the backend-reported `maxImageDimension2D`, so a sub-16384 backend (mobile, non-target) is only protected by the degrade paths, not the clamp. A request-scoped allocator with backend-reported limits + a byte/area + live-buffer budget is the complete fix and remains a follow-up. **Preview/export ceiling divergence (explicit):** because the preview ceiling (`2 × s_out`) is finite while the export ceiling is `+∞` *(updated 2026-06-15)*, a scene whose supply density exceeds `2 × s_out` renders its resolution-sensitive effects at a **lower (capped) working scale in Full preview than in export** (where they run at the full supply density); there is no mismatch-warning UI in v1, and the former `RenderScale.Full` "byte-identical to export" claim was removed from the code docs. +> **Feature 004 clarification (supersedes the universal-floor wording below for custom filter render nodes):** +> FR-036 defines the standard `MaterializeAtWorkingScale` behavior used by built-ins. A +> `FilterEffectRenderNode.GetWorkingScaleContract()` override may instead return an explicit `Custom` density below +> `s_out`; it remains subject to `MaxWorkingScale` and per-buffer bounds clamps but is not raised to the standard +> floor. The callback is evaluated per surviving branch with one input supply and the isolated effect-input bounds. +> Legacy multi-input work aggregates the densest concrete mapped result, falls back to `s_out` only when all mapped +> results are `Unbounded`, and tracks branch-local transformed buffers until the first opaque `Custom` callback. +> The forced compatibility materialization immediately before that callback removes renderer-owned aprons and +> presents each surviving target through the historical dimension-sized local backing. The callback then collapses +> its transformed branch results into an aggregate semantic domain for subsequent analysis while retained backing +> keeps its local origin and direct final placement. +> A no-item effect commits no isolation/contract fragment; an unprobed hook/resolver remains lazy. + +- **FR-036** *(amended 2026-06-15 — `s_out` is a working-scale FLOOR)*: Each buffer-allocating boundary (effect, container, sink) MUST compute a **working scale `w`** from its inputs' effective scales and run/allocate at `w`. The standard rule is **supply-driven on the high side, floored at the deliverable density**: `w = min( max(s_out, densest concrete input density), MaxWorkingScale )`. So a **denser** concrete supply runs **above** `s_out` (a 2.0 / 4K source stays 2.0 — `s_out` is **not** a ceiling, FR-016 preserved), and a **sub-output** concrete supply (an enlarged / low-density bitmap, `At(0.5)`) feeding an effect at a `1.0` export is **floored to `w = 1.0`** so the effect renders at the deliverable density (matching the pre-feature renderer), not below it. Rationale: an effect's own working resolution (its blur kernel / shadow / shader grid) is a quantity distinct from the source's available detail — running it below `s_out` only discards resolution the delivery target can use, without fabricating any source detail. A genuine reduced-scale proxy is still cheap in **preview** (at a `0.5` preview a `0.5` proxy gives `max(0.5, 0.5) = 0.5`). Vector / `Unbounded` inputs impose no supply, so an all-vector boundary stays at the `s_out` floor; the former special-case "mixed bitmap+vector floor at `s_out`" is now just an instance of this universal floor (the conclusion is unchanged — crisp vector siblings are not dragged down — but it is no longer a special case). At `s_out = 1.0` with unit-scale / vector inputs `w = max(1, 1) = 1.0`, so the FR-005 representative golden set remains byte-identical. Transform-rescaled or scaled-bitmap-into-effect content retains the explicit FR-005/FR-019 exemption and can intentionally use a non-unit supply density. `w` is bounded by the global working-scale ceiling `MaxWorkingScale` plus, at effect boundaries, the per-buffer dimension clamp (FR-037 — two distinct bounds, do not conflate them). There is **no closed per-effect resolution-policy enum**. Every built-in uses this standard contract. An out-of-tree effect that genuinely needs a different working scale (clamp-to-output for perf, oversample for SSAA) may override `GetWorkingScaleContract()` in a `FilterEffectRenderNode` subclass returned from `FilterEffect.Resource.CreateRenderNode()`. The base MUST fold that contract into the first surviving Shader, Geometry, or legacy operation without an identity fragment or extra pass, MUST preserve a true pass-through when no items are authored, and MUST reevaluate the pure contract after symbolic owning-domain resolution. `FilterEffectContext.TryGetWorkingScale` MUST return `false` (and `WorkingScale` MUST throw) while the effect-input density is symbolic or branch-dependent; an available value is nominal and a later expanded output remains subject to the per-buffer clamp. Override `Process` only for genuinely different topology or lowering. *(Earlier drafts declared a per-effect `ResolutionPolicy` — `Inherit` / `ClampToOutput` / `Oversample(k)` / `PreserveSource`; the closed policy type was unnecessary, and a custom render node is strictly more flexible, so the policy type was removed. Earlier drafts of this FR also said "a 0.5 proxy stays 0.5"; that pre-floor wording is superseded.)* Out-of-tree effects use this supply-driven default and receive the same FR-005/FR-019 byte-identity scope; they are not guaranteed byte-identical for exempt transformed/scaled bitmap inputs. +- **FR-037** *(export ceiling removed 2026-06-15 — see below)*: A configurable **global working-scale ceiling** MAY cap `w` (`w = min(w, MaxWorkingScale)`) on the high side; a positive ceiling is authoritative and may reduce `w` below either `s_out` or an input's supply. It MUST be inert at `s_out = 1.0` with unit-scale inputs under the default policy (byte-identity preserved). It is a **per-render-request** value: the **preview default is `2 × s_out`** (seeded at the editor preview `SceneRenderer`) to bound interactive working scale; **export imposes NO working-scale quality ceiling — it is `+∞`** (`MaxWorkingScale = float.PositiveInfinity`, seeded at `OutputViewModel`), so a deliberately-authored high-density source exports at full fidelity (FR-013). *(Amended 2026-06-15: the earlier finite export ceiling `max(8, 4 × s_out)` was **removed** — it was a quality clip masquerading as an OOM backstop, silently discarding detail from any source **denser than the ceiling** (e.g. a 4096-px logo in a 256-px box has supply ≈ 16, far above the `8` floor of the old `max(8, ·)` and well below any allocation limit). The "high enough never to clip a legitimate high-resolution source" claim was therefore false; export now never clips on quality grounds.)* Export has a per-buffer **per-axis** safeguard, not a complete OOM guarantee. The request-scoped aggregate byte/area/live-buffer budget and backend-reported image limit remain explicit follow-up work outside feature 003. **Two distinct bounds, do not conflate them** *(clarified 2026-06-09 — Codex reviews #1/#4; sharpened 2026-06-10: the two quantities scale by different powers of `w`)*: (a) `MaxWorkingScale` bounds the **working scale** `w` itself (preview only; export `+∞`); (b) a separate per-buffer **dimension** clamp (`RenderScaleUtilities.ClampWorkingScaleToBufferBudget`, `MaxBufferDimension = 16384`) bounds the **per-axis device dimension** `axis_px = logical_axis × w`; it does not bound aggregate memory, per-buffer area/bytes, simultaneous live buffers, or a backend limit below 16384. It uses **each buffer's own bounds**, so a small dense element exports at full fidelity while only a genuinely over-large buffer is reduced. An **anisotropic** transform projected onto its most-detailed axis (FR-019) inflates the stretched-axis dimension while raising density, so this clamp reduces `w` at the **effect boundary** (`FilterEffectRenderNode` / its `FilterEffectActivator.Flush`, the only sink that allocates by a transform-rescaled per-op density — re-clamped there against the post-effect-inflated bounds so a downstream blur/shadow cannot re-inflate past the limit) so the effect buffer stays allocatable; if allocation still fails, preview logs and drops the target, while delivery/export (`MaxWorkingScale = +∞`) fails fast instead of producing a layer-missing artifact. Buffer **memory** is a separate quantity — it scales as `area × w² × bytes/px`, which the dimension clamp does **not** bound. This bounds the density-driven *allocatability* blow-up; it is **not** a total cross-sink memory budget (a single buffer may still approach `16384² × 8 ≈ 2 GiB`). **Non-effect sink status (updated 2026-06-15 — S4):** the other sinks are NOT uniformly unguarded, and 003 — not "pre-existing" alone — added the `× w` / `× s_out` multiplier that made several of them growable: **particles** (`ParticleRenderNode`) and **3D** (`Scene3DRenderNode`) now apply `ClampWorkingScaleToBufferBudget` at the same `MaxBufferDimension`, so they degrade (softer sprites / a dropped 3D frame) instead of silently vanishing or crashing — 3D additionally wraps its `Renderer3D` allocation in a try/catch (its `CreateTexture2D` throws past the GPU limit, unlike the 2D path); **brush/tile intermediates** (`BrushConstructor`) allocate through `RenderTarget.Create`; preview degrades to a logged solid-white fill on failure, while delivery/export (`MaxWorkingScale = +∞`) fails fast (not clamped — the `s`/`1/s`/content-density coupling makes an in-place clamp risky); **`RenderNodeProcessor` rasterization** and the **root surface** size by `s_out` / authored dimensions and allocate through `RenderTarget.Create`'s try/catch (→ null degrade), with the export root additionally pre-validated in `OutputViewModel`. The remaining true gap is that `MaxBufferDimension` is a hard-coded `16384` rather than the backend-reported `maxImageDimension2D`, so a sub-16384 backend (mobile, non-target) is only protected by the degrade paths, not the clamp. A request-scoped allocator with backend-reported limits + a byte/area + live-buffer budget is the complete fix and remains a follow-up. **Preview/export ceiling divergence (explicit):** because the preview ceiling (`2 × s_out`) is finite while the export ceiling is `+∞` *(updated 2026-06-15)*, a scene whose supply density exceeds `2 × s_out` renders its resolution-sensitive effects at a **lower (capped) working scale in Full preview than in export** (where they run at the full supply density); there is no mismatch-warning UI in v1, and the former `RenderScale.Full` "byte-identical to export" claim was removed from the code docs. **Caching, backdrop, nested renders** @@ -200,8 +213,8 @@ Selecting a layer, dragging a transform handle, and hit-testing all behave ident ### Key Entities -- **Output scale (`s_out`)**: a uniform factor (default 1.0) on a render request; the **final normalization target** and the **working-scale floor** — it never *clamps* an intermediate effect from above (a denser supply runs higher, FR-016/FR-036), but an effect never runs *below* it (FR-036, amended 2026-06-15). **Preview** uses a fixed enum — Full (1.0) / Half (0.5) / Quarter (0.25) / Fit-to-previewer (derived, ≤ 1.0) — held as **per-edit-view session state**, never persisted (FR-035). **Export** uses 1.0, or `s_out > 1` for supersampled anti-aliasing with a final downscale to the output resolution (FR-034). -- **Working scale (`w`)**: the scale a given effect / boundary actually rasterizes at: `w = min( max(s_out, densest concrete input density), MaxWorkingScale )` — supply-driven above the `s_out` floor (FR-036) and capped by the global ceiling (FR-037; preview `2 × s_out`, export `+∞`). Values follow the coordinate-space rule: logical-space geometry under the CTM is unchanged; device-buffer dimensions and device-space shader uniforms are converted once (`× w`); readback-derived geometry converts back (`÷ w`) (FR-008). +- **Output scale (`s_out`)**: a uniform factor (default 1.0) on a render request; the **final normalization target** and `MaterializeAtWorkingScale`'s **fallback/floor**. It never *clamps* an intermediate effect from above (a denser supply runs higher, FR-016/FR-036), while an explicit `RenderScaleContract.Custom` filter contract may intentionally run below it. **Preview** uses a fixed enum — Full (1.0) / Half (0.5) / Quarter (0.25) / Fit-to-previewer (derived, ≤ 1.0) — held as **per-edit-view session state**, never persisted (FR-035). **Export** uses 1.0, or `s_out > 1` for supersampled anti-aliasing with a final downscale to the output resolution (FR-034). +- **Working scale (`w`)**: the scale a given effect / boundary actually rasterizes at. `MaterializeAtWorkingScale` uses `w = min( max(s_out, densest concrete input density), MaxWorkingScale )`; an explicit `RenderScaleContract.Custom` filter contract may choose another finite positive value, including below `s_out`. Both are capped by the global ceiling (FR-037; preview `2 × s_out`, export `+∞`) and clamped against each concrete allocation footprint's dimension budget. Values follow the coordinate-space rule: logical-space geometry under the CTM is unchanged; device-buffer dimensions and device-space shader uniforms are converted once (`× w`); readback-derived geometry converts back (`÷ w`) (FR-008). - **Logical frame size**: the resolution-independent project canvas (`Scene.FrameSize`), the unit anchor (`1 unit = 1 px at this size`). - **Device target size**: `ceil(FrameSize × s)`; the sole physical pixel buffer size of the main frame. - **Render-node context scale**: the top-down channel carrying the active scale to every node's allocation decisions. @@ -231,7 +244,7 @@ The following are reasonable defaults chosen where the description did not speci - **Render scale is uniform `float` in v1**, with storage chosen to widen to a vector later (anisotropic scale is out of scope here). - **Logical unit = 1 px at FrameSize**, so no file migration is needed (confirmed by the maintainer). Effect properties currently typed in pixel units (e.g. color-shift offsets) are reinterpreted as logical at scale 1.0, preserving their values. - **Resolution-sensitive effects use parameter scaling only** (confirmed: "プロパティにスケールを乗算する"); reduced-scale preview is best-effort, no force-full-scale subtree mechanism and no warning UI in v1. -- **Mixed-scale composites at `max` concrete child scale** (confirmed), using Mitchell resampling; the output-scale cap is **deferred to the final root normalization** per the supply-driven model — it does not clamp intermediates (FR-016/FR-036); the only intermediate bounds are FR-037's two: the global working-scale ceiling plus the per-buffer dimension clamp. +- **Standard materializing composites at `max(s_out, concrete child scales)`** (confirmed), using Mitchell resampling; the output-scale upper normalization is **deferred to the final root** — it does not down-clamp denser intermediates (FR-016/FR-036). An explicit custom filter contract may select another finite positive density, including below `s_out`; either result remains subject to FR-037's global working-scale ceiling and per-buffer dimension clamp. - **Proxy decode is out of scope** for this feature (confirmed); only the render-scale plumbing and the additive extensibility of `MediaOptions` are delivered. - **Render cache invalidates on scale change** (simple, correct default) rather than maintaining per-scale multi-entry caches; this may be revisited for scrubbing UX. - **Text keeps current hinting** and is re-shaped at the device scale; reduced-scale text preview is therefore perceptually faithful but not necessarily bit-identical, consistent with the resolution-sensitive policy. diff --git a/docs/specs/004-gpu-pass-fusion/checklists/requirements.md b/docs/specs/004-gpu-pass-fusion/checklists/requirements.md new file mode 100644 index 0000000000..742566df77 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Renderer-Wide GPU Pass Fusion + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-19 +**Feature**: [spec.md](../spec.md) +**Validated against**: `spec.md` at `95766e7d3`; the spec has been amended since (starting with `d803801fb`) and this checklist has not been re-run. + +## Content Quality + +- [x] Implementation detail is limited to the user-mandated public API outcomes needed to make the contract testable +- [x] Focused on user value and business needs +- [x] Written for technical product, engine, and plugin stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are measurable and name implementation surfaces only where the requested public contract requires them +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [~] Feature meets measurable outcomes defined in Success Criteria — every criterion is stated measurably and all but two are demonstrated; SC-007's comparison against committed current-main references and SC-008's performance confidence interval are not reproducible from this branch. See the notes below. +- [x] Named APIs in the specification correspond to deliberate public extensibility outcomes, with internal type shapes deferred to planning + +## Notes + +- Validation completed in three refinement iterations; no `[NEEDS CLARIFICATION]` markers remain. +- The exact `void RenderNode.Process(RenderNodeContext)` direction and existing public API names appear because the user fixed those public extensibility outcomes. Helper overloads and internal operation, compiler, executor, cache, and resource type shapes remain planning decisions. +- Independent source inventory, donor-evidence, and public-design reviews passed after covering every existing render-node graph shape, breaking migration, recording purity, nested-request continuity, cache behavior, Shader and Geometry semantics, 3D boundaries, lifetime, and measurable outcomes. +- *Amended.* "All functional requirements have clear acceptance criteria" certified FR-043 with its committed paired visual-evidence apparatus intact. That half of FR-043 — the pinned starting-SHA baseline, the fingerprinted RGBA16F references and manifests, and the committed paired runner — was withdrawn after this gate with the evidence tree (tasks T005–T007, T016, T019, T020, T114, T115, T123); see the FR-043 note in `spec.md` and T123 in `tasks.md`. The box stays ticked: the criteria were clear when the gate ran. +- *Amended.* "Feature meets measurable outcomes defined in Success Criteria" predates the FR-043, SC-007 and SC-008 amendments in `spec.md`, so it is no longer a plain tick. Two criteria are stated measurably but not demonstrated from what this branch commits: SC-007's comparison against provenance-verified current-main references, whose fingerprinted manifests went with the evidence tree, and SC-008's performance confidence interval, which is measurable on demand via `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` and is not a merge gate. Phase 2's checkpoint status in `tasks.md` records the same limitation, and FR-043 was narrowed to match rather than left as an unmet MUST. Everything else in the Success Criteria is demonstrated by the committed suites. +- *Amended.* "Named APIs in the specification correspond to deliberate public extensibility outcomes" certified spec text that still names `RenderScaleContract.MapInputSupply(TState state, Func map, structuralKey)`, a state-first shape that never shipped; the delivered pair is the bidirectional `MapInputSupply(map, mapOutputDemandToInput)` and the forward-only `MapInputSupplyPreservingDemand(map)`. See the FR-030 note in `spec.md`, with `contracts/public-api.md` and `contracts/breaking-changes.md` as the normative record. This box certified the 2026-07-19 spec text, not the delivered signatures. diff --git a/docs/specs/004-gpu-pass-fusion/contracts/breaking-changes.md b/docs/specs/004-gpu-pass-fusion/contracts/breaking-changes.md new file mode 100644 index 0000000000..d4930cf6df --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/contracts/breaking-changes.md @@ -0,0 +1,872 @@ +# Breaking Changes and Migration Contract + +## Summary + +BREAKING CHANGE: render-node work is now recorded through `void RenderNode.Process(RenderNodeContext)`. Nodes publish transaction-scoped fragment handles; they do not receive an immediate canvas, return an operation, or control retained output state directly. + +BREAKING CHANGE: public callback authoring now uses immutable `*Definition` objects and per-recording `.Call(state, bindings)` values. The former public callback-record construction path is no longer an authoring API. + +BREAKING CHANGE: `RenderNode.HasChanges` is the only public content-invalidation signal. A node sets it when its pixel-, metadata-, or topology-affecting state changes. No public API accepts caller-supplied cache identity, resource content metadata, or a manual operation fingerprint. + +The affected public surface is mostly in `Beutl.Engine`, plus `Beutl.ProjectSystem`'s `SceneRenderer`, which now takes its render intent as a required argument. In-tree consumers in `Beutl.Editor`, `Beutl.NodeGraph`, `Beutl.ProjectSystem`, `Beutl.AgentToolkit`, the application, and the test/benchmark hosts have already migrated, but out-of-tree render-node, filter-effect, geometry, mesh, renderer, target-factory, brush-construction, and graphics-backend code must apply the recipes below. Anything implementing `IGraphicsContext`, `IRenderPass3D`, or the other backend interfaces has to be recompiled even where its own source is unchanged, because those contracts gained members and lost a default. + +The branch records the public break in `35e7f28b0` (`refactor(engine)!: record then plan the render pipeline and fuse GPU passes`) and the later target-factory/brush additions in `699332cc5` (`feat(engine)!: expose drawable-brush materialization and the cache opt-out`). The remaining eighteen each carry their own footer as well: `999ad728f`, `991f49e70`, `ee507067d`, `2974a6073`, `6dfd0f2d3`, `66cd2dc4c`, `7e2d928b5`, `48318a60f`, `70479b19f`, `a619d8046`, `3c33795ab`, `d53b155e8`, `449e71258`, `c8314e40f`, `6857dfa98`, `def8dcb1b`, `66dc0486b` and `c9ab89352`, documented in the sections below. All twenty contain a literal `BREAKING CHANGE:` footer, so no history rewrite is required. Keep this list and the count current when a new `!` commit lands on the branch; a squash merge takes its footer from the pull request description, not from these messages, so the description is the only place the changelog reads. + +`main` is squash-only, so the single commit that lands there is built from the pull request's title and body, not from any of those messages. The footer that reaches changelog tooling is therefore the one in the **pull request description**; a branch full of correctly footed commits does not supply it. Keep a `BREAKING CHANGE:` footer in the description that names `Beutl.Engine` and summarises the migrations below, and update it whenever a new breaking commit is added to the branch. + +## Removed executable surface + +The following executable pull model has no compatibility shim: + +- `RenderNodeOperation`, including subclassing, disposal, `Render`, `HitTest`, and the `CreateLambda`, `CreateDecorator`, `CreateFromRenderTarget`, and `CreateFromSurface` factories; +- `RenderNodeOperation[] RenderNode.Process(RenderNodeContext)`; +- `RenderNode.PrepareForProcess(ImmediateCanvas)`; +- public construction or subclassing of `RenderNodeContext`; +- mutable `RenderNodeContext.Input`, `CalculateBounds()`, and the `IsRenderCacheEnabled` setter; +- the static scale helpers on `RenderNodeContext`; +- `RenderNodeProcessor`, including `Pull`, `PullToRoot`, the list-returning rasterizers, and the protected `CreateRenderTarget` override; +- `OperationWrapperRenderNode` and `SetOperations`; +- `EffectTarget(RenderNodeOperation)` and `EffectTarget.NodeOperation`; +- direct public access to `RenderNode.Cache`, `RenderNodeCache`, and `RenderNodeCacheHelper`. + +The replacements are `void Process`, the sealed engine-created `RenderNodeContext`, transaction-scoped `RenderFragmentHandle` values, declarative fragment recording, `RenderNodeRenderer`, and request-owned or borrowed resources. `EffectTarget()` and `EffectTarget(RenderTarget, Rect, EffectiveScale)` remain for source-less and caller-materialized filter-effect work. + +`RenderNodeContext.Inputs` is read-only. Use `TryCalculateInputBounds(out Rect)` instead of `CalculateBounds()`, `DisableRenderCache()` instead of assigning `IsRenderCacheEnabled = false`, and `RenderScaleUtilities` for `MaxBufferDimension`, `SanitizeMaxWorkingScale`, `ResolveWorkingScale`, and `ClampWorkingScaleToBufferBudget`. + +## Migration rules + +### Core node migration + +Before, a node could prepare immediate work or return an operation. After, it records and publishes fragments: + +```csharp +public sealed class PassthroughNode : RenderNode +{ + public override void Process(RenderNodeContext context) + { + context.PassThrough(); + } +} +``` + +`Inputs` is read-only and ordered. Use `TryCalculateInputBounds(out Rect)` and handle its `false` result when an input still depends on an enclosing target domain. Fragment handles expose metadata and hit testing only through their availability-checked APIs and cannot outlive `Process`. + +Set `HasChanges` at the point the node's observable state changes: + +```csharp +public float Opacity +{ + get => _opacity; + set + { + if (_opacity == value) + return; + + _opacity = value; + HasChanges = true; + } +} +``` + +### Publication migration + +Publication is explicit. Record methods return a handle but do not make it an output. + +```csharp +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + _opacity, + static (current, input, opacity) => current.Opacity(input, opacity)); +} +``` + +`PublishMappedInputs` maps every input to exactly one output in the same order. It is the appropriate replacement for a simple independent one-to-one loop. An empty input collection invokes no callback and publishes no output. A mapper may record intermediate fragments, but must not call a publication method itself; that is rejected and rolls back the whole node transaction. + +Use `PassThrough`, `Publish`, or `PublishRange` directly for intentional no-output, selection, reorder, combination, expansion, nested work, or target-effect placement. + +Publishing nothing is an intentional zero-output result; there is no implicit pass-through: + +```csharp +public override void Process(RenderNodeContext context) +{ + if (!_isEnabled) + return; + + context.PassThrough(); +} +``` + +### Recording-time metadata + +Fragment metadata may remain symbolic until the enclosing target domain is known. Replace unconditional operation properties with availability checks: + +```csharp +public override void Process(RenderNodeContext context) +{ + bool hasAggregateBounds = context.TryCalculateInputBounds(out Rect aggregateBounds); + + foreach (RenderFragmentHandle input in context.Inputs) + { + bool hasMetadata = input.TryGetMetadata(out RenderFragmentMetadata metadata); + bool hasHitTest = input.TryHitTest(_point, out bool hit); + + RecordWithoutAssumingMetadata( + input, + hasAggregateBounds ? aggregateBounds : null, + hasMetadata ? metadata : null, + hasHitTest ? hit : null); + } +} +``` + +An unavailable value is not permission to drop or pass through an input. Record bounds, hit-test, and scale contracts that can be reevaluated after graph-wide resolution. `ValueCardinality`, `ContributesValuesToTarget`, and `CanBeUsedAsValueInput` remain directly readable on an active handle. + +### Nested recording and retained wrappers + +Do not retain fragment handles in fields. They are valid only during the active `Process` transaction. Replace retained `OperationWrapperRenderNode` operations with nested recording: + +```csharp +public override void Process(RenderNodeContext context) +{ + IReadOnlyList outputs = + context.RecordNode(_child, context.Inputs); + context.PublishRange(outputs); +} +``` + +Use `RecordSubtree(root)` when the nested root should record its own descendants. `RecordNode(node, inputs)` remaps the supplied handles into a child transaction and remaps the child outputs back into the caller. A wrapper that references but does not own a child can use `ReferencesChildRenderNode`; disposing that wrapper does not dispose the referenced child. + +### Materialized input + +Replace `RenderNodeOperation.CreateFromRenderTarget` with an explicit resource lifetime and physical footprint: + +```csharp +public override void Process(RenderNodeContext context) +{ + RenderResource target = context.Borrow(_target); + var description = MaterializedInputDescription.FromRenderTarget( + target, + _bounds, + _effectiveScale, + _deviceBounds, + _deviceGridOffset, + RenderHitTestContract.OutputBounds); + + context.Publish(context.MaterializedInput(description)); +} +``` + +`Borrow` leaves disposal with the caller, which must keep the target alive and unchanged through execution. `Own` transfers a disposable object to the request family. Neither method accepts a cache identity or version; persistent reuse follows `HasChanges`, child dependencies, and request cache policy. The declared `PixelRect` and device-grid offset are the target's actual physical footprint, not values to reconstruct from logical bounds. + +### Source, combine, and expansion nodes + +A source records deferred work without touching media, GPU objects, or native resources during `Process`: + +```csharp +private static readonly OpaqueRenderDefinition s_source = + OpaqueRenderDefinition.Create( + static (session, color) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(color)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 64, 64)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + +public override void Process(RenderNodeContext context) +{ + context.Publish(context.OpaqueSource(s_source.Call(_color))); +} +``` + +Use `OpaqueCombine(inputs, call)` for many-to-one work and `OpaqueExpand(inputs, call)` for runtime N-to-M work. Every input must be value-eligible. If an ordered stream contains target effects, wrap it intentionally with `Layer(inputs, finiteDomain)` or `OwningTargetLayer(inputs)` before passing it to a value consumer; do not silently discard its effects. The definition must declare aggregate bounds, hit testing, scale, and a compatible cardinality (`Single` for one combined output or `Dynamic` for an expansion). An empty runtime expansion is zero output, not identity. + +### Target command, capture, and scope + +Guarded target work uses the same definition/call split: + +```csharp +private static readonly TargetScopeDefinition s_opacityScope = + TargetScopeDefinition.Create( + static (session, opacity) => session.Canvas.Use(canvas => + { + using (canvas.PushOpacity(opacity)) + session.ReplayInput(); + }), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply); + +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + _opacity, + static (current, input, opacity) => + current.TargetScope(input, s_opacityScope.Call(opacity))); +} +``` + +`TargetCommandDefinition` declares its affected `TargetRegion`, independent query bounds, hit testing, access, per-input readback selectors, and resource slots. `TargetScopeDefinition` surrounds exactly one input and must call `ReplayInput()` exactly once. Raw variants are explicit opaque-external boundaries and are never persistently reusable. + +A target capture is a value read, not an implicit redraw: + +```csharp +RenderFragmentHandle capture = context.TargetCapture( + TargetCaptureDescription.Create( + TargetRegion.Region(_bounds), + _bounds, + RenderHitTestContract.None, + TargetCaptureScaleContract.PreserveTargetSupply)); + +RenderFragmentHandle filtered = context.Shader(capture, s_tint.Call(_tint)); +context.Publish(context.ContributeValues(filtered)); +``` + +Use `TargetLayerScope(inputs, TargetRegion.Full)` for an ordered current-target isolation that remains non-value-eligible. Use `Layer` or `OwningTargetLayer` when the intentional result is one materializable value for a later Shader, Geometry, or opaque value operation. + +### Cache migration + +`RenderNodeCache` and `RenderNodeCacheHelper` are engine-internal. `MakeCache`, `CreateDefaultCache`, `CanCacheRecursiveChildrenOnly`, `RejectCache`, `IsCacheRejected`, `StoreCache`, `UseCache`, and direct cache density/state inspection are no longer plugin APIs. + +Choose persistent caching per request with `RenderNodeRenderRequest.CacheOptions`. `RenderCacheOptions.Default` is disabled; callers that require it must select `RenderCacheOptions.Enabled` or construct `RenderCacheOptions` with explicit rules. A node reports content changes through `HasChanges`. A node that dynamically records a child it cannot list in `ChildNodes` must call `context.DisableRenderCache()` during that transaction. + +### Callback migration + +#### Guarded opaque work + +Put callback code and fixed metadata in a reusable definition. Put values and tokens for this recording in the call. + +```csharp +private sealed record DrawState(float Opacity); + +private static readonly RenderResourceSlot s_brush = new(); + +private static readonly OpaqueRenderDefinition s_draw = + OpaqueRenderDefinition.Create( + static (session, state) => session.UseResource( + s_brush, + brush => Draw(session, brush, state.Opacity)), + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply, + resources: [s_brush]); + +public override void Process(RenderNodeContext context) +{ + RenderResource brush = context.Borrow(_brush); + OpaqueRenderCall call = s_draw.Call( + new DrawState(_opacity), + [s_brush.Bind(brush)]); + + context.PublishMappedInputs( + call, + static (current, input, recordedCall) => current.OpaqueMap(input, recordedCall)); +} +``` + +Use `OpaqueSource`, `OpaqueMap`, `OpaqueCombine`, or `OpaqueExpand` according to the fixed topology in the definition. Reusing a static/shared definition avoids needless allocation, but equivalent definitions recreated later still share the engine-derived plan; no manual identifier or singleton lifetime is required. + +#### Target work + +Use `TargetScopeDefinition` for one guarded replay scope and `TargetCommandDefinition` for a guarded current-target command. Declare bounds, hit testing, scale where applicable, target region/access, readback behavior, and resource slots in the definition; invoke it through `.Call`. + +Raw canvas behavior has matching generic definitions. It remains opaque external work, but its binding schema is still checked: + +```csharp +private sealed record RawState(RenderResource Backdrop); + +private static readonly RenderResourceSlot s_backdrop = new(); + +private static readonly RawTargetCommandDefinition s_command = + RawTargetCommandDefinition.Create( + static (session, state) => session.UseResource( + state.Backdrop, + backdrop => backdrop.Draw(session.Canvas)), + queryBounds: new Rect(0, 0, 1, 1), + hitTest: RenderHitTestContract.None, + resources: [s_backdrop]); + +public override void Process(RenderNodeContext context) +{ + RenderResource backdrop = context.Borrow(_backdrop); + context.Publish(context.RawTargetCommand( + s_command.Call(new RawState(backdrop), [s_backdrop.Bind(backdrop)]))); +} +``` + +For a raw scope, use `RawTargetScopeDefinition` and call `ReplayInput` exactly once. Both raw and guarded sessions address a resource by the slot the definition declared; the token overload remains for a request-local callback that captures what it needs. In both cases, the typed slot in the definition and `slot.Bind(token)` at the call site are mandatory. + +`TargetScopeDefinition.Create` takes a `RenderScopeTransformSpace` before its `resources` argument, so a call that passed `resources` positionally after `deviceGridMapping` must name it. The default, `AmbientTarget`, keeps the previous planning behaviour. Declare `InputLogical` when the callback transforms its input in the input's own coordinates: only then does the declared `RenderScaleContract`'s backward map carry an output demand back to the input, which is what keeps an unbounded child from rasterizing at the target's density and being enlarged afterwards. + +#### Resources + +Replace keyed or string-named registration with the lifetime-only APIs: + +```csharp +RenderResource texture = context.Borrow(_texture); +RenderResource scratch = context.Own(new TemporarySurface()); +RenderResourceBinding binding = s_texture.Bind(texture); +``` + +`Borrow` leaves ownership with the caller. `Own` transfers a disposable object to the request family. Neither method accepts identity or content arguments. `RenderResourceBinding` has no public constructor and binding names are not part of the API. A definition declares `RenderResourceSlot` values in `resources:` and its call binds each one exactly once. + +#### Shader and geometry work + +Use a shader definition for fixed source and binding schema: + +```csharp +private sealed record TintState(float Amount); + +private static readonly ShaderDefinition s_tint = + ShaderDefinition.CurrentPixel( + """ + uniform float amount; + half4 apply(half4 color) { + return half4(color.rgb * amount, color.a); + } + """, + static bindings => bindings.Uniform("amount", static state => state.Amount)); + +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + new TintState(_amount), + static (current, input, state) => current.Shader(input, s_tint.Call(state))); +} +``` + +`ShaderDefinition.WholeSource` declares a whole-input shader and fixed bounds mapping. Shader value providers, custom uniform binders, and resource binders must be non-capturing `static` callbacks so changing values are supplied only by `TState` and invalidate through `HasChanges`. `ShaderDefinitionBuilder.Resource` declares typed child-shader slots. `GeometryDefinition.Create` follows the same definition/call pattern for geometry callbacks, bounds, hit testing, optional readback, and slots. + +Existing `FilterEffectContext` authoring passes `ShaderCall` and `GeometryCall`: + +```csharp +context.Shader(s_tint.Call(new TintState(_amount))); +context.Geometry(s_geometry.Call(new GeometryState(_radius))); +``` + +## FilterEffect compatibility + +`FilterEffect.ApplyTo(FilterEffectContext, Resource)` remains the supported authoring entry point. Existing Skia, color, transform, and `CustomEffect` calls remain ordered, and `ShaderCall` and `GeometryCall` add typed stages without replacing `ApplyTo`: + +```csharp +public override void ApplyTo(FilterEffectContext context, Resource resource) +{ + context.Blur(resource.Sigma); + context.Shader(s_tint.Call(new TintState(resource.Amount))); + context.Geometry(s_geometry.Call(new GeometryState(resource.Radius))); + context.CustomEffect(resource.State, static (state, execution) => Execute(state, execution)); +} +``` + +The former public `FilterEffectContext.Bounds` property is removed. Bounds stay engine-internal because an earlier opaque custom operation can make them symbolic. `WorkingScale` also is not unconditionally available: call `TryGetWorkingScale(out float)` during `ApplyTo`. If it returns `false`, keep authoring scale-independent and move device-pixel calculations into the shader, geometry, or custom-effect execution callback. The engine invokes `ApplyTo` once; it does not replay authoring after metadata resolution. + +`FilterEffect.Resource.CreateRenderNode()` remains virtual. A custom `FilterEffectRenderNode` must use the new `void Process` contract. If the customization changes only working-scale semantics, override the protected `GetWorkingScaleContract()` and retain base `Process`; a `null` result selects `RenderScaleContract.MaterializeAtWorkingScale`. + +Direct `FilterEffectActivator` consumers must classify execution explicitly: + +```csharp +using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + outputScale, + workingScale, + maxWorkingScale); +``` + +The public constructor requires `RenderIntent` and `RenderRequestPurpose` before the optional scale arguments. A working-scale ceiling no longer infers either classification. `FilterEffectStageFallbackExecutor` is an internal execution path for typed Shader/Geometry suffixes after opaque work; it is not a public authoring API and does not make `ApplyTo` obsolete. + +### EffectTarget and SKSLShader migration + +Code that wrapped a `RenderNodeOperation` in `EffectTarget` must instead record the node in the current request or materialize an actual `RenderTarget` before constructing `EffectTarget`. `EffectTarget` no longer renders or disposes an executable operation. + +`SKSLShader.Effect` is no longer public, `CreateBuilder()` now returns Beutl's disposable `SKSLShaderBuilder`, and `ApplyToNewTarget` is replaced by rendering into a caller-created target: + +```csharp +EffectTarget output = context.CreateTargetLike(input); +try +{ + using SKSLShaderBuilder builder = shader.CreateBuilder(); + builder.Uniforms["amount"] = amount; + shader.RenderToTarget(context, builder, output); + + input.Dispose(); + context.Targets[index] = output; +} +catch +{ + output.Dispose(); + throw; +} +``` + +`SKSLShaderBuilder.Uniforms` and `.Children` expose the Skia binding collections, and `Build()` returns a caller-owned `SKShader`. `RenderToTarget` borrows the supplied materialized target and does not transfer or replace its ownership; the caller remains responsible for committing or disposing it on every path. + +### Metadata and scale migration + +Bounds, hit testing, scale, cardinality, input readback, target access, and device-grid behavior are fixed definition metadata. Their callbacks must be deterministic, side-effect-free, and non-capturing. + +For a one-input element-wise density transform, declare both directions of the density relationship: + +```csharp +RenderScaleContract scale = RenderScaleContract.MapInputSupply( + static inputSupply => inputSupply.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(inputSupply.Value / 2), + static outputDemand => EffectiveScale.At(outputDemand.Value * 2)); +``` + +Both callbacks are reevaluated when required to resolve symbolic upstream metadata. Source, capture, combination, and expansion work must choose their own valid scale contract. + +An operation that consumes its input at the density its own consumer demands — a supply map that reports a different density without resampling, or one that collapses to `Unbounded` — declares the forward callback alone: + +```csharp +RenderScaleContract scale = RenderScaleContract.MapInputSupplyPreservingDemand( + static inputSupply => inputSupply); +``` + +The name states that precondition, because the contract leaves backward demand unchanged. Reaching for it from an operation that resamples — an enlargement, a reduction — lets an unbounded input rasterize at the operation's own output demand and then be magnified, so the result is blurred by exactly the enlargement factor. The backward map is not derived from the forward one: the forward map may collapse to `EffectiveScale.Unbounded` and need not be invertible. `mapOutputDemandToInput` receives a concrete output demand and must return a finite positive density; the engine bounds the result by the request ceiling. Both callbacks may be reevaluated during graph-wide metadata resolution. + +For a matrix-shaped operation, `TransformRenderNode.RescaleDensity` and `TransformRenderNode.RescaleDemand` supply the two halves; hold the matrix in a non-capturing metadata state and pass their bound methods as the two callbacks. They are not inverses — forward reports the least-scaled axis and backward answers the operator norm, each erring toward more detail — so under an anisotropic or sheared transform a round trip does not return its input. + +`RenderScaleContract.Custom` declares no backward map and none can be attached to one, so an output demand reaches its inputs unchanged. A map-topology operation whose density differs from its input's must therefore use `MapInputSupply` rather than a custom resolver. + +## Whole-source shader coordinate space + +BREAKING CHANGE: a `ShaderDefinition.WholeSource` stage is now evaluated over its **complete** output. Its `coord` argument spans `[0, SemanticOutputSize]` and `ShaderExecutionContext.DeviceBounds` / `LogicalOrigin` describe the complete output footprint, even when the renderer only required a sub-region (content that overhangs the frame). Previously `coord` started at the required region's origin while `SemanticOutputSize` still described the complete output, so `coord / iResolution` never reached `1.0` and any absolute anchor — a mirror axis, a tile-grid origin, a pivot — moved by the clipped-off overhang. + +`RequiredRegion` still reports the region actually being produced, so a stage that wants the destination extent reads it there. + +Out-of-tree whole-source shaders and `ShaderResourceCoordinateSpace.OutputDevice` binders that worked around the old behaviour by subtracting `LogicalOrigin` (or by differencing `OutputBounds` against `DeviceBounds`) now compute zero and need no further change. Any binder that instead hard-coded the old required-region origin must drop that correction; leaving it in place double-corrects and moves the stage by the overhang in the opposite direction. + +## Direct processor consumers + +Replace each `RenderNodeProcessor` use according to its intent: + +| Removed use | Current replacement | +|---|---| +| `PullToRoot` followed by rendering every operation | `RenderNodeRenderer.Render(destination)` | +| operation-bounds union for layout, selection, or hit-test queries | `RenderNodeRenderer.Measure().QueryBounds` | +| operation-bounds union used to size a raster | `RenderNodeRenderer.Measure().OutputBounds` | +| `PullToRoot` followed by operation hit tests | `RenderNodeRenderer.HitTest(point)` | +| `Rasterize` or `RasterizeAndConcat` | one owned `RenderNodeRasterization` from `Rasterize()` | +| protected `CreateRenderTarget` override | `RenderNodeRendererOptions.TargetFactory` | + +A direct host supplies one complete request: + +```csharp +using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + Purpose = RenderRequestPurpose.Frame, + TargetDomain = targetDomain, + RequestedRegion = requestedRegion, + OutputScale = outputScale, + MaxWorkingScale = maxWorkingScale, + CacheOptions = RenderCacheOptions.Enabled, + }, + TargetFactory = targetFactory, + }); + +RenderNodeMeasurement measurement = renderer.Measure(); +using RenderNodeRasterization rasterization = renderer.Rasterize(); +if (!rasterization.IsEmpty) +{ + Bitmap bitmap = rasterization.Bitmap!; + // bitmap pixel (0, 0) maps to rasterization.Bounds.Position + // at rasterization.OutputScale device pixels per logical unit. +} +``` + +`OutputBounds` includes contributing values and potential target writes; `QueryBounds` is the independent layout/query view. `TargetDomain` supplies the owning domain for target-less requests that contain `TargetRegion.Full`; `RequestedRegion` does not replace or shrink that domain. + +`RenderNodeRasterization` owns its nullable bitmap. A non-empty result has a bitmap even when every pixel is transparent; an empty result has `Bitmap == null`. Dispose the result, not the renderer, to release a returned bitmap. + +`IRenderTargetFactory` now has only `Create(RenderTargetAllocationDescriptor)`. Remove `GetMaximumDimension` from custom factories. The descriptor supplies the exact device size, linear-premultiplied RGBA16F format, and current backend/context. A non-null return transfers ownership to the renderer; the factory itself stays caller-owned. + +## Resource-side authoring dispatch + +Geometry and mesh generation now dispatches on the resource snapshot rather than the engine object. The engine-object forms are removed without forwarding overloads: + +- `Geometry.ApplyTo(context, resource)` becomes `Geometry.Resource.ApplyTo(context)`; +- `PathSegment.ApplyTo(context, resource)` becomes `PathSegment.Resource.ApplyTo(context)` for `ArcSegment`, `ConicSegment`, `CubicBezierSegment`, `LineSegment`, and `QuadraticBezierSegment`; +- `PathFigure.ApplyTo(context, resource)` becomes `PathFigure.Resource.ApplyTo(context)`; +- `PathGeometry.HitTestFigure(point, pen, resource)` becomes `PathGeometry.Resource.HitTestFigure(point, pen)`; +- `Mesh.ApplyTo(resource, out vertices, out indices)` becomes `Mesh.Resource.ApplyTo(out vertices, out indices)`. + +Move an out-of-tree override into the generated `Resource` partial and read resource members directly: + +```csharp +// Before +public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) +{ + var value = (Resource)resource; + context.MoveTo(new Point(value.Width, 0)); +} + +// After +public partial class Resource +{ + public override void ApplyTo(IGeometryContext context) + { + context.MoveTo(new Point(Width, 0)); + } +} +``` + +The same rule applies to `CubeMesh`, `PlaneMesh`, `SphereMesh`, and `ModelMesh`: move the override into the `Mesh.Resource` partial, drop the resource parameter and cast, and fill the output arrays from resource members. Do not call `GetOriginal()` from these overrides. A detached resource created through its public constructor has no backing engine object and must still be able to generate its geometry or mesh. State that formerly lived only on the engine object must move into the resource; `SKPathGeometry`, for example, now keeps and disposes its `SKPath` on `SKPathGeometry.Resource`. + +`Scene3DRenderNode` is internal. Its in-tree implementation migrated to `void Process(RenderNodeContext)` and consumes the resource-side mesh API; it adds no separate public migration surface. + +## Render intent, brushes, and allocation behavior + +`Renderer` and `ImmediateCanvas` gain a trailing optional `RenderIntent` that defaults to `RenderIntent.Preview`. Existing call sites still compile, but delivery hosts must opt in explicitly so an intermediate allocation failure throws instead of dropping content: + +```csharp +using var renderer = new Renderer( + width, + height, + renderScale, + maxWorkingScale, + intent: RenderIntent.Delivery); +``` + +`BrushConstructor` has the final signature shape `(bounds, brush, blendMode, scale, maxWorkingScale, intent, drawableBrushMaterializer)`. Its allocation-failure policy no longer infers delivery from `float.IsPositiveInfinity(MaxWorkingScale)`; it uses `Intent`. Because `intent` defaults to `Preview`, an old delivery-oriented call such as `new BrushConstructor(bounds, brush, mode, scale, float.PositiveInfinity)` still compiles but changes from fail-fast to transparent degradation. Migrate it explicitly: + +```csharp +var constructor = new BrushConstructor( + bounds, + brush, + blendMode, + scale, + maxWorkingScale, + intent: RenderIntent.Delivery, + drawableBrushMaterializer: materializer); +``` + +The trailing `DrawableBrushMaterializer` is optional for source compatibility, but a `DrawableBrush` painted without one degrades to transparent. Prefer `ImmediateCanvas.CreateBrushConstructor(...)` when painting through a canvas because it carries the canvas density, working-scale ceiling, intent, and runtime materializer. A direct host that supports drawable brushes must provide a materializer; otherwise the missing nested content is intentional degraded output. + +Positional callers after `intent` must be updated for the trailing materializer parameter. Custom `IRenderTargetFactory` implementations must drop `GetMaximumDimension`; the current hard axis bound remains `RenderScaleUtilities.MaxBufferDimension`. + +`FilterEffectActivator`'s public constructor takes the same trailing optional `DrawableBrushMaterializer?` for the same reason: the activator is a direct host, and it forwards the materializer into every `CustomFilterEffectContext` it opens. Without one, a `DrawableBrush` used as a displacement map (or any other brush a custom effect paints) degrades to transparent, which for a displacement map silently turns the effect into a no-op: + +```csharp +using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + drawableBrushMaterializer: materializer); +``` + +## Ownership summary + +- `RenderNodeContext.Inputs` and every `RenderFragmentHandle` are borrowed, transaction-scoped values; authors never dispose or retain them. +- `RenderNodeRenderer` borrows its root, target factory, and destinations, and owns its structural/program caches and accepted factory-created targets. +- Each returned `RenderNodeRasterization` exclusively owns its nullable bitmap until disposal. +- `MaterializedDrawableBrush.Image` transfers to the `BrushConstructor` that requested it; the constructor disposes it once the tile shader is built, or once the fill fails, so a materializer returns a fresh image per call and never caches, shares, or disposes one. +- `Own` transfers one disposable resource to the request family; `Borrow` leaves the raw resource with its external owner. +- Definition slots and call bindings declare how deferred callbacks access resources; callbacks borrow session inputs, canvases, and declared resources only for callback duration. +- Deferred outputs remain executor-owned until publication or discard. +- A recording or execution failure publishes no partial output; cleanup continues best-effort without replacing the primary exception. + +## Output reuse and failure behavior + +The renderer decides whether recorded output is retained. Author code must only report changed node content through `HasChanges`; it cannot force, suppress, seed, or identify retained output. Raw target work is never persistently reusable. + +Every `Process` invocation is transactional. An exception from recording or deferred execution preserves the primary failure, releases request-owned values best-effort, and yields no partial output. + +## A custom effect's target allocation failure fails a delivery render + +BREAKING CHANGE: `CustomFilterEffectContext.CreateTarget` and `CreateTargetLike` throw `InvalidOperationException` when the allocation itself fails during a `RenderIntent.Delivery` render, instead of returning an empty target. The `RenderIntent.Preview` return value is unchanged: the failure is logged and an empty target comes back so the caller can keep the source pixels. + +Failure used to be reported the same way for both intents, which left every caller to invent its own policy — `SKSLScriptEffect` threw for both, `GLSLShader` silently kept the raw input, and `CreateTarget` relied on a later `Open()` throwing whatever the intent was. A delivery export could therefore ship an unprocessed frame while a preview failed outright. The policy now lives in the allocator, where the intent is known, so an out-of-tree effect gets it without having to find and use a helper. + +An unmaterialized or unbounded `CreateTargetLike` source remains a legitimate skip and still returns an empty target for either intent; only a real allocation failure fails a delivery render. Out-of-tree effects that relied on the empty-target return to skip work under `Delivery` must handle the exception, and an effect that must fail delivery for a case the allocator cannot see — a target that allocated but carries no GPU texture — has to throw for itself. + +## An SKSL script reads its semantic output size + +BREAKING CHANGE: an `SKSLScriptEffect` script sees the semantic output size in `width`, `height` and `iResolution` rather than the raster-padded backing the old path inherited from the source target, and `iScale` resolves through the supply-driven working scale instead of copying the source target's density. A script that normalizes coordinates with `iResolution` renders slightly differently. Group opacity is no longer rounded to 8 bits, so an opacity of 0.5 composites at 0.5 rather than at 127/255. A whole-source shader may no longer declare a top-level name the fusion merger generates; those names are reserved and rejected when the source is parsed. + +The uniform change follows from the script effect no longer recording a legacy custom effect, which used to make the whole enclosing segment opaque. A script now records declaratively — `main(float2)` as a whole-source stage and `apply(half4)` as a fully fusible current-pixel stage — and a declarative stage binds its size uniforms from the stage's own execution context rather than from whatever target the custom path was handed. Scripts the declarative surface cannot express still fall back to the custom-effect path, so no existing script stops running. + +Group opacity now rides a runtime colour filter on the layer paint. Skia's two idiomatic alternatives — a paint alpha on the `SaveLayer` paint, and the `DstIn` mask the pop used to draw — both quantize to a byte inside an otherwise 16-bit linear pipeline; dropping the mask also removes the extra `SaveLayer` and `DrawPaint` that pop performed. + +## A group's filter effect applies to each child + +BREAKING CHANGE: a `FilterEffect` on a `DrawableGroup` is applied to each child separately rather than to the group's composited result. A project that relies on a split, mosaic, stroke or other target-list effect seeing one assembled image renders differently; wrap the children in a nested `Scene` to get the previous behaviour. + +Measured before the change, a `SplitEffect` on a group of two children split the assembled image into four tiles; per child it gives eight, and a group holding five children gives twenty rather than four. A `SplitEffect` on a group is now byte-identical to that effect applied to each child individually, across every division setting, scale and budget. + +The group's isolation layer is still recorded, but only around the opacity and blend axes, so a group's opacity is still applied exactly once instead of once per nesting level and a child's non-`SrcOver` `BlendMode` still composites against the group. + +## An identity colour matrix records no stage + +BREAKING CHANGE: `FilterEffectContext.ColorMatrix` and the filters built on it — `Brightness`, `Saturate`, `HueRotate`, `Lighting` — record nothing when the resulting colour matrix is exactly the identity. As with the zero-radius morphology below, a call that used to contribute an item to `CountItems()` no longer does, and a subtree whose only effect was an identity matrix has no isolation fragment of its own, so it takes the working scale of its surroundings rather than resolving one for itself. + +Rendered output is unchanged, which is the whole point: the colour matrix stage unpremultiplies, applies the matrix, clamps, and re-premultiplies, so even an identity matrix computes `(c / a) * a`, which is not the identity in floating point, and the clamp at 1.0 can bite at an antialiased edge where `c / a` rounds just above one. A `Brightness` with `Amount` 100 is exactly the identity and still moved the output by one fp16 ULP; SwiftShader happened to round back to the original half and an Intel UHD Graphics 630 does not. + +The generic `ColorMatrix(T, Func)` overload now evaluates its factory while recording rather than deferring it into the recorded item, and keys identity on the resulting matrix rather than on the `(data, factory)` pair. A factory whose result depends on state mutated between recording and execution therefore yields its record-time value. + +## Zero-radius morphology records no stage + +BREAKING CHANGE: `FilterEffectContext.Dilate` and `.Erode` clamp each radius per axis at record time and record **no stage at all** when both clamped radii land on zero. A call that used to contribute an item to `CountItems()` no longer does. + +A negative radius previously produced three contradictory descriptions of the same operation: the Skia factory returned null (a pass-through), the sampling map clamped to the identity, but the forward bounds map used the raw radius and deflated the declared output by `|r|` per side. Under the region-driven pipeline that deflation applied twice, hard-cropping the content by `2|r|` logical px per side at every scale; past half the shorter side the doubled deflation went negative-extent and failed the render outright. + +The early return also repairs the pre-existing zero-radius case: a degenerate morphology stage still re-grids the content through an intermediate and shifts antialiased edges, so recording nothing is now a byte-exact pass-through where an identity-radius stage used to deviate. + +A subtree built only from such calls has no isolation fragment of its own and takes the working scale of its surroundings rather than resolving one for itself. Out-of-tree code that kept bounds bookkeeping keyed on `CountItems()`, or derived a cache key from it, must stop assuming a one-to-one mapping from call to recorded item. The per-axis clamp keeps a mixed radius such as `(-6, 5)` a real y-only morphology. + +## Built-in Skia filters replay on the destination's device grid + +BREAKING CHANGE: `DropShadow`, `DropShadowOnly`, `Dilate`, `Erode`, `MatrixConvolution` and `Transform(Matrix, BitmapInterpolationMode)` now report `SupportsDirectReplay`, and a **chain** of built-in Skia filter segments over a vector drawable is replayed as one device-space save layer whenever the fragment the chain terminates at admits it. Previously only `Blur` took that path, and any chain of two or more segments fell back to materializing each segment in the drawable's local space under a non-pixel-aligned drawable transform. + +Filter parameters keep their units — `Drawable.Render` pushes the drawable transform outside `PushFilterEffect`, so the filter's local space still carries it — but every effect in the stack now resolves at the destination's device resolution. Under a drawable transform that scales an axis down, a morphology radius or shadow offset that used to be applied in the drawable's own units is applied after that transform, so a spatial parameter that maps to less than one device pixel rounds away instead of growing the content. + +This is what stops a drawable squeezed below one device pixel from losing its ink: measured on a 6 x 100 bar under `ScaleTransform(10%, 100%)`, blur kept 0.06% of the unfiltered ink at output scale 0.5 and 2.8% at 0.333, while drop-shadow-only and dilate went to exactly zero at 0.25 and 0.5. + +Output under an identity or pixel-aligned drawable transform is unchanged. + +The filter's save layer is opened one device pixel wider than the content on every side, because a layer whose device bounds hug the content loses the coverage of content thinner than one device pixel. The layer therefore guarantees a **bound, not an exclusion**: nothing more than one device pixel outside the content is reachable, and that margin starts transparent because `SaveLayer` clears it, so it can never carry pixels nobody wrote. A spatial filter that relied on the layer clipping exactly at the content bound now samples up to one device pixel further. + +## A pending Skia colour filter is applied once + +BREAKING CHANGE: `SKImageFilterBuilder.GetFilter()` now clears the pending colour filter once it has folded it into the returned image filter, so repeated calls return the same chain instead of stacking another copy on each call. + +`AppendSkiaFilter` calls `GetFilter()` mid-chain to take the filter built so far as its input, and the flush that materializes the chain calls it again. A colour filter recorded through `FilterEffectContext.ColorMatrix`, `LuminanceToAlpha`, `BlendMode(Color, BlendMode)` or `AppendSKColorFilter` and followed by any Skia image filter was therefore folded twice and applied twice. Measured on `Split(2, 2)` wrapping `Delay(250ms, Group(animated Brightness, animated Blur))`, the per-tile factors came out as the exact squares of the correct ones: 1.6890 / 1.1564 / 0.7224 / 0.3906 against 1.30 / 1.075 / 0.85 / 0.625. + +No built-in effect reaches this path on the current branch — `Brightness` and the other colour operations record a `CurrentPixel` shader stage instead — so in-tree rendering is unchanged. A plugin that compensated for the doubling will render differently. + +## A custom effect's input is rasterized on the grid it crops on + +BREAKING CHANGE: the executor strips the sub-pixel phase from the device grid a filter-effect segment containing a custom (imperative) effect executes on, and **every nested execution frame that materializes that segment's inputs inherits the stripped grid**. + +An imperative callback crops and re-lays-out its targets in whole device pixels, and its input is anchored on the whole-pixel part of the ambient translation. Handing it a grid with the fraction intact made the flush resample the input onto the whole-pixel grid instead; a bilinear half-pixel shift over an edge already at 0.5 coverage leaves 0.75, so the effect's outer edge lost coverage before the callback ever saw it. The inheritance is load-bearing: `FilterEffectRenderNode.Process` emits a separate fragment per shader and geometry stage, so an ordinary colour effect in front of the custom one moves that rasterization into a nested frame that would otherwise re-derive the fractional grid. + +Content rasterized in those frames is snapped rather than resampled, so it keeps its edge coverage but moves by the phase that was stripped — anywhere in `[0, 1)` device pixels, since the grid origin drops `frac(offset x density)`. A fragment that feeds both the segment and a consumer outside it is materialized once, so whichever consumer reaches it first fixes the grid for both; in practice the outside consumer runs at top level, where the grid is already zero-phase, so the segment still gets a snapped input. + +Two phases are deliberately not touched, and are therefore not snapped: the phase carried by a callback's own target bounds, and the grid of a separate render request such as a `DrawableBrush` source materialized below the segment. + +This affects `SplitEffect`, `PartsSplitEffect`, `LayerEffect`, `Clipping`, `TransformEffect`, `StrokeEffect`, `FlatShadow`, `PixelSortEffect`, `PathFollowEffect`, `ShakeEffect`, `DelayAnimationEffect`, the displacement-map effects, the script effects, and any plugin effect built on `FilterEffectContext.CustomEffect`. + +## One rectangle-bounds map + +BREAKING CHANGE: `Rect.TransformToClippedAABB` is gone. `Rect.TransformToAABB` takes its place, gaining an optional `nearPlane` parameter and clipping the rectangle at the matrix's camera plane before mapping it. The raw mapped-corner box is no longer public surface. + +Rename `TransformToClippedAABB` calls to `TransformToAABB`; they are otherwise unchanged, including the default near plane, so a caller that opted into `Rect.RasterizerNearPlane` keeps that behaviour. Existing `TransformToAABB` calls compile unchanged and return the same box for every affine matrix, and for every perspective matrix the rectangle does not straddle. + +Where the rectangle **does** straddle the `w = 0` plane, the answer changes from a box on the wrong side of the image to one that contains it. The two methods were bit-identical everywhere except in precisely that broken case, so a caller could not discover the difference by testing — which is why only the safe one is published now. Code that genuinely wants the raw mapped corners there must map the four corners itself. + +`Rect.DefaultNearPlane` (0.05) is a pragmatic bound, not the rasterizer's: it sits 820x in front of `Rect.RasterizerNearPlane` (Skia's `1 / 16384`), so a near-edge-on layer declares bounds that exclude pixels Skia still draws. Clipping at the exact value is not affordable as a default — a 1200x54 layer at the default Depth of 500 rotated 60 degrees about Y would declare a box 4.73 million px wide and collapse the working scale by ~289x. Callers that intersect the result with their own target before sizing a buffer should pass `Rect.RasterizerNearPlane`. + +## A sheared filter layer keeps its perpendicular pixel + +BREAKING CHANGE: the apron the engine opens around a directly replayed Skia filter (internally `ImmediateCanvas.PushFilterLayer`) is derived from the transformed basis **area**, not from the transformed basis lengths. Every edge of the content now sits exactly one device pixel inside the layer whatever basis the canvas carries, so content under a sheared transform — a `SkewTransform`, or any transform group that composes one — renders differently: its layer is wider and keeps antialiased coverage that used to be clipped away. + +`Drawable.Render` pushes the drawable transform outside `PushFilterEffect`, so a shear is live on the destination canvas whenever the executor replays a built-in Skia filter chain onto it. Inflating the bounds by `dx` along x moves a vertical edge perpendicular to itself by `dx * |det| / devicePerY` device pixels, not by `dx * devicePerX`; the two agree only when the basis is orthogonal, and a shear drives `|det|` below the product of the basis lengths. The apron each axis needs is therefore the **other** axis's basis length over the determinant. Measured on the basis an 80 degree `SkewTransform` produces at output scale 2 — rows `(2, 0)` and `(1.134, 0.2)` — the previous apron bought 0.174 device pixels instead of one, and a 100 x 6 bar under it lost 9.4% of its ink to a blur too small to move a pixel, against 0.35% now. + +The visible change is confined to filters whose own margin is smaller than that shortfall. Skia grows a save layer by the image filter's own radius, so a blur of sigma 0.5 logical units or more at output scale 2 already covered the deficit; a near-identity blur, a zero-radius morphology, and any plugin filter with no spatial extent did not. + +Unsheared transforms are unchanged bit-for-bit, not merely within rounding: the apron keeps the reciprocal-of-basis-length form whenever the basis rows are orthogonal to within `1e-5` of the product of their lengths. Composing a rotation with an anisotropic scale leaves the rows orthogonal but misses an exactly zero dot product by up to `1e-7` relative, while the shallowest shear that can move a device pixel misses it by `1e-3`, so the split separates float rounding from real shear with three orders of magnitude to spare on each side. This matters because an apron landing on a whole device pixel would otherwise round out to a layer one pixel larger. + +A basis that collapses the plane now leaves the bounds uninflated, joining the existing non-finite and non-positive guards. It previously inflated by the reciprocal of its collapsed basis length, which is an arbitrary amount of logical space for content that has no area to preserve coverage for. + +## ChromaKey matches its key colour in linear light + +BREAKING CHANGE: `ChromaKey` no longer relies on a `1/255` widening of the hue and saturation edges as its match tolerance. That widening survives as smoothstep edge slack, but the match itself is now tested against the key colour in premultiplied linear light, within half an 8-bit code per channel plus one half-precision ulp, and a match there is a mask of zero whatever the hue and saturation differences say. The hue term is additionally weighted by the smaller of the pixel's and the key's linear chroma, ramping in between one and two linear codes, so hue stops voting where quantization alone could have manufactured it. `Boundary` still controls only how gradually the mask ramps past the threshold. + +The tolerance was applied in the wrong colour space. A constant paint colour reaches the shader folded to 8 bits in the destination colour space, and the render targets are linear F16, so the grid the error lands on is linear — but the tolerance sat after `linearToSrgb`, where half a linear code is not a fixed quantity. Near black it spans about ten sRGB levels, roughly forty times the tolerance; near white it spans a fifth of one. + +The consequence was that a fill did not key against its own colour. `rgb(20,18,22)` has all three channels round to linear code 2, so the pixel arrives as an exact grey: saturation disagreed with the key by 0.1818 and hue by 0.2500 against a 0.0039 threshold. `rgb(10,40,20)` disagreed by 0.0821, `rgb(5,5,60)` by 0.0835, and even `rgb(60,180,75)` — bright and saturated — by 0.0073, enough to leave 95% of its alpha at `Boundary` 0. Sampling 225 solid fills across the cube, 118 failed to self-key; all 225 now key to zero alpha. + +This was never confined to the fused pipeline, and never to rectangles. Only an axis-aligned rectangle gave Skia a full-coverage quad, so an `EllipseShape` or `RoundedRectShape` with the same dark fill self-keyed at the same 0.1818 and 0.0820 residuals well before this branch. + +Content that already keyed is unaffected: the tolerated neighbourhood of `rgb(206,92,42)`, `rgb(240,240,250)` and `rgb(12,12,12)` measured identical before and after, level for level. Because the band is tested premultiplied, it is independent of coverage, so the antialiased edge of a keyed shape now keys with its interior; the same property means a pixel faint enough that half a linear code swamps its colour matches any key, which at that alpha is a change of at most a fraction of a percent of coverage. + +The chroma gate has one visible consequence beyond the fix. A neutral fill has no hue to compare, so it can no longer be kept out of a key by the hue term alone: with `SaturationRange` widened to 100, a mid grey that a lime key used to leave alone is now removed. At any narrower `SaturationRange` the saturation term still keeps it, as before. This replaces the previous behaviour, where a neutral pixel took the `h = 0` that `rgb2hsv` returns at zero chroma and therefore matched a red key while surviving every other hue — a distinction the pixel did not carry. + +## The short supply-mapping name is the one that carries demand back + +BREAKING CHANGE: `RenderScaleContract.MapInputSupply(Func)` is renamed to `RenderScaleContract.MapInputSupplyPreservingDemand(Func)`. The two-callback `RenderScaleContract.MapInputSupply(Func, Func)` keeps its name and is unchanged. + +| Before | After | +|---|---| +| `RenderScaleContract.MapInputSupply(map)` | `RenderScaleContract.MapInputSupplyPreservingDemand(map)` | +| `RenderScaleContract.MapInputSupply(map, mapOutputDemandToInput)` | unchanged | + +Nothing about how either contract resolves changed; this is a rename and a documentation change. `spec.md` FR-030 records the earlier plan for a state-first `MapInputSupply(state, map, structuralKey)`; that shape was never built, and the pair documented here is the delivered surface. + +The two forms were overloads of one name, and the name described only the forward half both of them share. An author reaching for a one-input density map met the one-callback signature first and had no signal that a second existed, so a map that resampled — an out-of-tree `OpaqueMap` that enlarges — declared its output supply, silently fell back to the identity backward map, and let a downstream materialization rasterize the source below the density the enlargement needed. The failure is invisible until someone looks at a blurry frame. + +The sibling `RenderBoundsContract` had already settled this: `Create` takes both directions and the narrower `CreateFullInput` names its own backward behaviour. `RenderScaleContract` now reads the same way, and splitting the overload set means the narrow form can no longer be reached by dropping an argument — each name carries its own documentation, and the compiler rejects a one-argument `MapInputSupply` outright. + +The new name is a precondition, not a warning label. Leaving demand unchanged is exactly right for a supply map that reports a different density without resampling, or one that collapses to `Unbounded`, which is the common case; `MapInputSupplyPreservingDemand` says which operations it fits rather than implying the contract is a degraded variant. It also pairs with the well-known `PreserveInputSupply`, whose demand pass-through is correct by construction. + +`RenderScaleContract.Custom` has the same unchanged-demand fallback and no way to attach a backward map. It is deliberately left alone here — adding a demand callback to a custom resolver is a design change, not a rename — but its documentation now states the fallback and points at `MapInputSupply`. + +## The graphics backend contract gained members and lost a default + +BREAKING CHANGE: `Beutl.Graphics.Backend` changed shape for anyone implementing or calling it directly. Every +item below is source-breaking; none has a default implementation or an overload that preserves the old call. + +| Surface | Before | After | +|---|---|---| +| `ITexture2D` | — | adds `bool RequiresSkiaFlushForBackendInterop { get; }` | +| `ITexture2D` | — | adds `void PrepareForSkiaRendering()` | +| `ITexture2D` | — | adds `void PrepareForSkiaSampling(bool requireCompletion)` | +| `IGraphicsContext.CreateRenderPass3D` | `TextureFormat depthFormat = TextureFormat.Depth32Float` | `TextureFormat? depthFormat` — required, `null` for a colour-only pass | +| `IGraphicsContext.CreateFramebuffer3D` | `ITexture2D depthTexture` | `ITexture2D? depthTexture` | +| `IFramebuffer3D.DepthTexture` | `ITexture2D` | `ITexture2D?` | +| `PipelineOptions` | — | adds `ImmutableArray SpecializationConstants { get; set; }` | +| `IRenderPass3D.SetPushConstants` | `SetPushConstants(T data, ShaderStage stageFlags = Vertex \| Fragment)` | `SetPushConstants(T data)` | + +The three `ITexture2D` members exist because the fused pipeline hands one texture back and forth between Skia +and the backend within a frame. Skia records into a surface it owns while the backend records into the same +image, and neither can see the other's pending work, so the hand-off needs an explicit point at which the +preceding side submits and establishes visibility. `RequiresSkiaFlushForBackendInterop` lets a caller skip that +cost on a backend where the two never share, and `PrepareForSkiaSampling`'s `requireCompletion` distinguishes a +hand-off that can be expressed with GPU synchronization from one that has to wait on the host. An +implementation that has no Skia interop answers `false` and leaves the two methods empty. + +The nullable depth attachment is what lets a pass declare that it writes colour only. The old default silently +gave every render pass a `Depth32Float` attachment, including the several passes in this pipeline that never +read or write depth, and a default cannot be removed while keeping the parameter optional without changing +what existing call sites mean. Making it required is the change that makes those call sites state their +intent; `null` is the colour-only pass, and `depthLoadOp` is ignored for one. + +`SpecializationConstant` is additive: an implementation that ignores `SpecializationConstants` compiles and +behaves as before, and only a backend that wants compile-time specialization needs to read it. + +`SetPushConstants` lost its `stageFlags` argument because no caller could set it correctly. A push-constant +update must name every shader stage of every declared range it overlaps, and which stages those are is a +property of the bound pipeline layout — which the Vulkan backend declares as one range spanning vertex and +fragment. A caller naming only the stage it reads from, as the GLSL filter pipeline did, produced undefined +behaviour that no driver is required to report; the Vulkan validation gate reports it as +`VUID-vkCmdPushConstants-offset-01796`. Delete the argument: the backend now takes the stages from the layout +it is pushing against, which is the only value that was ever correct. + +## A source declares the room its rasterization needs instead of publishing it + +BREAKING CHANGE: `OpaqueRenderBoundsContract.Source` takes an optional `Thickness rasterOutset`, and +`RenderNodeContext.PaintedSource` takes a matching optional argument. Existing calls compile unchanged; +both signatures moved, so a caller compiled against the previous assembly must be rebuilt. + +| Before | After | +|---|---| +| `OpaqueRenderBoundsContract.Source(outputBounds)` | unchanged, or `Source(outputBounds, rasterOutset)` | +| `PaintedSource(..., resources)` | unchanged, or `PaintedSource(..., resources, rasterOutset)` | + +The outset is logical room per side that widens only the buffer the source draws into. Nothing +downstream sees it: the fragment still publishes `outputBounds`, and that is what places it. + +The pipeline already had a fixed one-device-pixel raster apron for a source whose rasterization spills +past its bounds, and that is what this generalizes. A fixed pixel is the wrong unit whenever the spill +is measured in logical units and varies with density, which is exactly the text case below. An author +whose source draws entirely inside its bounds — every built-in but text — declares nothing and gets the +previous behaviour. + +## Text publishes the rectangle it occupies, not the one its masks need + +BREAKING CHANGE: a `TextRenderNode` fragment publishes `FormattedText.ActualBounds`. It previously +published `FormattedText.GetRasterBounds(OutputScale)`. Code that read those bounds to place, measure or +lay out text now sees the text's own rectangle, unchanged by render density. + +The bounds a fragment publishes are what place it, so a density-dependent value moved the text whenever +the density changed. Measured on this branch, one string published `(0, -44, 355.56, 60)` at a 50% +preview and `(2, -41, 351.56, 54)` at 100% and at a 2x export — the same project, three compositions. +`RasterBounds` documents this itself: only the allocated footprint may use it, and layout stays on the +semantic bounds. + +Publishing the semantic bounds alone would clip the hinted glyph masks, which reach two to three logical +units outside them and by a different amount at every density. The masks' room is therefore declared as +the raster outset above, so the buffer still clears them while the published rectangle stays put. + +The emptiness gate still tests the mask: a glyph can have a degenerate outline and rasterize something, +and that case has no scale-independent rectangle to be placed by, so it falls back to the mask footprint. + +`main` published `ActualBounds`, so this restores the composition a project had before the branch. + +## A particle covers the rectangle it is turned into, and is resampled into it + +BREAKING CHANGE: a particle's extent is the bounding box of its scaled and rotated source rather than a +square of the source's longer side, and the blit resamples with Mitchell rather than point sampling. +Both change the pixels a `ParticleEmitter` produces. + +The extent is what the layer buffer is allocated from, so the previous square clipped whatever the +rotation pushed outside it — a 20x20 source turned 45 degrees reached about 4.14 further along each axis. +The new extent is exact rather than merely larger: an unrotated non-square source now allocates less than +it did. + +Point sampling reduced each particle to whichever texels its sample points landed on, which is visible as +a stair-stepped edge on every particle whose size is not exactly the source's. Measured on a magnified +particle, its edge carried 81 distinct alpha values against 314 resampled. Mitchell is the resampler the +canvas applies to any other scaled bitmap, and is what the pipeline used before this branch. + +## What these three change in the corpus + +The differential harness renders the case corpus on two builds and compares every shot. Between the +commit before these three changes and the commit after, 2,217 of 94,862 shots differ on Linux and 2,190 +on Windows, with no render errors, no dimension mismatches, no shot blank on one side only, and no +non-finite pixel on either. Every differing case draws text, particles, or a chroma key; no case without +one of those moved. + +## A detached resource's backing object is nullable + +`EngineObject.Resource.GetOriginal()` returned `EngineObject` and initialised its backing field to `null!`. +A resource built directly rather than through `EngineObject.ToResource` — a detached resource, which the +public authoring contract supports — never receives one, so the accessor returned `null` under a declaration +that promised it could not. `EngineResourceIdentity` already handled that null, and any plugin that trusted +the declaration met a `NullReferenceException` instead of a diagnosable one. + +The base accessor and the `GetOriginal()` override the resource source generator emits now return a nullable +reference. Nothing about the runtime behaviour changed. Under nullable reference types, a caller that only +ever holds resources produced by `ToResource` states that: + +```csharp +// before +Drawable drawable = resource.GetOriginal(); + +// after — the resource came from ToResource, so it is attached +Drawable drawable = resource.GetOriginal()!; +``` + +A caller that may hold a detached resource handles the null it was already able to receive, or keys on +`EngineResourceIdentity` when it only needs an equality-stable identity. + +## A render host states what its output is for + +`Renderer`, `SceneRenderer` and `BrushConstructor` took `RenderIntent` as a trailing optional argument +defaulting to `RenderIntent.Preview`. The intent decides whether an intermediate that cannot be allocated +fails the render or drops the contribution, so an export host that let it default shipped a frame missing +whatever could not be allocated, and nothing in the source said so. `BrushConstructor`'s drawable-brush +materializer had the same shape and the same consequence: without one a `DrawableBrush` fill resolves to +transparent. + +Both are now required, positioned where they cannot be reached by dropping a trailing argument: + +```csharp +// before +using var renderer = new SceneRenderer(scene, renderScale, maxWorkingScale: ceiling, intent: RenderIntent.Delivery); +var brush = new BrushConstructor(bounds, resource, BlendMode.SrcOver, scale, maxWorkingScale); + +// after +using var renderer = new SceneRenderer(scene, RenderIntent.Delivery, renderScale, maxWorkingScale: ceiling); +var brush = new BrushConstructor( + bounds, resource, BlendMode.SrcOver, RenderIntent.Preview, drawableBrushMaterializer: null, + scale, maxWorkingScale); +``` + +A call that previously relied on the default was rendering as a preview, so `RenderIntent.Preview` preserves +its behaviour exactly; an export host that meant delivery was already degrading silently and should pass +`RenderIntent.Delivery`. Pass `null` for the materializer when the brush is never a `DrawableBrush`. diff --git a/docs/specs/004-gpu-pass-fusion/contracts/public-api.md b/docs/specs/004-gpu-pass-fusion/contracts/public-api.md new file mode 100644 index 0000000000..2776ba9540 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/contracts/public-api.md @@ -0,0 +1,288 @@ +# Public API Contract + +## Namespaces + +Public render-node authoring lives in `Beutl.Graphics.Rendering`; shader and geometry authoring also uses `Beutl.Graphics.Effects`. + +## Render-node contract + +```csharp +public abstract class RenderNode : IDisposable +{ + public bool IsDisposed { get; } + public bool HasChanges { get; set; } + public virtual ReadOnlySpan ChildNodes { get; } + public abstract void Process(RenderNodeContext context); + protected virtual void OnDispose(bool disposing); +} +``` + +`Process` records work; it does not draw immediately. The context and every fragment handle obtained from it are valid only for that invocation. Resource tokens from `Own`/`Borrow` are scoped to the active request family instead, so they remain declarable in nested recordings within the same request and are rejected once released. `Dispose` is not virtual; release node-owned state by overriding `OnDispose`, which both `Dispose` and the finalizer route through. + +`HasChanges` is the sole public content-invalidation signal. Set it before the next request whenever any node state that can affect pixels, bounds, hit testing, or recorded topology changes. An invalidation resets that node and its recorded ancestors, but does not mark unchanged `ChildNodes` dirty: an independently reusable child may continue warming or serving its retained output while its parent changes every request. Definitions may be reused across requests; changing the state passed to a call still requires the owning node to report the change. `ChildNodes` reports content dependencies for traversal and revalidation, not disposal ownership. A node that discovers what it records through only while processing, and so cannot hold a stable span, leaves `ChildNodes` empty; traversal and revalidation then stop at that node, so it must take itself out of the cache for that recording with `context.DisableRenderCache()`. + +Authors do not provide runtime identities, structural identifiers, resource cache identities, or resource content counters. The engine derives operation shape from its immutable definition and manages reusable output state internally. + +## Fragment handles and publication + +`RenderFragmentHandle` is a non-null, transaction-scoped handle. It is returned by recording methods but is not an output until published. It also exposes the recorded metadata an author may need in order to decide what to record next. + +```csharp +public sealed class RenderFragmentHandle +{ + public RenderValueCardinality ValueCardinality { get; } + public bool ContributesValuesToTarget { get; } + public bool CanBeUsedAsValueInput { get; } + + public bool TryGetMetadata(out RenderFragmentMetadata metadata); + public bool TryHitTest(Point point, out bool result); +} + +public readonly record struct RenderFragmentMetadata(Rect Bounds, EffectiveScale EffectiveScale); +``` + +`TryGetMetadata` and `TryHitTest` return `false` instead of throwing when the fragment's bounds still depend on an unresolved owning target domain, which is a legitimate recording state rather than an error. Neither executes deferred work or resolves graph-wide regions of interest. + +```csharp +public sealed class RenderNodeContext +{ + public IReadOnlyList Inputs { get; } + public RenderIntent Intent { get; } + public RenderRequestPurpose Purpose { get; } + public Rect? TargetDomain { get; } + public float OutputScale { get; } + public float MaxWorkingScale { get; } + public bool IsRenderCacheEnabled { get; } + + public bool TryCalculateInputBounds(out Rect bounds); + public void DisableRenderCache(); + + public void PassThrough(); + public void Publish(RenderFragmentHandle fragment); + public void PublishRange(IEnumerable fragments); + public void PublishMappedInputs( + Func mapper); + public void PublishMappedInputs( + TState state, + Func mapper); + public void Drop(RenderFragmentHandle fragment); + + public RenderFragmentHandle ContributeValues(RenderFragmentHandle input); + public RenderFragmentHandle Layer( + IReadOnlyList inputs, + Rect domain, + bool domainIsQueryFootprint = false); + public RenderFragmentHandle OwningTargetLayer(IReadOnlyList inputs); + public RenderFragmentHandle TargetLayerScope( + IReadOnlyList inputs, + TargetRegion region); + public RenderFragmentHandle MaterializedInput(MaterializedInputDescription description); + public RenderFragmentHandle TargetCapture(TargetCaptureDescription description); +} +``` + +`PassThrough` republishes all inputs in order. `PublishMappedInputs` is explicit one-to-one publication: it invokes its mapper once per input in painter order and publishes exactly the returned handle for that input. It produces no output for an empty input list. A mapper may record intermediate fragments, but it must not publish anything itself; nested publication is rejected and the invocation rolls back. + +Use `Publish`, `PublishRange`, or `PassThrough` for every other topology: no output, selection, reordering, combining, expansion, nested recording, or placement of target effects. The generic overload enables a non-capturing `static` mapper in allocation-sensitive code. + +```csharp +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + _opacity, + static (current, input, opacity) => current.Opacity(input, opacity)); +} +``` + +The context validates publication ownership, topology, resource transfer, and callback completion as one transaction. An exception leaves no partial recording and releases transferred resources best-effort. + +`ContributeValues` wraps a value-eligible fragment so its values composite into the target when published. `Layer` records a finite off-screen layer over an explicit logical domain and returns its composited single value; it is the explicit boundary that turns a mixed painter sequence into a value. `OwningTargetLayer` is the same boundary for a recording that has no finite domain available yet: it stays symbolic until the owning target domain resolves, and graph finalization rejects it if no enclosing scope or request supplies one. `TargetLayerScope` scopes ordered target work to a symbolic `TargetRegion` and stays effectful rather than becoming a value. `MaterializedInput` adopts a target that is already materialized as a value without copying it, and `TargetCapture` records a declared capture of the active target. None of them publishes automatically. + +`TryCalculateInputBounds` unions the current input bounds from concrete recording metadata. It returns `false` when any input still depends on an unresolved owning target domain, and, like the handle-level probes, executes no deferred work. + +## Immutable definitions and per-recording calls + +Public callbacks are authored in two layers: + +- A `*Definition` fixes callback code, metadata contracts, resource-slot schema, and operation kind. +- `.Call(state, bindings)` supplies the values and request-scoped resource tokens for one recording. + +Definitions are immutable. Reuse a static/shared definition when its callback and metadata are fixed to avoid needless allocation. Equivalent definitions recreated later still reuse the same internal plan because the engine derives equivalence from the callback and declared metadata, not object lifetime. Use a distinct immutable definition when those fixed characteristics differ. Put per-recording values only in call state and bindings. This keeps an operation's schema stable without requiring application-provided identity values. + +```csharp +public RenderFragmentHandle OpaqueSource(OpaqueRenderCall call); +public RenderFragmentHandle OpaqueMap(RenderFragmentHandle input, OpaqueRenderCall call); +public RenderFragmentHandle OpaqueCombine(IReadOnlyList inputs, OpaqueRenderCall call); +public RenderFragmentHandle OpaqueExpand(IReadOnlyList inputs, OpaqueRenderCall call); + +public RenderFragmentHandle TargetScope(RenderFragmentHandle input, TargetScopeCall call); +public RenderFragmentHandle TargetCommand(IReadOnlyList inputs, TargetCommandCall call); +public RenderFragmentHandle RawTargetScope(RenderFragmentHandle input, RawTargetScopeCall call); +public RenderFragmentHandle RawTargetCommand(RawTargetCommandCall call); +``` + +`RenderNode.PrepareForRequest(RenderNodePreparation)` runs on every request before that node's children are recorded. Recording walks children first, so a node whose children depend on the request - one that records a nested graph at the request's density - cannot rebuild them from `Process`, where they are already recorded. `RenderNodePreparation` carries only what is settled before any fragment exists: the request's output scale, working-scale ceiling, intent, purpose, and target domain. It runs on every request, so an override that changes nothing must cost nothing. + +`OpaqueRenderDefinition.Create` declares source, map, combine, or expansion metadata through its bounds, hit-test, cardinality, scale, and optional input-readback contracts. `TargetScopeDefinition.Create` and `TargetCommandDefinition.Create` declare their guarded target behavior. A scope also declares the space its replay transform lives in through `RenderScopeTransformSpace`: the default `AmbientTarget` is a transform defined against the surrounding target transform, which already carries the scope's own scale, while `InputLogical` states that the transform is expressed in the input's own coordinates so the declared `RenderScaleContract`'s backward map carries an output demand back to that input. Declaring `InputLogical` for a scope that in fact appends to the destination matrix rasterizes the input enlarged and then draws it enlarged again. `RawTargetScopeDefinition.Create` and `RawTargetCommandDefinition.Create` declare the same binding schema while preserving a deliberately request-local canvas boundary. + +```csharp +private sealed record DrawState(float Opacity); + +private static readonly RenderResourceSlot s_brush = new(); + +private static readonly OpaqueRenderDefinition s_draw = + OpaqueRenderDefinition.Create( + static (session, state) => session.UseResource( + s_brush, + brush => Draw(session, brush, state.Opacity)), + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply, + resources: [s_brush]); + +public override void Process(RenderNodeContext context) +{ + RenderResource brush = context.Borrow(_brush); + OpaqueRenderCall call = s_draw.Call( + new DrawState(_opacity), + [s_brush.Bind(brush)]); + + context.PublishMappedInputs( + call, + static (current, input, recordedCall) => current.OpaqueMap(input, recordedCall)); +} +``` + +The callback for a guarded operation receives a session that addresses resources through the declared `RenderResourceSlot`. It cannot use an arbitrary request token that was not declared by the definition. + +## Resources and lifetimes + +```csharp +public RenderResource Own(T resource) where T : class, IDisposable; +public RenderResource Borrow(T resource) where T : class; + +public abstract class RenderResourceSlot { } + +public sealed class RenderResourceSlot : RenderResourceSlot + where T : class +{ + public RenderResourceSlot(); + public RenderResourceBinding Bind(RenderResource resource); +} +``` + +`Own` transfers disposal responsibility to the active request family. `Borrow` retains caller ownership and requires the raw object to remain usable for the request. Both return opaque request-scoped tokens; neither is a public output-reuse identity. + +Each definition declares all of its slots in `resources:`. A definition's `resources:` list is an `IEnumerable` of the non-generic base, which is how slots of different resource types travel together; the base is not otherwise part of the authoring surface. Each call binds every declared slot exactly once with `slot.Bind(token)`, and may not bind an undeclared or differently typed token. `RenderResourceBinding` is intentionally created only by a typed slot. + +Guarded opaque, geometry, and target callbacks lease a resource through their slot: + +```csharp +static (session, state) => session.UseResource( + s_brush, + brush => Draw(session, brush, state.Opacity)) +``` + +## Raw target calls + +Raw calls are for existing behavior that must access an unguarded canvas and cannot be represented with typed operations. They are opaque external work and never become persistently reusable output. They still use a generic immutable definition and typed binding schema. + +The raw session intentionally receives resources by token. Put the token in per-call state and bind that same token to the declared slot, so the declaration is validated before execution. + +```csharp +private sealed record BackdropState(RenderResource Backdrop); + +private static readonly RenderResourceSlot s_backdrop = new(); + +private static readonly RawTargetCommandDefinition s_backdropCommand = + RawTargetCommandDefinition.Create( + static (session, state) => session.UseResource( + state.Backdrop, + backdrop => backdrop.Draw(session.Canvas)), + queryBounds: new Rect(0, 0, 1, 1), + hitTest: RenderHitTestContract.None, + resources: [s_backdrop]); + +public override void Process(RenderNodeContext context) +{ + RenderResource backdrop = context.Borrow(_backdrop); + context.Publish(context.RawTargetCommand( + s_backdropCommand.Call( + new BackdropState(backdrop), + [s_backdrop.Bind(backdrop)]))); +} +``` + +Use a guarded target definition when the operation's region and access can be declared. Use raw definitions only for the unavoidable external-canvas boundary. + +## Shader and geometry calls + +Shader source, entry-point kind, fixed bounds behavior, uniform schema, and resource-slot schema belong to `ShaderDefinition`. Per-recording uniform values and tokens belong to `ShaderCall`. + +```csharp +private sealed record TintState(float Amount); + +private static readonly ShaderDefinition s_tint = + ShaderDefinition.CurrentPixel( + """ + uniform float amount; + half4 apply(half4 color) { + return half4(color.rgb * amount, color.a); + } + """, + static bindings => bindings.Uniform("amount", static state => state.Amount)); + +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + new TintState(_amount), + static (current, input, state) => current.Shader(input, s_tint.Call(state))); +} +``` + +Use `ShaderDefinition.CurrentPixel` for a `half4 apply(half4 color)` stage. Use `.WholeSource` for a `half4 main(float2 coord)` stage with its required `uniform shader src;` input and a fixed `RenderBoundsContract`. `ShaderDefinitionBuilder.Uniform` maps state to canonical uniforms. Its value providers, custom binders, and `.Resource` binders must be non-capturing `static` callbacks, so every changing value flows through `TState` and is covered by the owning node's `HasChanges` update. `.Resource` declares a typed child-shader slot and coordinate space; bind its token with that slot in `.Call`. + +A whole-source stage that enlarges what it samples also declares `inputDemand`: a `RenderInputDemandContract` mapping the stage's resolved output demand to the demand it places on `src`. Without it the output demand reaches `src` unchanged, so an unbounded or vector source asked for 1x output rasterizes at 1x and is then stretched. The default leaves demand unchanged, which is correct only for a stage that samples at the density its own consumer asked for. + +Several definitions over one source can share its parsed form: `SkslSource.CurrentPixel(source)` and `SkslSource.WholeSource(source)` validate the text once, and the matching `ShaderDefinition` factories accept the result in place of a raw string. The parsed source is immutable and carries its `Kind` and `IdentityHash`; passing one of the wrong kind is rejected where the definition is declared. + +`GeometryDefinition.Create(render, bounds, hitTest, requiresReadback, resources)` follows the same model and produces `GeometryCall` for `RenderNodeContext.Geometry`. Geometry callbacks lease declared tokens through slots. + +`FilterEffectContext` accepts the same public calls: + +```csharp +context.Shader(s_tint.Call(new TintState(_amount))); +context.Geometry(s_geometry.Call(new GeometryState(_radius))); +``` + +`FilterEffectContext.TryGetWorkingScale(out float)` probes whether the nominal effect-input density is concrete. The `WorkingScale` property throws while that density is unresolved or branch-dependent, so use the probe during `ApplyTo` and defer device-pixel decisions to an execution-time shader, geometry, or custom-effect callback when it returns `false`. + +## Metadata contracts + +Definitions use `RenderBoundsContract`, `RenderHitTestContract`, `RenderScaleContract`, `RenderValueCardinality`, and, where applicable, `TargetRegion`, `TargetAccess`, `RenderInputReadback`, device-grid sensitivity, and device-grid mapping. Metadata callbacks must be deterministic, side-effect-free, and non-capturing. The engine derives their operation-shape fingerprint from the fixed callback and contract; author code supplies no manual identifier. + +`RenderScaleContract.MapInputSupply` declares both directions of the density relationship of an element-wise one-input operation: a pure `Func` mapping the input supply forward to the output supply, and a second pure `Func` mapping a backward output demand to the input demand that satisfies it. It is the right default for any one-input density map. `RenderScaleContract.MapInputSupplyPreservingDemand` declares the forward callback alone and leaves backward demand unchanged; its name states its precondition, which is that the operation consumes its input at the density its own consumer demands. Either callback may be evaluated again while resolving symbolic upstream metadata. An operation that resamples must use `MapInputSupply`, or an unbounded input materializes at the operation's own output demand instead of the density the operation consumes. For an affine density map, the public statics `TransformRenderNode.RescaleDensity` and `TransformRenderNode.RescaleDemand` supply the two callbacks; they are the two halves of one relationship rather than inverses of each other, because each errs toward more detail through a different axis. + +`RenderInputDemandContract` carries a backward demand where a `RenderScaleContract` cannot. `MapOutputDemandToInput` maps one input; `MapOutputDemandPerInput` maps each input separately by its zero-based index, which is what a combine or an expand needs when it resamples its inputs asymmetrically — enlarging the first while passing the second through. `OpaqueRenderDefinition.Create` accepts one as `inputDemand`, and only a combine or an expand may declare it: a one-input map carries demand back through `RenderScaleContract.MapInputSupply` instead, and a source has no input to demand from. `ShaderDefinition.WholeSource` accepts one for the same reason, because a whole-source stage resolves its own supply from the working scale and has no forward map to declare. `GeometryDefinition.Create` and `TargetCommandDefinition.Create` accept one too: geometry is a materialization boundary that can draw its input through an enlarging transform, and a target command can resample any of the inputs it draws onto the target, so the command's contract is resolved per input index. In every case the default leaves demand unchanged, which is correct only for an operation that consumes its input at the density its own consumer asked for. A target scope has no equivalent: the backward half of its `RenderScaleContract` is read only for the engine's internal value-replay map, because an ordinary scope replays its input onto the target at the target's own density. + +`RenderHitTestContract.FromSlot` builds a hit test that reads the resource a call bound to a `RenderResourceSlot`, resolved against that call's bindings rather than captured by the definition. `RenderHitTestContext.UseResource` exposes the same resolution to a `Custom` callback. A hit-test callback still may not capture a `RenderResource` itself, because the definition holding it outlives every call. + +## Recording rules + +- `Opacity`, `Blend`, `OpacityMask`, `ContributeValues`, `Layer`, `OwningTargetLayer`, `TargetLayerScope`, `MaterializedInput`, `TargetCapture`, shader calls, geometry calls, opaque source, map, combine, and expansion calls, and target scopes return unpublished handles. +- Opaque source, map, combine, and expansion calls must match the topology declared by their definition. +- Target commands are ordinary effectful handles; publish them at the intended painter position. +- A fragment may be published or consumed more than once only when it is value-eligible. Publishing or consuming an effectful fragment — a target command or scope, or any wrapper built over one — more than once is rejected and the recording rolls back. +- `RawTargetScope` replays its input exactly once. `RawTargetCommand` has no logical value input. +- `RecordNode` and `RecordSubtree` record nested work in the active request; no transaction-scoped handle may escape the call that produced it. + +## Cache and failure rules + +The renderer controls retained output and resource lifetime. An author invalidates node content only by setting `HasChanges`; no context method opts a recording out of reuse and no token carries public content metadata. Raw target work remains request-local by definition. + +`ContainerRenderNode` sets `HasChanges` itself when its children change — `AddChild`, `RemoveChild`, `RemoveRange`, `SetChild`, and `BringFrom` — because replacing a child changes what the container composes and the container's own state does not otherwise record it. `SetChild` with the child already at that index is a no-op. A container assembled and then rendered is therefore dirty on its first frame, which is one frame before its cache can warm. + +*Amended.* The reuse opt-out this contract withheld was reinstated during implementation: `RenderNodeContext.DisableRenderCache()` monotonically removes the current transaction from persistent caching, and `IsRenderCacheEnabled` reports that state. It was published because a node that records a child it cannot list in `ChildNodes` has no other way to stay correct — the cache cannot observe a change reported only by that unlisted child. It is not a second invalidation signal: `HasChanges` remains the only way to invalidate a cached node. The migration is in [breaking-changes.md](breaking-changes.md), which carries the current contract. The rest of the paragraph is unchanged: no token carries public content metadata, and raw target work stays request-local. + +If `Process` or a deferred callback fails, the engine preserves the primary failure, releases request-owned resources best-effort, and does not publish a partial result. diff --git a/docs/specs/004-gpu-pass-fusion/contracts/render-request.md b/docs/specs/004-gpu-pass-fusion/contracts/render-request.md new file mode 100644 index 0000000000..345a8d3148 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/contracts/render-request.md @@ -0,0 +1,134 @@ +# Internal Render Request Contract + +## Request entry points + +`RenderNodeRenderer` creates one complete request for rendering, rasterization, measurement, or hit testing. A request contains intent, purpose, optional target domain, optional requested region, output/maximum working scales, and renderer-owned execution policy. + +Frame requests record every root in painter order. Bounds and hit-test requests use the same recorder and metadata analysis but stop before deferred GPU, media, and canvas work. Public APIs never return a fragment handle outside its active recording transaction. + +## Renderer frame sequencing + +```text +update node state + -> record all roots into one graph + -> lower scoped target dependencies + -> resolve metadata and required regions + -> select safe retained-output substitutions/captures + -> plan islands, shader runs, and resource leases + -> execute once in painter order + -> publish successful output and settle resources +``` + +Content invalidation enters this sequence through `RenderNode.HasChanges`. It is the sole public signal that a node's recorded content must be refreshed. The renderer invalidates the changed node and its recorded ancestors, while independently reusable unchanged descendants retain their warm-up and may still satisfy cache lookup. The context's only public retention control is `DisableRenderCache()`, which a node must call when it records a child it cannot list in `ChildNodes`; there is no public way to force retention, and public resource tokens carry no content-invalidation fields. + +## Recording + +### Transaction protocol + +Each `Process` invocation creates a transaction checkpoint. The recorder provides borrowed input handles, validates publications and resource transfers, and commits only on normal return. An exception discards fragments, publications, and pending transfers from that invocation, releases owned raw objects best-effort, preserves the primary exception, and invalidates every context/handle/token facade. + +`PublishMappedInputs` runs its mapper inside this same checkpoint. It is a strict one-input/one-output helper, not an implicit pass-through or a general expansion API. + +### Definition and call lowering + +Public authoring passes a typed call to the context. Internally, the call is lowered into execution state plus immutable metadata captured from its definition. The fixed definition supplies callback code, bounds/hit-test/scale/cardinality or target contracts, and typed resource-slot schema. The call supplies current state and bindings. + +The engine derives plan shape from those fixed inputs. Equivalent definitions recreated later produce the same plan shape; sharing a definition instance is an allocation optimization, not a correctness condition. There is no caller-provided operation or resource identifier in the lowering protocol. + +### Resource binding validation + +A definition declares a heterogeneous list of `RenderResourceSlot` values. A call must bind exactly the declared slots, once each, through typed `slot.Bind(token)` values. Guarded execution sessions use a slot to lease the raw value. Raw execution sessions retain token leasing because the raw-canvas boundary is request-local; their state includes the token and the call still binds it to the matching slot. + +## Recorded IR + +### Ordered fragment graph and value graph + +Every fragment preserves ordered child fragments, value inputs, target effects, scope kind, publication/provenance, value cardinality, and whether it can be consumed as a value input. Target commands and raw commands are real ordered fragments even when their value cardinality is none. Captures are target-token-to-value edges. Opacity, blend, mask, guarded scope, and raw scope wrap their child fragment without moving target effects out of painter order. + +### Scope-local target lowering + +Target effects are lowered as scoped target-token dependencies rather than a request-global side list. A guarded target scope has declared bounds/hit-test/scale behavior; a guarded target command has declared affected region, query bounds, access, and optional readback. Raw scope and raw command are opaque external boundaries. A raw scope must replay its input exactly once. + +### Provenance + +Root provenance retains painter order and query behavior independently of materialized value substitutions. `RootOutputExtent` covers contributing values and pixel-writing target effects; query bounds remain separate. A null requested region selects the complete output extent. + +## Metadata analysis + +### Forward resolution + +Forward analysis resolves output bounds, effective supply, value cardinality, contribution, target dependence, and hit-test provenance from the complete graph. It may reevaluate pure bounds or scale mappings after symbolic upstream metadata becomes concrete. It does not execute deferred execution callbacks; only pure metadata callbacks run during analysis. + +### Backward regions + +Backward analysis starts at the requested root region or complete output extent and propagates required regions through value transforms and scope-local target dependencies. Unknown mappings request full inputs. Full target access requires a finite owning target domain. + +## Retained-output resolution + +The renderer discovers safe candidates after metadata and region analysis. A hit substitutes an internal materialized value while preserving original query provenance. A miss inserts a capture point after the scheduled producer. Capture publication happens only after the complete request succeeds. + +The public lifecycle is deliberately simple: if node content changes, the node sets `HasChanges`; otherwise the renderer may reuse eligible output according to its complete internal plan and request conditions. Raw target work, unbounded external work, and target-dependent regions without a proven complete predecessor are not safely retained. + +## Island planning + +The planner partitions work at materialization, opaque callbacks, geometry callbacks, target commands/captures/readback, target scopes where equivalence is unproven, raw canvas work, external targets, backend transitions, dynamic topology, unsupported shader capability/resource limits, a fragment consumed more than once, an incompatible working-scale transition between adjacent stages, and the retained-output substitution and capture points selected earlier in the sequence. + +An island is maximal only if combining adjacent work preserves painter order, target-token order, value semantics, bounds/ROI, scale, color/alpha semantics, hit-test provenance, output cardinality, and required synchronization. + +## Shader fusion + +### Eligibility + +Eligible stages are current-pixel `ShaderDefinition` calls and engine operations with a proved equivalent lowering. Whole-source shaders, geometry, opaque work, coordinate-changing or unknown sampling stages, blend/composite, readback, capture, external targets, raw work, and backend transitions are barriers. + +*Amended (`991f49e70`).* A whole-source shader is no longer a barrier. One may lead a fused run of downstream current-pixel and opacity stages, so a run's leading stage may be coordinate-changing; folding work upstream of a whole-source stage stays rejected, because its sampling is too broad to prove that rewrite equivalent. `research.md` R8 and `plan.md`'s Shader and Geometry seam carry the current rule. + +An arbitrary public current-pixel shader cannot cross an analytic or antialiased coverage-producing source. Coverage must first resolve into a materialized value unless an engine-owned stage has a mechanical premultiplied-coverage-homogeneity proof. + +### Composition and binding + +The compiler merges compatible stages in authored order, isolates uniform/resource names and declarations, validates source and backend limits, and splits deterministically before an overflowing stage. A one-stage run is valid. + +After plan selection, runtime binding receives the resolved logical bounds, required region, effective supply, working density, device footprint, call-state uniforms, and child resources. A binding failure fails the request and suppresses output publication. A target-allocation failure fails the request under `RenderIntent.Delivery`; under `RenderIntent.Preview` the renderer drops the affected contribution, completes the request with degraded pixels, and publishes no retained output for that request. + +### Program reuse + +The renderer owns compiled shader reuse based on the complete merged source, layout, backend capability, color/alpha/format contract, and relevant compile options. Hashing is only a lookup optimization; full equality decides reuse. Public shader calls never provide program identity fields. + +A stage may additionally carry an engine-authored SPIR-V lowering. When the run is that single stage, the shared graphics context supports it, and its input and output are matching RGBA16F footprints at equal density, the renderer executes that lowering through a separate SPIR-V program cache; a native compile or resource failure falls back to the SkSL lowering, which remains the compatibility contract. Backend selection is engine-owned and never author-declared. + +## Resource and scale plan + +### Working density + +`RenderScaleContract.PreserveInputSupply` copies the resolved supply for an element-wise one-input map or replay scope. `RenderScaleContract.MapInputSupply` applies a pure transform to the corresponding input supply, which may return `EffectiveScale.Unbounded`, together with the pure transform that carries a backward output demand to the input demand. `RenderScaleContract.MapInputSupplyPreservingDemand` declares the forward transform alone and leaves demand unchanged. The callbacks are reevaluated after symbolic metadata resolves. + +Materializing values choose a concrete positive density from complete bounds, input supplies, output scale, and maximum working scale. The renderer applies its per-buffer device-axis clamp and binds the actual resulting density to execution. Region cropping does not recompute a declared supply. + +### Pool and liveness + +The resource plan computes first/last use for each materialized value and leases exact compatible targets from a renderer-owned pool. Planner-owned targets are initialized before guarded callback access. Borrowed root and presentation targets are neither pooled nor disposed by the request. + +### Synchronization + +Synchronization is declared per consumer through a sampling intent. Declared CPU readback, backend transitions, and cross-context or undetermined consumers submit and wait; same-context texture sampling at a materialization boundary submits without waiting. Target-token dependencies and platform ownership transitions synchronize where they require it. Compatible same-backend shader stages do not introduce per-stage flushes. Guarded callback canvases are executor-managed one-shot leases; raw canvases remain opaque external work. + +## Execution and failure + +`RenderRequestExecutor` owns plan resources. It acquires program/target leases, executes islands and scoped target dependencies in dependency/painter order, validates dynamic output against declared contracts, stages captures, publishes final state only after complete success, and settles every lease in all paths. + +On failure it preserves the first planning or rendering exception, discards partial outputs and staged captures, invalidates callback sessions, continues cleanup best-effort, and reports cleanup faults separately. + +## Nested requests + +Same-target nested rendering uses `RecordNode` or `RecordSubtree` and remains in the parent graph. Separate-target work records a child request before parent execution and inherits appropriate renderer ownership and request policy. A child requested region is expressed in child coordinates; it is never copied blindly from the parent. + +Nodes cannot retain public fragment handles between requests. NodeGraph-style wrappers use active request-local bindings and publish only while that binding is active. + +## 3D boundary + +`Scene3DRenderNode` records a graphics-backend source using an engine-defined opaque call. Execution resolves the declared bounds/density, renders one materialized 2D value, performs the required backend transition, and releases resources through the request owner. The 2D planner does not inspect 3D internals; fusion may begin after the materialized boundary. + +## Metadata-only queries + +Bounds and hit-test requests perform recording and metadata analysis only. They do not execute deferred callbacks, allocate renderer targets, read media frames, or publish retained output. Hit testing evaluates query provenance in reverse painter order and returns false outside a non-null requested region. diff --git a/docs/specs/004-gpu-pass-fusion/data-model.md b/docs/specs/004-gpu-pass-fusion/data-model.md new file mode 100644 index 0000000000..ec0202bbd7 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/data-model.md @@ -0,0 +1,150 @@ +# Data Model: Renderer-Wide GPU Pass Fusion + +## Relationship overview + +```mermaid +flowchart LR + N[RenderNode] --> C[RenderNodeContext] + C --> G[RecordedRenderGraph] + D[immutable Definition] --> K[per-recording Call] + S[typed RenderResourceSlot] --> K + K --> C + G --> M[ResolvedFragmentMetadata] + M --> R[RequiredRegion] + R --> P[ExecutionIslandPlan] + P --> E[RenderRequestExecutor] +``` + +The public model records an immutable operation shape and a request-local invocation of that shape. The internal model resolves the complete graph only after all nodes have recorded. + +## Public authoring entities + +### RenderNode + +`RenderNode` owns application state and implements `void Process(RenderNodeContext)`. `ChildNodes` exposes content dependencies in recording order but does not transfer ownership. + +`HasChanges` is the public content-invalidation signal. A node sets it when a property can change pixels, bounds, hit testing, or topology. The renderer observes and clears it as part of successful request processing, invalidating that node and its recorded ancestors without resetting unchanged descendants. Public node code does not expose or supply output-reuse identities. + +### RenderNodeContext + +The engine creates one sealed context for each `Process` invocation. It exposes borrowed `Inputs`, request intent/purpose/domain/scale metadata, and recording methods. It is invalid after the invocation returns. + +`IsRenderCacheEnabled` reports whether the current transaction is still cache-eligible, and `DisableRenderCache()` monotonically opts the node out. A node that records a child it does not list in `ChildNodes` must call `DisableRenderCache`, because the cache cannot observe a change reported only by that unlisted child. + +Publication is explicit: + +| Method | Meaning | +|---|---| +| `PassThrough` | Publish all inputs unchanged and ordered. | +| `Publish` / `PublishRange` | Publish authored outputs at their intended painter position. | +| `PublishMappedInputs` | Map each input to exactly one published output in the same order. | +| `Drop` | Abandon an unpublished handle. | + +`PublishMappedInputs` runs synchronously. Its callback may record intermediate handles but cannot publish; a violation rolls back the transaction. It is not a general map/flat-map primitive. + +### RenderFragmentHandle + +A handle represents a fragment in the active recording transaction. It carries no public executable canvas or persistent ownership. Methods return handles; publication makes them node outputs. Metadata and hit testing can be unavailable until enclosing target information is resolved. + +### Definitions and calls + +A public callback operation has these two entities: + +| Entity | Holds | +|---|---| +| `*Definition` | Fixed callback code, operation metadata, and resource-slot schema. | +| `*Call` | State and resource bindings for one recording. | + +Definitions are immutable and are commonly static/shared when their fixed shape is unchanged to avoid allocation. Equivalent definitions recreated later still share an engine-derived plan because equivalence comes from callback code and declared metadata, not object lifetime. Calls are created by `.Call(state, bindings)` for each recording. Changing call state is ordinary node content change and requires the owning node to set `HasChanges` before the next request. + +The rendering context accepts: + +- `OpaqueRenderCall` through source, map, combine, and expansion methods; +- `TargetScopeCall` and `TargetCommandCall` for guarded target work; +- `RawTargetScopeCall` and `RawTargetCommandCall` for opaque external canvas work; +- `ShaderCall` and `GeometryCall` for value transforms. + +### RenderResource and RenderResourceSlot + +`RenderResource` is an opaque request-scoped token. `RenderNodeContext.Own` transfers a disposable raw object to the request family; `Borrow` leaves ownership with the caller. Neither changes output invalidation semantics. + +`RenderResourceSlot` is a typed address declared by a definition. `slot.Bind(token)` creates the only valid public binding form. A call binds every declared slot exactly once and cannot bind an undeclared or differently typed token. + +Guarded sessions use `UseResource(slot, callback)` to lease the matching raw value. Raw sessions intentionally use `UseResource(token, callback)` because their callback boundary is request-local; the token remains in call state and the same token is also bound to a typed slot for validation. + +### ShaderDefinition and GeometryDefinition + +`ShaderDefinition` fixes source, entry-point kind, bounds behavior, uniforms, and child-shader slots. `.CurrentPixel` models `half4 apply(half4 color)`; `.WholeSource` models `half4 main(float2 coord)` with `uniform shader src;`. `ShaderDefinitionBuilder` maps call state to uniforms and declares typed child resources. `.Call` yields the `ShaderCall` passed to `RenderNodeContext.Shader` or `FilterEffectContext.Shader`. + +`GeometryDefinition` fixes a geometry callback, bounds, hit testing, optional readback, and slots. `.Call` yields the `GeometryCall` passed to the corresponding context method. + +### Raw target definitions + +Raw definitions declare metadata and typed slots even though their canvas work is opaque external. A raw scope wraps and replays one input exactly once. A raw command has no logical value input. Both prevent persistent output reuse because the renderer cannot inspect their internal canvas behavior. + +### Metadata contracts + +Definitions use `RenderBoundsContract`, `RenderHitTestContract`, `RenderScaleContract`, `RenderValueCardinality`, target region/access, input readback, and device-grid contracts as appropriate. Metadata callbacks are deterministic and side-effect-free, and may capture only lightweight immutable CPU values, never a resource, context, request graph, mutable payload, or capturing delegate. Shader-definition uniform and resource binders are stricter and must not capture at all. The engine derives operation-shape information from the definition and contract callbacks. + +`RenderScaleContract.MapInputSupply` accepts a pure one-input supply transform together with the backward demand transform that matches it; `RenderScaleContract.MapInputSupplyPreservingDemand` accepts the supply transform alone and leaves demand unchanged. Both are reevaluated after symbolic upstream metadata becomes concrete. + +## Recorded request entities + +### RenderRequestOptions + +Options carry intent, purpose, optional target domain, requested region, output and maximum working scales, and the renderer's execution policy. A complete render request owns one active recording transaction and all temporary state required to finish or roll it back. + +### RecordedRenderGraph + +The graph preserves authored painter order and consists of ordered fragments plus embedded value edges. Nested same-target recording remains in this graph. Separate-target work records a child request before parent execution. + +### RecordedRenderFragment and RenderFragmentReference + +A fragment has ordered inputs, conservative bounds/scale/cardinality metadata, contribution behavior, hit-test provenance, and an execution payload. Value fragments can be transformed or combined. Target effects remain ordinary ordered fragments even when they produce no value. + +### Target scopes, commands, and captures + +Guarded scopes and commands declare their target behavior. Captures form explicit target-to-value edges. A finite layer may materialize mixed painter work into one value; a target-layer scope stays an effectful scope. Raw scope/command fragments conservatively form opaque external boundaries. + +## Analysis and planning entities + +### ResolvedFragmentMetadata and RequiredRegion + +Forward analysis resolves conservative output bounds, hit-test provenance, value cardinality, and effective supply. Backward analysis maps requested output regions to the required regions of their producers. Symbolic target dependencies become concrete only after their enclosing scopes are known. + +### RenderCacheCandidate and retained output + +The renderer may select a safe retained-output candidate after complete graph analysis. Node content invalidation is driven by `HasChanges`; authors do not define cache fields or token content identities. Raw target scope/command fragments, fragments carrying an external target-token dependency, and filter-effect segments that cannot materialize are not candidates for persistent reuse. + +### ExecutionIsland and ExecutionIslandPlan + +The planner partitions the graph at materialization, target dependencies, readback, backend transitions, raw work, and unsupported fusion seams. It may combine compatible current-pixel shader stages into one island while preserving fragment order, bounds, scale, color/alpha semantics, and target behavior. + +*Amended.* Fusion was widened past current-pixel color shaders in `991f49e70`. An island may now also fold an engine-proven invariant opacity stage, and a single whole-source shader may lead a fused run of downstream current-pixel or opacity stages, though it never consumes an upstream stage within that run. `research.md` and `plan.md` carry the current fusion-scope contract. + +### StructuralPlanCache and request-time binding + +An internal plan records fixed graph topology, operation schemas, shader source/binding layout, barriers, and allocation shape. Request-time bindings contain current call state, request-scoped resources, resolved bounds/regions, densities, target allocation data, and frame inputs. The engine owns this split; public authoring supplies definitions and calls only. + +### ResourcePlanUseSchedule and RenderTargetLease + +The resource plan calculates first/last use for materialized values and manages pooled targets. A lease has one owner at a time and is released, transferred, or disposed exactly once. Externally borrowed root/presentation targets are never pooled or disposed by the request. + +### CompiledShaderRun + +A compiled shader run stores merged source/binding layout and backend capability requirements. Runtime uniforms and child resources bind after final bounds, density, and target allocation are known. Full program equality guards any hash lookup. + +## Request lifecycle + +1. Begin a request and record each node transactionally. +2. Lower scope-local target dependencies and resolve forward metadata. +3. Propagate required regions backward and select eligible retained-output substitutions/captures. +4. Build islands, shader runs, and resource leases. +5. Execute in dependency and painter order. +6. Publish retained output only after complete success, then settle all leases and resource transfers. + +Any failure invalidates sessions/handles, suppresses partial output, preserves the primary failure, and performs remaining cleanup best-effort. + +## Evidence is not request state + +Test probes, renderer statistics, golden artifacts, and benchmark measurements observe recording, planning, allocation, and execution. They are not mutable state carried by production requests or public callbacks. diff --git a/docs/specs/004-gpu-pass-fusion/plan.md b/docs/specs/004-gpu-pass-fusion/plan.md new file mode 100644 index 0000000000..4e8e24758a --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/plan.md @@ -0,0 +1,316 @@ +# Implementation Plan: Renderer-Wide GPU Pass Fusion + +**Branch**: `speckit/004-gpu-pass-fusion` | **Date**: 2026-07-19 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `docs/specs/004-gpu-pass-fusion/spec.md` + +## Summary + +Replace the executable `RenderNodeOperation[]` pull pipeline with a renderer-wide, recording-only request pipeline. Every `RenderNode` implements `void Process(RenderNodeContext)` and publishes ordered `RenderFragmentHandle` instances through the context. One fragment DAG preserves value contributions, target commands/captures, finite value Layers, symbolic current-target `TargetLayerScope` effects, other target-scope nesting, and painter order; its embedded value DAG exposes only the semantic values that may be analyzed or fused. Scope-local target-token topology is derived after recording, never stored in an early root-global side list. The recorder discovers the complete 2D request without consulting render caches or touching GPU/media resources, then lowers scope-local token dependencies, resolves forward query/output metadata, propagates requested regions backward, substitutes safe cache entries, partitions execution islands, compiles compatible Shader/opacity runs, schedules pooled resources, and executes the request once. + +The public filter-effect lifecycle remains `FilterEffect.ApplyTo(FilterEffectContext, Resource)`. `FilterEffectContext` and `RenderNodeContext` will accept the same renderer-neutral `ShaderCall` and `GeometryCall` values, built from `ShaderDefinition` and `GeometryDefinition`. Only a mechanically validated current-pixel Shader form is fusible, and that validation proves coordinate restrictions rather than commutation with antialiased coverage. Arbitrary CurrentPixel work therefore remains after upstream coverage is resolved; only engine-known operations with mechanically proven premultiplied-coverage homogeneity may cross a coverage-producing rasterization boundary. Whole-source Shader, Geometry, legacy custom effects, 3D work, destination readback, and unknown callbacks remain explicit barriers. Legacy `CustomEffect` preserves its raw execution callback as marked opaque-external work, so exact physical-pass claims require zero such boundaries. The abandoned implementation branch is an extraction source for leaf algorithms, tests, and independently reproducible visual evidence, not a branch or subsystem to merge. + +*Amended (`991f49e70`).* The fusion envelope widened after this paragraph was written, so neither "only a mechanically validated current-pixel Shader form is fusible" nor "whole-source Shader ... remains an explicit barrier" still holds. Six built-in Skia colour filters record current-pixel stages, a filter-effect segment derives its cardinality from its items instead of always declaring `Dynamic`, and a whole-source Shader may lead a fused run of downstream current-pixel and opacity stages, though it still never consumes an upstream stage. Geometry, legacy custom effects, 3D work, destination readback, and unknown callbacks remain explicit barriers. The Shader and Geometry seam below carries the current rule. + +## Technical Context + +**Language/Version**: C# with `LangVersion=preview`; .NET 10 (`net10.0` and `net10.0-windows`) + +**Primary Dependencies**: Beutl.Engine rendering abstractions, SkiaSharp/SkSL, Avalonia geometry primitives, the existing Vulkan/Skia backends, Beutl.Engine.SourceGenerators as already referenced by consuming projects + +**Storage**: In-memory recorded graphs, structural/program caches, render-output caches, pooled RGBA16F render targets, and immutable raw linear-RGBA16F starting-SHA references plus fingerprinted manifests under this feature's evidence directory; no database or persisted project-format change (*Amended*: the references and manifests were withdrawn with the evidence tree — see Phase A step 3) + +**Testing**: NUnit + Moq for unit/integration/public-contract coverage; Vulkan-gated NUnit execution-shape tests; BenchmarkDotNet for paired renderer benchmarks + +**Target Platform**: Cross-platform Beutl desktop engine on macOS, Windows, and Linux; preferred GPU execution where available and the existing supported ordinary-2D fallback otherwise + +**Project Type**: Desktop compositing/rendering engine with plugin-facing public APIs + +**Performance Goals**: Exactly one GPU pass for the distinct-node, coverage-resolved-source `Shader A -> Opacity -> Shader B` proof; one structural compilation across 100 parameter-only frames; zero warmed intermediate creations for stable bounds; no growth in peak live intermediates between equivalent 3-stage and 10-stage linear schedules; paired warmed median frame-time ratio whose 95% confidence interval is below 1.0 for the cross-boundary workload (*Amended*: this last goal was withdrawn with the evidence tree — see Phase A step 3 and spec.md SC-008; it is measurable on demand and is not a merge gate) + +**Constraints**: Recording performs no GPU, target allocation, media-read, snapshot, flush, synchronization, or nested execution; preserve painter order, scoped target dependencies, antialiased coverage application order, premultiplied linear RGBA16F semantics, feature-003 density rules and 16,384-pixel buffer clamp; keep output-cache identity separate from structural/program identity; preserve current-main allocation-failure behavior; no GPU fusion across unresolved coverage production, opaque, legacy raw-canvas, 3D, readback, destination-dependent/unproven composite, external-target, or backend boundaries + +**Scale/Scope**: One complete target-surface request, including all top-level drawables and nested/auxiliary 2D requests; migrate 29 production and 7 test `Process` overrides plus every direct processor/operation and scale-helper consumer across `Beutl.Engine`, `Beutl.NodeGraph`, `Beutl.ProjectSystem`, `Beutl.Editor`, `Beutl.AgentToolkit`, and application call sites; add a non-friend public API contract test project + +## Constitution Check + +*GATE: Passed before Phase 0 research and re-checked after Phase 1 design.* + +| Principle / gate | Result | Design evidence | +|---|---|---| +| I. License Firewall | PASS | All production work remains in MIT projects. No reference to `Beutl.FFmpegWorker` is added and no IPC boundary changes are planned. | +| II. Dual-Target Framework | PASS | Existing `net10.0` and `net10.0-windows` targets remain unchanged. New Engine code is backend-neutral unless already guarded by the existing backend projects. | +| III. Test-First with NUnit | PASS | Baseline and contract tests precede behavior changes; each planner, cache, fusion, lifetime, and fallback unit has matching NUnit coverage. BenchmarkDotNet is used only for performance evidence. | +| IV. Avalonia + Compiled Bindings | PASS / N/A | No XAML or UI control is introduced. | +| V. Style Belongs to the Linter | PASS | Implementation ends with repository format verification; the plan does not prescribe manual style-only edits. | +| VI. Source Generators Are Load-Bearing | PASS | One generator change ships: `ResourceClassEmitter` emits a nullable resource backing field and makes a non-nullable resource property throw when it holds no owned resource. No new ownership protocol is generated; `tests/SourceGeneratorTest` snapshots are updated and source-generator review is required (see Dependency and Review Boundaries). The new non-friend project references the existing generator only as an analyzer when its public authoring fixtures require generated members. | +| Quality gates | PASS BY PLAN | The implementation must pass format verification, dual-target solution build, `net10.0` tests with coverage settings, GPU-required tests on capable hardware, and review before merge. | + +Post-design re-check: the selected request recorder, planner, public descriptors, test project, and donor extraction policy introduce no constitutional exception. There is therefore no complexity violation to justify. + +## Project Structure + +### Documentation (this feature) + +```text +docs/specs/004-gpu-pass-fusion/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── public-api.md +│ ├── render-request.md +│ └── breaking-changes.md +└── tasks.md # generated by /speckit-tasks, not this phase +``` + +The paired baseline/feature evidence tree this plan originally specified (generator patch, +runner scripts, immutable RGBA16F references, and their manifests) was 61.5 MB across 267 +files and is not carried in the repository. Its acceptance role is served by the in-repo +golden, fusion, and failure suites under `tests/Beutl.UnitTests/Engine/Graphics/Rendering/`, +which compare fusion-disabled against fusion-enabled rendering in the same process and +device. Earlier phase documents below still describe the evidence tree as it was produced; +read those passages as a record of how the feature was validated, not as paths in the tree. + +### Source Code (repository root) + +```text +src/Beutl.Engine/Graphics/ +├── ImmediateCanvas.cs +├── FilterEffects/ +│ ├── FilterEffect.cs +│ ├── FilterEffectContext.cs +│ ├── ShaderDefinitionCalls.cs # public ShaderDefinition/ShaderCall authoring seam +│ ├── ShaderDescription.cs # engine-internal lowered form +│ ├── SkslSource.cs +│ ├── ShaderBindings.cs +│ ├── GeometryDefinitionCalls.cs # public GeometryDefinition/GeometryCall authoring seam +│ ├── GeometryDescription.cs # engine-internal lowered form +│ └── GeometrySession.cs +├── Rendering/ +│ ├── RenderNode.cs +│ ├── RenderNodeContext.cs +│ ├── RenderFragmentHandle.cs # replaces executable RenderNodeOperation +│ ├── RenderNodeRasterization.cs # one owned bitmap/logical-domain result +│ ├── RenderExecutionInput.cs # shared by Geometry/opaque/target callbacks +│ ├── RenderBoundsContract.cs # renderer-wide forward/backward bounds primitive +│ ├── RenderScaleUtilities.cs # feature-003 pure scale helpers +│ ├── RenderNodeRenderer.cs # high-level replacement for Pull APIs +│ ├── Renderer.cs +│ ├── GraphicsContext2D.cs +│ ├── Operations/ +│ │ ├── OpaqueRenderDescription.cs +│ │ ├── MaterializedInputDescription.cs +│ │ ├── TargetCaptureDescription.cs +│ │ ├── TargetScopeDescription.cs +│ │ ├── TargetRegion.cs # symbolic Full/Empty/Region(Rect) scope extent +│ │ ├── TargetCommandDescription.cs +│ │ └── RenderDefinitionCalls.cs # Opaque/TargetScope/TargetCommand/RawTarget definitions and calls +│ ├── Planning/ +│ │ ├── RecordedRenderGraph.cs +│ │ ├── RenderRequest.cs +│ │ ├── RenderRequestOptions.cs +│ │ ├── RenderRequestRecorder.cs +│ │ ├── RegionAnalyzer.cs +│ │ ├── RenderCacheResolver.cs +│ │ ├── ExecutionIslandPlanner.cs +│ │ ├── RenderRequestCompiler.cs +│ │ ├── CompiledRenderRequest.cs +│ │ ├── RenderRequestExecutor.cs +│ │ ├── StructuralPlanCache.cs +│ │ ├── ProgramCache.cs +│ │ └── RenderTargetPool.cs +│ └── Cache/ +│ ├── RenderNodeCache.cs +│ └── RenderNodeCacheHelper.cs # policy/invalidation only; no independent pull + +src/Beutl.Engine/Graphics3D/ +└── Scene3DRenderNode.cs # records an opaque backend source + +src/Beutl.NodeGraph/ # migrate wrapper/output nodes and query consumers +src/Beutl.ProjectSystem/ # migrate SceneDrawable and nested scene consumers +src/Beutl.Editor/ # migrate save-frame scale and renderer consumers +src/Beutl.AgentToolkit/ # migrate metadata-only query consumers +src/Beutl/ # migrate player/type-converter processor consumers + +tests/ +├── Beutl.UnitTests/Engine/Graphics/Rendering/ +│ ├── FilterEffects/ +│ ├── Recording/ +│ ├── Planning/ +│ ├── Fusion/ +│ ├── Cache/ +│ ├── Failure/ +│ └── Golden/ # holds the parity harness; Baseline/ went with the evidence tree +├── Beutl.Graphics3DTests/ # 3D opaque/backend boundary coverage +├── Beutl.PublicApiContractTests/ # non-friend authoring/compile contract +└── Beutl.Benchmarks/Rendering/ + └── RenderPipelineBenchmarks.cs +``` + +**Structure Decision**: Keep plugin-facing Shader and Geometry descriptions in the existing `Beutl.Graphics.Effects` namespace so `FilterEffectContext` authors need no new subsystem. Put the renderer-wide `RenderBoundsContract`, `RenderScaleUtilities`, render-node, and request types in `Beutl.Graphics.Rendering`, so custom-node authors do not depend on Effects merely to describe bounds or density. Place the implementation under renderer-wide `Rendering/Planning` and `Rendering/Operations` folders, not the donor's effect-private graph namespace. Add one lean non-friend NUnit project to compile public authoring examples without `InternalsVisibleTo`; add it to `Beutl.slnx` and do not copy donor tests tied to `Describe(EffectGraphBuilder, ...)`. + +## Design Overview + +### Complete-request pipeline + +```text +update all drawable trees + | +record all roots without cache substitution + | +ordered effectful fragment DAG + embedded semantic value DAG + | +lower scope-local target-token topology and dependencies + | +finalize and validate forward bounds / density / hit-test metadata + | +backward requested-region propagation + | +cache-hit substitution + cache-capture insertion + | +execution-island partition + Shader/opacity fusion + | +resource/synchronization schedule + | +single request execution and atomic cache publication +``` + +Each published fragment represents an ordered sequence containing zero or more materializable values plus target commands, non-contributing captures, finite value Layers, symbolic `TargetLayerScope`, and other target-state scopes. Commands flow through parent `Inputs`, so `[A, Clear, B]` remains distinct from `Layer { A, Clear, B }` and a child command cannot escape its Layer/opacity/transform scope. The embedded value DAG represents source, map, combine, expansion, Shader, Geometry, materialized, nested, and guarded opaque results. During lowering, every root, finite Layer, and non-empty `TargetLayerScope` receives its own initial target token. `TargetLayerScope` resolves its `TargetRegion` only after every enclosing transform/clip/scope map is known. A non-empty scope replays its mixed stream once on a transparent local target and composites that isolated result once to the current target; `Empty` preserves authored ordering without allocating or executing pixel work. It remains an effect fragment rather than an immediately consumable value. Commands/captures consume and produce the appropriate local token, while pure values retain reusable data dependencies. Per-root provenance links fragments and values to renderer entries for output extent, query bounds, and hit testing. + +`DrawableGroup` applies its filter individually to the ordered child targets. When group opacity or a group/child blend requires isolation, the filtered targets are wrapped in `TargetLayerScope(..., Full)` and one invariant-opacity application wraps that isolated result, so opacity is composite-then-fade and nested group opacity multiplies. Child blends therefore see only preceding group content. A blend whose transparent source changes the destination (`Clear`, `Src`, `SrcIn`, `DstIn`, `SrcOut`, `DstOut`, `DstATop`, or `Modulate`) lowers as an ordered target command with Full access to the group scope; its source value retains child-bounds ROI. Other blends remain destination-dependent barriers but keep child-bounds target coverage. When required, the scope is allocated on the resolved device-aligned owning-target domain, including at 100% opacity, so isolation does not depend on the legacy opacity SaveLayer shortcut or alter antialiased coverage through a clip-sized layer. + +*Amended (`3dde97c5e`).* The isolation scope is recorded as a finite `TargetRegion.Region(contentBounds)` taken from `CalculateRecordedInputBoundsHint()`, not as `Full`, and it is therefore not allocated on the owning-target domain. A `Full` group layer made a bounds-dependent effect on a group measure against the canvas, so the same project rendered differently at a different scene resolution; the scope is now sized to the recorded child content. + +### Transactional node recording + +`RenderRequestRecorder` opens a checkpoint before invoking each `RenderNode.Process`. The supplied context owns borrowed input fragments, newly recorded handles, ordered publications, target effects/scopes, cache-disable state, transferred disposable resources, declared brush-mask resources, and non-resource runtime cache identities. Each fragment immediately memoizes conservative query/value bounds, effective-scale declaration, value-only cardinality, `ContributesValuesToTarget`, `CanBeUsedAsValueInput`, and CPU hit-test metadata from already-recorded inputs, so downstream nodes can inspect handles without execution. Scope-relative target access remains separate internal metadata: a `TargetLayerScope(Full)` handle keeps its child-derived query hint internal, exposes no concrete recording-time metadata while the owning domain is symbolic, and remains value-input-ineligible until an author deliberately wraps it in finite `Layer(inputs, Rect)`. Concrete scale resolution uses complete output bounds and applies the feature-003 ceiling/dimension clamp before reverse ROI; later cropping never changes that density. Success validates context ownership and atomically commits the checkpoint. Failure rolls it back, discharges transferred resources best-effort, preserves the primary exception, and invalidates the context and every public handle. Nested node recording uses fresh child-owned facade handles over parent fragment IDs and maps committed child publications back to fresh parent handles. The later graph-wide forward phase finalizes/validates metadata; it does not make the first fragment available. + +The executable `RenderNodeOperation` type is removed rather than reused for a different lifecycle. Its replacement, `RenderFragmentHandle`, is a sealed context-owned fragment handle with aggregate metadata and value cardinality; the name remains accurate when one handle represents an ordered runtime fragment stream, allowing dynamic expansion to remain one graph edge until execution. Shader/opacity preserve value cardinality; Geometry and opaque map are ordered zero-or-one maps per value input; combine produces at most one value; expansion owns arbitrary N-to-M topology; target commands may exist with cardinality `None`. Authors inspect `CanBeUsedAsValueInput` before feeding an arbitrary child fragment to a pixel-materializing method and use explicit finite-domain `Layer(inputs, Rect)` when replaying a mixed stream as one value is intended. + +### Region and cache ordering + +Per-node requested regions are not exposed during `Process`, because they are not sound until the complete graph exists. The planner first derives scope-local target-token dependencies, then resolves forward metadata and stores required regions for values, fragments, and target accesses with explicit `Full`, `Empty`, and finite `Region(Rect)` states. `RootOutputExtent` unions contributing value bounds with every potentially pixel-writing root target effect after scope mapping and clipping; separate `QueryBounds` unions contributing-value and target-command/scope query provenance for Measure/HitTest. A null `RequestedRegion` selects `RootOutputExtent`, while a non-null value is the explicit root output requirement/commit crop. Neither form shrinks the available root, finite Layer, or resolved TargetLayerScope target domain, and reverse ROI may expand a target read up to that domain. Cache lookup happens only after this analysis and target-token dependency discovery. A hit substitutes a selected pure producer with a materialized input while retaining original metadata/provenance for queries. A target-dependent subtree bypasses whole-subtree reuse unless the cache identity proves the complete preceding token's pixel identity and coverage; opaque raw target work always bypasses. A miss inserts a capture point into the same schedule and never starts a second pull. Cache disablement is monotonic through the current result and its ancestors. + +### Shader and Geometry seam + +Both authoring contexts accept the same public `ShaderCall` and `GeometryCall` values; `ShaderDescription` and `GeometryDescription` are the engine-internal forms those calls lower to. `ShaderDefinition.CurrentPixel` accepts only the restricted `half4 apply(half4 color)` form after lexer-based validation; there is no author-asserted invariance or coverage-homogeneity flag. CurrentPixel consumes pixels after upstream analytic/antialiased coverage has been resolved. Coordinate validation is the eligibility source for joining a Shader run, but does not prove `f(kx) = kf(x)` for partial coverage. The planner materializes vector, text, path, or antialiased-clip coverage before arbitrary public CurrentPixel work; a future engine-known participant may cross only when the engine mechanically proves that premultiplied-coverage property. `ShaderDefinition.WholeSource` may lead a fused run of downstream CurrentPixel or opacity stages, but never consumes an upstream stage within that run. Structural source/binding names are separated from execution-time uniform/resource values, and full source equality protects program-cache hash collisions. + +`GeometryDefinition` describes a deferred one-input/zero-or-one-output barrier with mandatory forward/backward bounds, CPU hit-test contract, separate structural/runtime cache identities, declared resources, and an explicit readback declaration. Its callback receives complete output bounds, resolved required/device region, and a one-shot callback-scoped canvas facade over executor-owned input/output resources. The canvas maps composition-global logical coordinates through canonical rounded device bounds and closes without an implicit flush. Retained sessions, inputs, canvases, facades, and resource handles reject use after the callback. Runtime output discard or shrink is permitted only within the allocated forward bounds. Custom bounds, scale, and hit-test contracts take pure non-capturing callbacks and carry no author state: a callback capturing a mutable value, a resource, an execution facade, or a disposable is rejected, and structural identity is derived from the callback method. A paired forward/backward contract declares both callbacks independently, because the backward map is not derived from the forward one. Per-recording values reach *execution* callbacks instead, through the `TState` an operation's `*Definition`/`*Call` pair carries; production does not recursively validate that state against a state-type allowlist. [contracts/public-api.md](contracts/public-api.md) carries the shipped signatures. + +## Implementation Strategy + +### Phase A - Freeze the current-main behavior + +1. Pin the actual pre-feature parent SHA `83e63689d8c72bd0b7fbd4cb01d9e468d7a78c53` in provenance. Older renderer snapshots are not acceptable substitutes; the baseline must include every main-branch renderer fix present when this feature branch started. + + **Withdrawn** with the evidence tree; see step 3. Note also that the pinned SHA was never this branch's parent: the first feature commit `95766e7d3` sits on `88ce0e132`, eleven main commits after `83e63689d`. The SHA is left as written because the derived override counts in `RenderPipelineMigrationCensusTests` were computed against it. +2. Add raw linear-RGBA16F immutable golden support, alpha MAE, edge-band local MAE, and maximum-channel error without changing rendering behavior. +3. Store the target-baseline generator as `evidence/target-baseline-generator.patch` plus `evidence/generate-target-baseline.sh`; the script creates a temporary worktree pinned to the starting SHA, applies the patch there, and copies only immutable RGBA16F files and a manifest back. No historical generator source is compiled by the feature branch. Add `evidence/run-paired-visual-evidence.sh` to run both worktrees and reject missing or mismatched fingerprint fields before comparison. For the single approved semantic divergence only, run `docs/specs/004-gpu-pass-fusion/evidence/refresh-intentional-visual-baselines.sh` on the authoritative fingerprinted environment; the script must refresh exactly `scene3d-with-2d-tail`, restore `geometry-stroke` and `split-expansion` to their regenerated legacy payloads, and update their linked manifest trust anchors. Every evidence inventory and provenance check MUST preserve SHA-256 hashes for `target-baseline-generator.patch`, `generate-target-baseline.sh`, `refresh-intentional-visual-baselines.sh`, and each paired runner; the generator records and verifies these first-class tool inputs (`docs/specs/004-gpu-pass-fusion/evidence/generate-target-baseline.sh:9-11`, `docs/specs/004-gpu-pass-fusion/evidence/generate-target-baseline.sh:398-445`). The manifest also records artifact hashes and exact OS, architecture, backend, device, driver, graphics-library, and runtime fingerprints. + + **Withdrawn.** This evidence tree is not carried on the branch that merges — it remains on `origin/speckit/004-gpu-pass-fusion` and `origin/speckit/004-s4-evidence` (267 files, 61.5 MB) and was deliberately not merged — and has been retired (tasks T005–T007, T016, T019, T020, T114, T115, T123). No `docs/specs/004-gpu-pass-fusion/evidence/` directory exists here, so none of the generators, manifests, runners or hashes described here is produced or verified by anything that merges. Output parity is evidenced instead by the same-process fusion-disabled/enabled A/B in `GpuPassFusionSameProcessParityHarness` on normal CI, and by an out-of-tree differential harness that renders the whole corpus on a target-main build and a feature build of the same machine — both sides on one device, so neither needs a committed device-specific reference. The paragraph is kept as the record of the original design. +4. Capture new-branch visual, allocation-failure, scale, cache, nested/query, AA-coverage, and no-preferred-GPU behavior. Paired baseline/feature evidence is valid only under an exact matching fingerprint and fails explicitly on mismatch. Normal CI instead compares fusion-disabled and fusion-enabled output in the same process/device through an internal request `FusionMode`; production and public renderer options expose only the enabled behavior, while friend evidence tests may select disabled compatibility partitioning. The mode is part of structural-plan identity so the two schedules cannot reuse one another accidentally. Normal-CI AA edge checks use a fixed device-independent maximum channel error of `0.02`; fingerprint-specific paired bounds come only from the exact matching manifest. CI always verifies evidence integrity, never silently selects a foreign-device blob, and does not treat this same-process check as a replacement for the paired starting-SHA proof. Import the eight independently reproducible `004-parity-strong` donor references only as supplemental effect regressions. + + **Withdrawn** in part with the evidence tree; see step 3. The fingerprinted manifest, its tighter paired bounds, and the paired starting-SHA proof are gone; what remains is the same-process `FusionMode` A/B and its fixed device-independent `0.02` AA edge bound, which is now the whole normal-CI story rather than a supplement to a paired proof. *Not done.* The eight `004-parity-strong` donor references were never imported. They were supplemental regressions rather than an acceptance input, and the in-repo effect suites under `Golden/` and `Fusion/` cover the same effects. +5. Capture baseline workload shape with test-owned probes and record feature plan shape through immutable compiled-plan objects plus component-local plan/program/pool statistics. Do not add a request-wide diagnostic recorder or make evidence instrumentation part of production decisions. +6. Add a persistent-production-lifetime BenchmarkDotNet harness and record paired baseline data; do not adopt donor timing percentages. + + The harness shipped in `tests/Beutl.Benchmarks/Rendering/` and stays runnable on demand. **Recording paired baseline data was withdrawn** with the evidence tree; see step 3 and spec.md SC-008. + +`FusionMode` is part of both structural-plan identity and render-output cache identity, and nested requests inherit the enclosing mode. A fusion-disabled result therefore cannot satisfy a fusion-enabled output-cache lookup even when every other runtime value matches. + +### Phase B - Introduce the recording contract with compatibility execution + +1. Add request options (including target-less `TargetDomain` distinct from `RequestedRegion`), render purpose/intent, renderer-wide bounds contract including custom-forward/full-input fallback, ordered fragment/value IR, scope-local target-token lowering, provenance, owned/borrowed resource handles, scalar runtime identities, and node transaction support. Characterize option sanitization, lifecycle transitions, graph IDs/order/provenance/cache candidates, LIFO cleanup and cleanup-fault aggregation, and exact ownership discharge/cache transfer before production implementation. Move feature-003 pure density helpers from the recorder to `RenderScaleUtilities` and migrate every production and test caller without a forwarding shim; update the old `EffectiveScale` operation-oriented documentation at the same time. +2. Change `RenderNode.Process` to `void`, remove executable `RenderNodeOperation`, add the sealed `RenderFragmentHandle`, and implement the concrete `RenderNodeContext` API in [contracts/public-api.md](contracts/public-api.md). Replace all capture-taking custom metadata factories with pure non-capturing metadata callbacks and migrate every bounds, scale, and hit-test caller without forwarding overloads. Derive structural identity from the callback method, and do not add a recursive production state-type allowlist validator. Fix `CanBeUsedAsValueInput` propagation per recorder: eligible Shader/Geometry/opaque values stay true, pure-child Opacity stays true, destination-dependent Blend plus public `TargetScope`, `TargetLayerScope`, raw target forms, and commands stay false, and finite Layer is the explicit mixed-stream-to-value boundary. An engine-owned TargetScope value-replay map conditionally preserves eligibility only for a mechanically restricted callback and one contributing, self-contained `Single` input that is already eligible. +3. Implement typed `TargetCommand`, non-contributing `TargetCapture`/`ContributeValues`, public symbolic `TargetLayerScope(inputs, TargetRegion)`, finite public `Layer(inputs, Rect domain)`, guarded `TargetScope`, and the explicitly opaque-external `RawTargetScope`/`RawTargetCommand`. `LayerRenderNode.Process` records a default legacy `PushLayer()` through `TargetLayerScope(..., Full)` in the normal bottom-up transaction; no recorder traversal special-case bypasses a public override. The scope retains symbolic Full through later parent transform/clip wrappers and resolves it only during target-token lowering. It remains value-input-ineligible; a non-empty scope preserves the isolation target unless equivalence proves elision, while `Empty` preserves ordering without pixel work. It becomes an ordinary value only through an explicit finite Layer. Add ordering characterizations for root `[A, Clear, B]`, finite public `Layer { A, Clear, B }`, `Transform(+10) -> PushLayer(default) -> Full Clear`, nested target-Layer scopes, empty target-Layer scopes, and `Snapshot -> optional Clear -> blend/transform/filter DrawBackdrop`; require each capture to materialize once and contribute only at its explicit later draw. +4. Migrate all 29 production and 7 test overrides in one breaking change. Classify every old callback through the migration census: typed value/effect, guarded `Opaque*`, typed target command/capture/scope, raw scope/command, or 3D/backend boundary. Separately migrate every existing render-node authoring, scale, hit-test, rasterization, cross-project, and golden-harness test that directly names the removed operation/pull surface; the 18 golden consumers remain unchanged behind the migrated harness. Initially keep unsupported callbacks opaque so output remains baseline-equivalent before fusion, but leave no unclassified `CreateLambda` or raw-canvas escape. +5. Replace `RenderNodeProcessor.Pull`/`PullToRoot` and both old rasterize shapes with the disposable high-level `RenderNodeRenderer.Render`, single-result `Rasterize`, `Measure`, and `HitTest` operations. `Rasterize` returns one caller-owned result that carries its logical bounds/origin, output scale, normal empty state, and optional bitmap rather than a list or a bare shifted bitmap. Migrate all Engine, NodeGraph, ProjectSystem, Editor, AgentToolkit, and application consumers. Raster/save callers use `Measure().OutputBounds` or the rasterization result bounds, while layout/query/hit-test callers use `QueryBounds`; the old operation-bounds union never represented every target write soundly, so new output and query bounds intentionally differ where required. Remove operation-backed `EffectTarget` and `OperationWrapperRenderNode.SetOperations`; the renderer owns persistent plan/program/pool state and factory-created pooled targets, and no executable/list-rasterization compatibility operation remains. +6. Make Particle, Scene3D, media sources, custom filter effects, nested drawables, brushes, and NodeGraph record deferred work instead of executing during `Process`. Engine-owned source nodes record one plain non-capturing draw callback over ordinary `ImmediateCanvas`, `Brush.Resource?`, and `Pen.Resource?` values; fill/pen resources remain declared through `Borrow`/`Own` so output-cache identity stays correct. Legacy custom effects likewise retain ordinary brush/pen values and the existing `BrushConstructor`/canvas execution path instead of a feature-only registration/lowering layer. Resolve ordinary brushes and pens eagerly inside the active execution session. A nested `DrawableBrush` uses an executor-installed materializer and disables direct replay when that nested materialization is required. Add the `ImmediateCanvas` deferred-callback capability guard so author disposal, snapshot, nested execution, undeclared resources, synchronization, `SaveLayer`-backed state APIs, and hidden target allocation are rejected. + +### Phase C - Make the renderer request-wide + +1. Update all drawable trees before recording any root. +2. Record root clear and every top-level contribution as ordered fragments; derive target-token dependencies only inside the root, finite Layer, or symbolic TargetLayerScope that owns them. +3. Resolve per-root metadata and convert bounds/hit-test queries to metadata-only requests that never invoke the executor. +4. Record same-target child nodes into the current graph; represent separate-target child rendering as nested requests inheriting target-factory policy, purpose, intent, scale, region, cache policy, and failure ownership. +5. Convert 3D to a deferred opaque backend source whose execution produces one materialized 2D input and explicit transition/synchronization events. + +### Phase C.5 - Validate target allocations at materialization + +1. Keep feature 003's 16,384-axis clamp as the portable scale/allocation invariant and honor a smaller active backend/factory limit when one is available for the concrete allocation descriptor. + + *Amended (`699332cc5`).* `IRenderTargetFactory.GetMaximumDimension` was removed because nothing queried it, so a factory advertising a smaller limit was ignored either way. `RenderScaleUtilities.MaxBufferDimension` is now the sole portable bound, and a factory signals that it cannot satisfy an allocation only by returning `null` from `Create`. Step 5's test case moved with it. See [contracts/breaking-changes.md](contracts/breaking-changes.md). +2. Validate every actual allocation's positive device size, checked RGBA16F byte-size arithmetic, pixel format, backend/context compatibility, and factory ownership transfer at the target-pool boundary. +3. Use compiler liveness only to schedule reuse and bound the number of simultaneously leased intermediates. Do not reserve planned allocations, simulate a request-wide byte/target admission budget, or install a second allocation preflight before execution. +4. Preserve the characterized `RenderIntent` behavior when an actual materialization cannot be acquired: Preview may degrade only at an explicitly eligible materialization, while Delivery and every non-eligible failure abort without partial cache publication. +5. Add deterministic tests for invalid/overflowed sizes, a factory that refuses an allocation it cannot satisfy, dynamic expansion, Preview degradation, Delivery failure, stale/double lease release, and cleanup after primary and cleanup exceptions. + +### Phase D - Analyze regions and caches after discovery + +1. Acquire and validate each finite owning target domain from the real destination, a finite Layer, or explicit target-less `TargetDomain`, then lower scope-local target-token topology and discover complete preceding-token dependencies. Resolve symbolic TargetLayerScope regions only after every enclosing scope map is known; fail during lowering when a reachable Full access has no finite owner domain. +2. Resolve forward bounds, `RootOutputExtent`, `QueryBounds`, aggregate stream cardinality, effective supply density, and hit-test metadata. Self-bounded graphs without Full need no separate root domain. Query bounds and `RequestedRegion` never substitute for a target domain. +3. Seed the final requirement from non-null `RequestedRegion` or otherwise `RootOutputExtent`, then propagate it backward through sound bounds contracts, use explicit `Full` for opaque/unknown mappings, preserve `Empty`, and reject invalid rectangles as planning failures. +4. Discover render-cache candidates without short-circuiting traversal. Reject raw-target candidates and conservatively bypass target-dependent whole subtrees unless complete preceding-token identity/coverage is proven; select valid pure-value hits after dependencies are known, preserve fragment/token order, and insert miss capture points into the same schedule. +5. Preserve child cache granularity, static-prefix reuse, feature-003 density eligibility, query isolation, and atomic publication after complete request success. +6. Keep the production default normative and disabled: `RenderCacheOptions.Default` is the same instance/policy as `Disabled`, ordinary `RenderNodeRenderRequest` construction (including `RenderNodeRendererOptions.DefaultRequest`) does not enable persistent caching, and only `RenderCacheOptions.Enabled` opts in. Guard the direct policy in `RenderNodeCacheHelperTest.DefaultPolicy_IsDisabledAndCacheRequiresExplicitOptIn`, the ordinary renderer path in `ComposedSceneRenderCacheTests.PlainGroup_DefaultRenderNodeRendererOptionsDoNotUsePersistentCache`, and GPU admission in `ComposedSceneRenderCacheTests.DefaultPolicy_DoesNotAdmitPlainAntialiasedGeometryOnGpu`. + +### Phase E - Add canonical Shader and Geometry authoring + +1. Extract and harden the donor lexer, source identity, snippet merger, uniform binding, bounds contract, and Geometry session algorithms. +2. Add descriptor-only `Shader` and `Geometry` methods to both contexts while preserving every existing `FilterEffectContext` operation and `ApplyTo` lifecycle. In `FilterEffectContext`, apply each description's pure forward bounds contract synchronously to the engine-internal recording tracker in item order before the call returns; invalid/thrown mapping rolls back that append, and the removed public `Bounds` accessor is not restored. +3. Lower existing Skia filters, color filters, transforms/scopes, built-in brush masks, and custom effects to known semantics only where equivalence is proven; otherwise preserve authored order through guarded opaque or marked raw compatibility fragments. + + *Amended (`48318a60f`, `d53b155e8`).* A third lowering outcome shipped after this was written. A built-in Skia filter segment that reports `IFEItem.SupportsDirectReplay` is replayed directly onto the destination's device grid, and a whole chain of such segments over a vector drawable replays as one device-space save layer bounded by a one-device-pixel raster apron. See [contracts/breaking-changes.md](contracts/breaking-changes.md). +4. Add non-friend public authoring tests for existing ApplyTo source compatibility and every new render-node shape, the full value-input-eligibility table, non-disposable borrowed resources, disposable ownership transfer, null-key request-local Borrow identity, and the independent scale utilities. +5. When the API lands, update both mirrored `beutl-filter-effect` skills and `docs/ai-workflow/resolution-independent-rendering.md` so author guidance teaches Shader/Geometry recording and deferred custom-scale declarations instead of eager `Process` allocation. + +### Phase F - Plan, fuse, and execute + +1. Lower a target-token chain independently for every root, finite Layer, and non-empty TargetLayerScope; preserve an Empty TargetLayerScope as order-only metadata with no local chain or pixel work. Then partition the complete graph at cache, unresolved analytic/antialiased coverage production, opaque, target-read/write, raw-canvas, readback, destination-dependent/unproven composite, external-target, backend, dynamic-topology, and 3D boundaries. +2. Compose maximal validated current-pixel Shader runs and invariant opacity across distinct render nodes after upstream coverage is resolved. Select a finite conservative fusion profile from the actual destination `GRBackend` (Portable for target-less or unsupported backends; stable Metal/Vulkan capability classes for those destinations), and split deterministically at coverage and stage/uniform/sampler/child/source/token limits. Treat these as engine fusion-growth policies because SkiaSharp does not expose the exact runtime-effect driver ceilings. +3. Compile a structural plan independent of parameter values, bind execution-time bounds/regions/resources, include the internal fusion mode in both structural-plan identity and render-output cache identity, preserve that mode through nested requests, include built-in parameters, Shader uniforms, declared resources, and callback runtime identities only in output-cache keys, and cache programs by backend capability plus full-source equality. +4. Schedule pooled RGBA16F intermediates by lifetime, validate each concrete target at acquisition, execute through one request owner, release all resources on every path, and publish caches only after complete success. +5. Preserve current-main fallback and allocation-failure outcomes; invalid Shader source/bindings or program creation fail explicitly and never become identity. + +### Phase G - Prove the redesign + +1. Run public contract, raw-callback migration census, complete field-wise state identity, non-capturing callback enforcement, author-stable forward/backward state consistency, fragment/scope order, target capture/backdrop, transaction, ROI, scale, cache, animation, fallback, nested, 3D-boundary, failure, program-cache, and pool suites; also prove mutable state types are not recursively rejected by a separate production validator. +2. Require exactly one pass for a coverage-resolved source followed by `Shader A -> Opacity -> Shader B`; require an exact materialization/barrier before a non-coverage-homogeneous Shader applied to an antialiased thin line/path and enforce edge-local parity; also require exact remaining barrier splits, one compile over 100 parameter frames, zero warmed allocation, bounded peak ownership, and non-vacuous visual references. +3. Run all applicable feature-003 goldens, the full `net10.0` test suite, both target builds, format verification, and dedicated `GpuPassFusionGpu` NUnit-category suites in both `Beutl.UnitTests` and `Beutl.Graphics3DTests` with GPU absence promoted to failure. Run the non-GPU Shader fallback suite separately so a hardware filter cannot hide it. +4. Run paired persistent-lifetime benchmarks against the pinned baseline and record confidence intervals, controls, environment, code SHAs, workload-shape observations, applicable component statistics, and raw results. + + **Withdrawn** with the evidence tree (tasks T114, T115, T123); see Phase A step 3 and spec.md SC-008. The benchmark cases remain runnable on demand, but no committed artifact reproduces the confidence interval, so the performance improvement is not asserted as a met acceptance criterion. + +## Dependency and Review Boundaries + +- Phases A and the opaque-only portion of B establish characterization evidence before behavior changes. +- The complete `Process`/operation migration is one public breaking change; no returning overload, `[Obsolete]` member, or parallel builder is permitted between phases. +- Request-wide recording (C) precedes region/cache decisions (D); cache short-circuiting or top-down ROI during recursive traversal is prohibited. +- Canonical descriptions (E) precede fusion (F); an author declaration alone never makes work fusible. +- Structural plan caching is introduced only after request identity and cache-island behavior are correct without it. +- Public API changes require `beutl-design-reviewer`; the complete diff requires `beutl-reviewer`. +- Feature 004 adds no `EngineObject.Resource` ownership protocol. The final cleanup restores plain generated nested-resource assignment and existing disposal behavior in `Beutl.Engine.SourceGenerators`, updates generator snapshots in `tests/SourceGeneratorTest`, runs `beutl-source-generator-impact`, and requires source-generator review before the public breaking commit. + +## Risk Controls + +| Risk | Control | +|---|---| +| Painter-order or target-read corruption | Ordered effectful fragments; scope-local target tokens; explicit read/write dependencies; root/finite-Layer/TargetLayerScope clear-order and multi-root backdrop/snapshot tests. | +| ROI under-render | Graph-complete backward analysis; mandatory bounds contracts; full-input fallback; shifted/full/empty ROI goldens. | +| False Shader fusion | Restricted current-pixel grammar validated by lexer; no invariance assertion; exact barrier and collision suites. | +| Shader moves across antialiased coverage | CurrentPixel is post-coverage; arbitrary public stages stop at geometry/text/path/AA-clip rasterization; only engine-mechanically-proven coverage-homogeneous operations may cross; thin-AA edge-local golden and exact-boundary tests. | +| Cache hides dependencies or loses density | Record before lookup; substitute only after metadata/ROI; retain provenance; include coverage/density/device in output-cache identity. | +| Animated values trigger recompilation | Separate structural source/names/topology from runtime binding values and resource contents; verify the 100-frame plan/program statistics gate. | +| Recording leaks GPU or media side effects | Transaction probes around every public node shape and known eager source; execution sessions are callback-scoped. | +| Guarded callback hides `SaveLayer`/nested work | Capability canvas rejects layer/opacity/blend/mask/paint APIs, target allocation, nested renderers, and flush; retained raw hooks are classified `LegacyRawCanvas` and excluded from exact claims. | +| Dynamic N-to-M output loses ordering | Stream-valued handles with explicit cardinality/topology and aggregate metadata; never infer identity from empty bounds. | +| Resource leaks or masked failures | One request owner, generation-checked leases, rollback checkpoints, best-effort cleanup sweep, primary-exception preservation, injection at every acquisition/compile/publish phase. | +| Donor architecture contaminates the redesign | Leaf-file allowlist and explicit denylist in research; no cherry-pick, no `PlanExecutor` copy, no effect-local caches or replacement lifecycle. | +| Device-specific evidence becomes a false CI oracle | Out-of-tree pinned-SHA generator; hashed RGBA16F manifest with exact environment fingerprint; hard-error paired mismatches; same-process fusion-off/on CI comparison; no foreign-blob selection. *Amended*: the generator, the fingerprinted manifest, and the paired-mismatch hard error were withdrawn with the evidence tree (see Phase A step 3); the surviving controls are the same-process fusion-off/on CI comparison, the out-of-tree differential harness, and no foreign-blob selection. | + +## Complexity Tracking + +No constitution violations or intentionally retained parallel architectures exist. The temporary opaque interpreter is an implementation stage of the single new request pipeline, not a compatibility API, and is removed or retained only as the explicit long-term opaque execution boundary required by the specification. diff --git a/docs/specs/004-gpu-pass-fusion/quickstart.md b/docs/specs/004-gpu-pass-fusion/quickstart.md new file mode 100644 index 0000000000..52fb2737ab --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/quickstart.md @@ -0,0 +1,189 @@ +# Quickstart: Implementing Renderer-Wide GPU Pass Fusion + +Read [spec.md](spec.md), [plan.md](plan.md), [data-model.md](data-model.md), and the [contracts](contracts/) before changing production code. This guide uses the finalized public authoring model. + +## 1. Confirm the feature worktree + +```bash +set -euo pipefail + +expected_branch=speckit/004-gpu-pass-fusion-unified +expected_baseline_sha=83e63689d8c72bd0b7fbd4cb01d9e468d7a78c53 + +test "$(git branch --show-current)" = "$expected_branch" +git merge-base --is-ancestor "$expected_baseline_sha" HEAD +``` + +The evidence SHA is a behavioral ancestor, not a required current merge base, so the ancestor guard stays true after the squash-merge while the branch-name guard does not. The earlier `speckit/004-gpu-pass-fusion` branch was superseded and is not an ancestor of the delivered work. Do not cherry-pick an abandoned GPU-pass branch; adapt only reviewed algorithms to this request-wide architecture. + +## 2. Freeze evidence before changing scheduling + +First add test-owned visual and workload evidence: raw linear-premultiplied RGBA16F artifacts, image-quality assertions, immutable provenance manifests, baseline shape probes, and persistent-lifetime benchmarks. Capture primary chains, barriers, thin antialiased paths, multiple roots, ROI/scale, reuse hits and misses, nested work, 3D, preview, and allocation failures. + +The paired baseline runner must use a temporary worktree pinned to the evidence SHA and copy back only immutable artifacts and a manifest. Regular CI compares fusion-disabled and fusion-enabled schedules on the same process/device. The internal fusion mode is not a public renderer option. + +*Amended.* The pinned starting-SHA baseline, its temporary-worktree runner, the immutable provenance manifests and the baseline shape probes were withdrawn with the evidence tree (tasks T005–T007, T016, T019, T020, T114, T115, T123), and `docs/specs/004-gpu-pass-fusion/evidence/` is not part of the repository. The evidence that still comes first is narrower: the raw RGBA16F store and the image-quality assertions under `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/`, plus the same-process fusion-disabled/enabled A/B in `GpuPassFusionSameProcessParityHarness`. [tasks.md](tasks.md) records the retirement per task and [spec.md](spec.md) SC-007 carries the parity contract that now applies. + +## 3. Introduce one request recorder + +The first recorder records a request-wide ordered fragment graph without executing GPU, media, or raw-canvas work: + +```csharp +public sealed class PassThroughNode : RenderNode +{ + public override void Process(RenderNodeContext context) + { + context.PassThrough(); + } +} +``` + +Each invocation checkpoints fragments, publications, and resource transfers; validates them on normal return; then commits atomically or rolls back. Contexts, handles, and resource tokens become invalid when the invocation ends. + +## 4. Migrate public authoring in one change + +`RenderNode.Process` is `void`. It records and explicitly publishes fragment handles. Use the following topology choices: + +- `PassThrough` for identity and no recording; +- `PublishMappedInputs` for an ordered one-to-one transform; +- `Opacity`, `Shader`, `Geometry`, or another proven typed primitive for its exact semantics; +- `OpaqueSource`, `OpaqueMap`, `OpaqueCombine`, or `OpaqueExpand` for callback-defined value work; +- `TargetScope` and `TargetCommand` for guarded target work; +- raw target calls only for unavoidable external-canvas behavior; +- `Publish`, `PublishRange`, `Drop`, `RecordNode`, and `RecordSubtree` for all other topology, where `Drop` abandons a fragment recorded only to inspect its metadata. + +Content invalidation is equally direct: set `HasChanges` whenever a node property can alter pixels, metadata, or topology. Do not introduce application-managed output identities or resource content fields. + +### One-to-one publication + +```csharp +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + _opacity, + static (current, input, opacity) => current.Opacity(input, opacity)); +} +``` + +`PublishMappedInputs` invokes its mapper once per input in painter order and publishes the returned handle immediately. An empty input collection produces no output. The mapper may record intermediate handles but must not publish; publication in a mapper is rejected and rolls back the transaction. Prefer the generic overload and a `static` mapper in allocation-sensitive paths. + +### Guarded definition and call + +Prefer one static/shared definition for fixed callback code, metadata, and slot schema to avoid allocation. Equivalent definitions recreated later still share the engine-derived plan, so a singleton lifetime is not a correctness requirement. Create a call for state and request-scoped tokens each time `Process` records it. + +```csharp +private sealed record DrawState(float Opacity); + +private static readonly RenderResourceSlot s_brush = new(); + +private static readonly OpaqueRenderDefinition s_draw = + OpaqueRenderDefinition.Create( + static (session, state) => session.UseResource( + s_brush, + brush => Draw(session, brush, state.Opacity)), + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply, + resources: [s_brush]); + +public override void Process(RenderNodeContext context) +{ + RenderResource brush = context.Borrow(_brush); + OpaqueRenderCall call = s_draw.Call( + new DrawState(_opacity), + [s_brush.Bind(brush)]); + + context.PublishMappedInputs( + call, + static (current, input, recordedCall) => current.OpaqueMap(input, recordedCall)); +} +``` + +Every slot declared through `resources:` must be bound exactly once. Guarded callbacks lease it through `session.UseResource(slot, ...)`. `Borrow` retains caller ownership; `Own` transfers a disposable raw object to the request family. Neither takes caller-controlled reuse metadata. + +### Raw target work + +Raw work remains request-local, but it still declares typed slots and is recorded through a generic definition: + +```csharp +private sealed record RawState(RenderResource Backdrop); +private static readonly RenderResourceSlot s_backdrop = new(); + +private static readonly RawTargetCommandDefinition s_command = + RawTargetCommandDefinition.Create( + static (session, state) => session.UseResource( + state.Backdrop, + backdrop => backdrop.Draw(session.Canvas)), + queryBounds: new Rect(0, 0, 1, 1), + hitTest: RenderHitTestContract.None, + resources: [s_backdrop]); + +public override void Process(RenderNodeContext context) +{ + RenderResource backdrop = context.Borrow(_backdrop); + context.Publish(context.RawTargetCommand( + s_command.Call(new RawState(backdrop), [s_backdrop.Bind(backdrop)]))); +} +``` + +The raw callback uses the token kept in call state. The same token must be bound to the typed slot, which validates the schema. A raw scope uses `RawTargetScopeDefinition` and must replay its input exactly once. + +## 5. Add shader and geometry definitions + +Define fixed shader source and uniform/resource schema once, then pass values through `ShaderCall`: + +```csharp +private sealed record TintState(float Amount); + +private static readonly ShaderDefinition s_tint = + ShaderDefinition.CurrentPixel( + """ + uniform float amount; + half4 apply(half4 color) { + return half4(color.rgb * amount, color.a); + } + """, + static bindings => bindings.Uniform("amount", static state => state.Amount)); + +public override void Process(RenderNodeContext context) +{ + context.PublishMappedInputs( + new TintState(_amount), + static (current, input, state) => current.Shader(input, s_tint.Call(state))); +} +``` + +Use `.WholeSource` for a whole-input shader with `uniform shader src;` and fixed bounds behavior. Renderer-generated names are reserved: any shader source that declares a binding named `__beutl_pixel` or `__beutl_head_main`, a `__beutl_s_`-prefixed name, or an `fe`-prefixed name containing `_`, is rejected, and a whole-source shader may not declare a renderer-generated top-level name. `ShaderDefinitionBuilder.Resource` declares typed child-shader slots. `GeometryDefinition.Create` uses the same definition/call split for geometry callbacks, metadata, optional readback, and slots. `FilterEffectContext` accepts `ShaderCall` and `GeometryCall` directly. + +## 6. Record complete roots, then analyze and execute + +Record every root into one ordered graph. Only after recording should the renderer lower scoped target dependencies, resolve bounds/density/required regions, choose retained-output substitutions and captures, plan islands, and execute in painter order. + +Do not pass resolved ROI into `Process`. `Process` records contracts; analysis derives concrete regions after the complete graph is known. Bounds and hit-test requests use that same recorder but stop before deferred pixel work. + +Raw target callbacks, backend transitions, target readback, capture, and unsupported shader/geometry features create deliberate barriers. A fused run admits adjacent current-pixel shader stages and bounds- and scale-preserving opacity stages once coverage has been resolved, and it may be led by a whole-source shader head whose downstream current-pixel stages are appended to it; folding work upstream of a whole-source head is still rejected. + +## 7. Add retained-output and resource planning last + +The renderer owns retained output, structural/program plans, and pooled targets. A node only reports content change through `HasChanges`. A node that records a child it cannot list in `ChildNodes` must also call `context.DisableRenderCache()` during that transaction, because the cache cannot observe a change reported only by an unlisted child. Raw target work cannot be retained across requests. + +After the uncached plan is correct, verify stable parameter frames reuse immutable plans and programs, concrete target sizes reuse pools, and changed node content triggers correct rerecording. Keep direct output ownership and resource disposal inside the request/renderer lifecycle. + +## 8. Finish boundaries and failure behavior + +Record 3D as a backend source and materialize one 2D value at the boundary. Record separate-target nested work before GPU execution; same-target nested work remains in the parent graph. + +Inject failures around recording, analysis, allocation, shader compilation/binding, callback execution, capture publication, and cleanup. Each resource transfer must settle exactly once. A failure preserves the primary exception and publishes no partial output. + +## 9. Run final validation + +```bash +dotnet format Beutl.slnx --verify-no-changes +dotnet build Beutl.slnx +dotnet test Beutl.slnx -f net10.0 --settings coverlet.runsettings +``` + +Run the fallback shader tests on every host and the GPU-required suites on a configured graphics host. Run paired persistent-lifetime benchmarks in the pinned baseline and feature worktrees on the same system. The branch's breaking commits each use a breaking Conventional Commit subject and carry their own `BREAKING CHANGE:` footer, but `main` is squash-only, so the footer that reaches changelog tooling is the one in the pull request description: keep a footer there that names `Beutl.Engine` and summarizes the migrations in [contracts/breaking-changes.md](contracts/breaking-changes.md), and update it whenever another breaking commit lands. + +*Amended.* The paired pinned-baseline benchmark comparison was withdrawn with the evidence tree (tasks T114, T115, T123). `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` stays runnable on demand for the SC-008 workloads, but the same-fingerprint paired comparison and its confidence interval are not produced, so the performance improvement is measurable on demand and is not asserted as a met acceptance criterion; see [spec.md](spec.md) SC-008. diff --git a/docs/specs/004-gpu-pass-fusion/research.md b/docs/specs/004-gpu-pass-fusion/research.md new file mode 100644 index 0000000000..dd923bb6b9 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/research.md @@ -0,0 +1,417 @@ +# Research: Renderer-Wide GPU Pass Fusion + +## Research scope + +This document resolves the implementation decisions needed to replace the current recursive executable-operation pull with one complete-request planner while preserving the existing filter-effect authoring lifecycle. It is based on: + +- target baseline code at the actual pre-feature parent `88ce0e132`, the parent of this branch's first commit; the branch later merged main at `9352a5cf3`, and the migration census below was counted at `83e63689d`; +- the delivered feature-003 scale contracts; +- current production/test render-node and processor consumers; +- donor branch `yuto-trd/integrate-gpu-pass` at `7290836f43e4cdf1512b50dcc790a3f0a291cd0a` as extraction-only evidence; +- independently reproducible legacy golden provenance in the donor branch. + +There are no unresolved design questions in this planning phase. + +## Phase 4 backend and evidence adjudication + +**Withdrawn.** This section adjudicates workloads and artifacts belonging to the +evidence tree retired at R14; no `docs/specs/004-gpu-pass-fusion/evidence/` +directory exists, so `refresh-intentional-visual-baselines.sh` and the +`scene3d-with-2d-tail` / `nested-drawable-brush-delay` artifacts named below have +no referent, and the delivered benchmark set (`RenderPipelineBenchmarkScenes`) +contains neither workload. The adjudications are kept as the record of what was +decided; the refresh instruction and its runnable command have been removed. + +The solid-brush experiment that supplied a linear-sRGB `SKColorF` shader did not +remove CurrentPixel quantization on the authoritative MoltenVK backend. An authored +sRGB red byte of 51 reached both an identity CurrentPixel stage and a paired inverse +CurrentPixel chain as linear `8 / 255` (`0.03137207`), which encodes back to sRGB byte 49. +Because the identity and paired-transform paths agree, the loss occurs at the backend +materialization boundary before the CurrentPixel operations, not in the transform or +fusion. A brush shader cannot bypass that boundary, so solid brushes retain their +plain `SKColor` path. The regression compares the paired transform with an identity stage +that crosses the same materialization boundary instead of imposing a +backend-dependent direct-draw byte. + +The live Scene3D evidence failure was a benchmark-harness defect. The feature +exporter appended a custom `FeatureEvidenceShaderNode` directly to the opaque +Scene3D fragment. That helper requires a materializable value and therefore +correctly triggered the planner guardrail. Production `FilterEffectRenderNode` +inserts the finite Layer required to convert that backend fragment into a 2D value. +The workload now attaches the existing 65% color-inversion filter to `Scene3D`, matching +the pinned generator and exercising the production boundary. Repairing the harness +also exposed that the frozen tail blob is entirely transparent: the legacy +generator recorded the old executor dropping the filtered 3D output. The live +nonempty result is required by FR-005 and T102, which explicitly make the 3D +surface available to downstream 2D work, so this newly visible blob also requires +semantic refresh. + +The large `nested-drawable-brush-delay` divergence was a planner defect, not an +intentional Mosaic change. The delayed Mosaic itself produced a fully populated +100 by 70 output, but `DelayAnimationEffect` deliberately keeps an unknown bounds +contract. The legacy brush recording path therefore used the enclosing 154 by 88 brush rectangle +as both the finite isolation domain and the tile content size. The latter changed +`TileBrushCalculator`'s natural source size and shrank the pattern. DrawableBrush +now retains the fragment's finite recorded-bounds hint solely for tile mapping +while keeping the larger finite Layer as the conservative isolation domain. The +live workload again passes its frozen reference, and a regression proves that the +direct and delayed Mosaic paths preserve the same complete alpha footprint. + +*Amended.* The recorded-bounds-hint mechanism was superseded. The materializer now +crops its snapshot to the compiled output bounds and reports them as +`MaterializedDrawableBrush.ContentBounds`, which `BrushConstructor.CreateTileShader` +feeds to `TileBrushCalculator` as the content size; the executor separately +recompiles the brush request against the compiled `Measurement.QueryBounds` when +that intrinsic domain differs from the destination domain. The hint shape reported +the brush's destination box as the source size, so a 40 px drawable filling a +160 px shape covered only 42 px. + +The separately justified `scene3d-with-2d-tail` artifact is approved for semantic refresh. Run +`docs/specs/004-gpu-pass-fusion/evidence/refresh-intentional-visual-baselines.sh` +on the authoritative Apple M3/MoltenVK environment. The script renders the complete +workload table, requires an exact environment fingerprint match, copies exactly the +one approved blob, updates only its artifact hash and non-vacuity record, +updates the visual manifest trust anchor, and keeps the benchmark manifest's visual +evidence linkage consistent. It never selects or truncates the live workload set. + +## Phase 6 render-cache default + +The node render cache remains available as an explicit experimental option, but it +is disabled by default. Authoritative Apple M3/MoltenVK measurements found that +admitted antialiased geometry changed GPU pixels at admission and replay: an +ellipse rim differed by up to 0.48 in linear light, and an ordinary SrcOver group +could lose its outermost antialiasing apron. The same warm-cache path was +1.7–2.6 times slower than direct rendering for admitted content, including an +observed 7.1 ms/frame regression at 1080p. Expensive blur content did not become +eligible yet still paid 1.02–1.2 times the planning cost. + +`RenderCacheOptions.Default` therefore matches `Disabled`; +`RenderCacheOptions.Enabled` is the deliberate opt-in used by cache-specific +tests and experiments. The machinery is retained because its planning, failure, +and CPU parity contracts remain useful, but it must not affect ordinary rendering +until a backend-exact admission path and a cheaper hit path are demonstrated. + +## Phase 6 particle seek investigation + +The reported export/still probe still showed scattered particle displacement +differences at frames 30, 45, and 80 (respectively 299, 992, and 1482 pixels above +the codec threshold), but the simulator-level state did not reproduce that +divergence. A `ParticleEmitter.Resource` with the reported seed, emission, +lifetime, and turbulence settings produced byte-identical particle arrays when +evaluated sequentially and from a cold seek at 1, 3, 10, 60, 300, and 600 seconds, +at both 30 and 60 fps. `ParticleSimulator` has no accumulating turbulence phase or +fractional emission carry: each canonical 60 Hz step derives turbulence from +particle position and absolute step time, and RNG position is restored from the +checkpoint call count. + +One precision defect was independently reproducible: the production resource +converted the exact `TimeSpan` to `float` before resolving its canonical step. +That representation cannot distinguish the 2.5e-5-step NTSC snapping boundary at +long durations. The resource now retains time as `double`, and the double step +resolver caps snapping at 2.5e-5 steps while retaining the 8e-6 tick-truncation +floor. That precision fix shipped on main in #2160 rather than as part of feature +004; this branch inherits it. Ten-minute 30000/1001 and 60000/1001 sweeps are +exact. If the authoritative MCP pixel probe still diverges after this precision +fix, its remaining mechanism is downstream of `ParticleSimulator` state and +requires a frame-level capture of the evaluated `ParticleEmitter.Resource` plus +its render-node inputs; the current evidence does not justify changing +deterministic simulation or RNG semantics. + +## R1. Plan one complete target-surface request + +**Decision**: `Renderer` updates every participating drawable tree first, then records all top-level roots and target contributions into one `RenderRequest` before any planner-controlled 2D GPU work executes. The pipeline order is: + +1. record the complete ordered effectful fragment DAG and embedded value DAG without cache lookup; +2. lower/discover scope-local target-token dependencies and resolve symbolic target regions against their actual external-root or offscreen scope; +3. resolve forward metadata, including separate root output and query extents; +4. propagate requested output regions backward; +5. substitute valid render-cache hits and insert miss capture points; +6. partition cache/backend/opaque execution islands; +7. compile fusion and resource schedules; +8. execute once and atomically publish successful caches. + +**Rationale**: Current `Renderer.RenderObjects` pulls and executes each top-level drawable independently. `RenderNodeProcessor.Pull` can return a cache hit before traversing children, and `RenderNodeCacheHelper` can independently pull a subtree again to create a cache. These boundaries hide later drawables and upstream dependencies from one optimizer and prevent direct whole-request plan analysis. + +**Alternatives rejected**: + +- **Effect-local planner**: cannot fuse across opacity or other ordinary render nodes and repeats the donor architecture's central limitation. +- **One planner per top-level drawable**: still cannot model backdrop, snapshot, painter order, or cross-root opportunities over the target surface. +- **Cache lookup during recursive traversal**: hides dependency metadata and makes later ROI/cache-island decisions unsound. + +## R2. Make `RenderNodeContext` the only public recorder + +**Decision**: The public node method is exactly `public abstract void Process(RenderNodeContext context)`. `RenderNodeContext` owns borrowed fragment inputs, semantic/opaque recording, and one explicit publication order containing both value contributions and target-command/capture/scope fragments. A command is returned as a handle and travels through parent inputs; there is no root-global void side list, public plan builder, or returning `Process` overload. The context also owns nested recording, cache disablement, and transferred resources. + +The executable `RenderNodeOperation` type is removed and replaced by `RenderFragmentHandle`, a sealed, non-executable, non-disposable context handle. The new name reflects that one handle may denote an ordered fragment stream rather than one executable operation. Value contribution/cardinality and `CanBeUsedAsValueInput` are always readable while the handle is active. Concrete recording-time bounds/scale are available only through `TryGetMetadata(out RenderFragmentMetadata)`, and CPU hit testing only through `TryHitTest(Point, out bool)`; either query returns false with a default out value while an owning-target dependency is symbolic. It has no public constructor, factories, `Render`, `Dispose`, or ownership transfer. The new count type is named `RenderValueCardinality`, not output cardinality, because fragment existence and value count are independent. + +Nested node recording maps parent inputs to fresh child-owned facade handles over the same internal fragments, invalidates those facades with the child transaction, and maps committed child outputs back to fresh parent-owned handles. Parent handle objects never cross the child lifetime. The high-level replacement for direct pull consumers is a disposable `RenderNodeRenderer`; it owns persistent structural/program caches, the target pool, and accepted factory-created targets while borrowing its root, targets, and factory. Its single-result rasterizer returns a disposable `RenderNodeRasterization` carrying logical `Bounds`, `OutputScale`, nullable `Bitmap`, and `IsEmpty`; the result owns `Bitmap` when non-null and represents an empty output without inventing or allocating a zero-area bitmap. `RenderNodeMeasurement` separately reports execution-facing `OutputBounds` and query-facing `QueryBounds` before its existing scale/cardinality/fragment flags. + +**Rationale**: Returning executable operations creates a second lifecycle and lets rendering escape the request owner. A context transaction can atomically commit all node effects or roll them back, and it gives public custom nodes one orthogonal vocabulary instead of a context plus builder plus callback-operation hierarchy. + +**Alternatives rejected**: + +- **Retain or internally derive `RenderNodeOperation` as the public authoring model**: its executable singular name preserves the removed lifecycle and misdescribes a handle that may carry a command, capture, scope, or ordered stream. +- **Separate `RenderPlanBuilder` argument**: duplicates context responsibilities and lets the two surfaces diverge. +- **Compatibility overload or `[Obsolete]` bridge**: leaves both ownership models live and conflicts with the repository's public-design policy. + +## R3. Represent effectful fragments and value cardinality separately + +**Decision**: One `RenderFragmentHandle` handle can represent an ordered runtime fragment stream. A fragment may carry a semantic value, an effect-only target command, a target-token-to-value capture, or a local/current-target scope. `RenderValueCardinality` counts materializable values only, so an effectful command is a real published fragment with `None`; internal fragment existence/order is separate. `CanBeUsedAsValueInput` tells an author whether every possible runtime value is exposed to value-consuming APIs after explicit dependencies are scheduled; it does not promise purity or target-independent execution. Shader preserves value order/cardinality, opacity/typed target scopes preserve the complete fragment, Geometry and opaque map produce one or zero-or-one value per value input, combine produces at most one value, and arbitrary runtime N-to-M belongs to expansion. Pure fan-out is explicit; effectful duplication is rejected except for a single-execution capture value shared by pure consumers. + +**Rationale**: Some existing nodes can publish zero, one, many, duplicated, or runtime-discovered outputs. Forcing recording to know every runtime output would require eager media/GPU execution. A stream-valued edge keeps topology inspectable without inventing individual handles prematurely. + +Each fragment/value record computes pure conservative aggregate metadata immediately when all required input metadata is concrete. When a fragment depends on `OwningTargetDomain`, the recorder may retain finite internal bounds/scale/hit-test hints for constructing the graph, but the fragment and every ordinary descendant remain publicly symbolic: `TryGetMetadata`, `TryHitTest`, and `RenderNodeContext.TryCalculateInputBounds` return false rather than exposing those hints. Standard and custom scale contracts consume complete resolved output bounds before their result becomes authoritative; they cannot observe the later ROI, and the 16,384-axis clamp is applied against those complete bounds. Target commands expose no value supply/cardinality. Public target capture either resolves an explicit output-derived working density or declares target-supply preservation and remains `Unbounded` until the active scope executes; both forms remain non-contributing until `ContributeValues` is recorded. Runtime shrink/discard and later ROI cropping remain within the final declaration. Graph-wide analysis resolves/finalizes symbolic metadata and performs reverse ROI without executing deferred work. + +**Alternatives rejected**: + +- **One handle per eventual output**: impossible for runtime-discovered expansion without violating recording-only behavior. +- **Single opaque array callback**: preserves ordering but hides map/combine/expansion topology from lifetime, cache, and ROI analysis. +- **Implicit pass-through for zero published outputs**: makes intentional drop/no-output indistinguishable from author error and current `MemoryNode` behavior. + +## R4. Use an ordered fragment DAG with scoped target tokens + +**Decision**: The primary IR is an ordered `RenderFragment` DAG. Fragment kinds embed a `RenderValue` DAG for reusable pixels, effect-only `TargetCommand` nodes, `TargetCapture` token-to-value edges, ordered sequence publications, current-target scopes, scope-relative `TargetLayerScope` effects, and finite value-producing Layer scopes. Child publications—including commands—flow through parent `Inputs` in painter order. Only after the hierarchy is complete does lowering thread a `TargetToken` separately through each external root and nested offscreen scope. A command consumes/emits its current scope's token; a capture consumes/emits that token plus a request-owned value. `TargetRegion.Full` refers to the finite current scope, not automatically to the external root. + +`TargetCommand` is a returned handle published in the same stream as value fragments. `TargetScope` replays one fragment exactly once with mechanically allocation-free transform/clip state; Opacity/Blend/OpacityMask are planner-visible typed scopes. Public `TargetLayerScope(inputs, TargetRegion)` is the typed offscreen-isolation effect: it records through the normal bottom-up `Process` path, keeps a Full region symbolic, and remains `CanBeUsedAsValueInput == false`. Its handle and ordinary downstream handles report metadata/hit-test unavailability while that dependency remains symbolic. A non-empty resolved region replays its ordered inputs into a transient local target during lowering/execution and composites that target back into the current painter target; the transient materialization is required unless the engine proves its removal equivalent. `Empty` is an order-only scope with no target allocation or pixel work. Existing `PushLayer(default)` records this public typed scope with `TargetRegion.Full`; no concrete-node pre-order traversal or early root-sized guess is used. + +Public `Layer(inputs, Rect domain)` is deliberately different: it requires a finite non-empty domain, replays a mixed ordered stream into one local target, exposes exactly one reusable outer value, and publishes `EffectiveScale.Unbounded`: the layer is a replayable recorded stream rather than a fixed raster, so graph-wide demand resolution selects its materialization density (a denser downstream consumer raises it, and the recorded evidence pins this in the target-authoring contract tests) instead of freezing the children's recorded density at recording time. Concrete inputs retain tight child-derived bounds and hit testing. If any input is symbolic, the Layer is the explicit public metadata barrier: it immediately reports its full domain as conservative bounds and domain containment for hit testing, while preserving the internal symbolic dependency for final resolution and fan-out analysis. It cannot accept scope-relative Full because it needs that finite conservative boundary during recording. During scope-token lowering, the ordered outer transform/clip state is known before a nested TargetLayerScope Full is resolved. For root X-domain `[0, 100)`, `Transform(+10) -> PushLayer(default) -> Full clear` therefore resolves the child's local Full domain to `[-10, 90)`; freezing `[0, 100)` during child recording would incorrectly miss root `[0, 10)`. `RawTargetScope` and zero-input `RawTargetCommand` preserve unguarded legacy target behavior while conservatively consuming/producing the whole current target token and making exact physical-pass claims unavailable. This preserves `A -> Clear -> B`, finite `Layer { A -> Clear -> B }`, symbolic `TargetLayerScope { A -> Clear -> B }`, and `Snapshot -> Clear -> filtered draw(snapshot)` without moving the Clear or Snapshot across scopes. The public typed capture is non-contributing until wrapped by `ContributeValues`; the built-in backdrop uses a request-local identity binding across sibling transactions. + +*Amended.* Two public layer forms shipped, not one. Finite `Layer(inputs, domain, domainIsQueryFootprint)` gained a third parameter selecting whether the domain is also the queried footprint, and a symbolic value-producing form was added alongside it rather than folded into `Layer`: `OwningTargetLayer(inputs)` takes no domain, records a single-value, value-eligible fragment whose bounds requirement resolves against the enclosing finite domain at graph finalization, and keeps child-derived bounds and hit testing as an internal hint until then. `FilterEffectRenderNode`, `ParticleRenderNode`, and the NodeGraph filter-effect node all depend on it; [contracts/breaking-changes.md](contracts/breaking-changes.md) is the migration contract that directs authors to it. + +`OpacityMask` retains the existing `Brush.Resource` semantics rather than pretending the brush is a finite alpha bitmap, but its context method accepts an engine-created `RenderResource` so the dependency and version are explicit. A built-in mask node captures the brush through ordinary `Own`/`Borrow` recording without constructing paint during `Process`. After the active execution session is acquired, the existing `BrushConstructor` resolves the mask against its mapping bounds; nested `DrawableBrush` content materializes through the executor-installed canvas hook. Guarded callback canvases still reject every `SaveLayer`-backed opacity/blend/mask/paint API and hidden target allocation; retained raw hooks are the marked raw forms above. + +*Amended.* That materialization seam is public surface, not an executor-private hook. The delegate `DrawableBrushMaterializer(DrawableBrush.Resource, Rect bounds, float scale)` returns the public `MaterializedDrawableBrush(SKImage Image, Rect ContentBounds)` record, and its ownership rule is part of the contract: the caller disposes the returned image on every path, so a materializer must return a fresh or independently ref-counted image and never hand back a cached instance. A `BrushConstructor` built by `ImmediateCanvas.CreateBrushConstructor` inherits the canvas's materializer; one constructed directly has none, and a `DrawableBrush` painted without one degrades to transparent. + +**Rationale**: A pure value DAG does not encode painter order/current-target reads, while an early root-global command list loses child interleaving and Layer/decorator scope. Embedding both in composable fragments retains reusable value dependencies and lowers target tokens only when the correct scope is known. + +**Alternatives rejected**: + +- **Value DAG plus an independently appended global command list**: loses `[A, Clear, B]` ordering through parents and makes a child Clear affect the root instead of its Layer. +- **Convert the target into an ordinary graph value after every draw**: creates artificial full-frame values/materializations and complicates external root ownership. +- **Resolve `PushLayer(default)` before recording children**: an enclosing parent transform/clip has not yet been lowered in the bottom-up tree, so a root-sized guess can under-render and bypasses the ordinary public `Process` contract. +- **Let public value-producing Layer accept symbolic Full**: it would have no finite conservative domain with which to reestablish concrete `TryGetMetadata`/`TryHitTest` results during recording. + + *Amended.* The constraint was reversed rather than upheld. `Layer` still requires its finite domain, but the need this bullet rejects was real, so the design added the separate `OwningTargetLayer` form described in the amendment above: it stays publicly symbolic during recording — that is exactly what makes it safe — and graph finalization, not recording, supplies the finite domain. + +## R5. Resolve bounds and requested regions after recording + +**Decision**: Every typed non-invariant value has a `RenderBoundsContract` with a forward output map and either a backward required-input map or a conservative full-input declaration. The contract lives in `Beutl.Graphics.Rendering` beside hit-test/scale and target primitives because Shader, Geometry, render-node scopes, and opaque descriptions all consume it; Effects descriptions reference that renderer-wide primitive rather than making custom render nodes import an effect-only namespace. `CreateFullInput(Func transformBounds)` covers non-identity forward maps whose inverse ROI cannot be proven. The engine deliberately runs two callback rules rather than one. Execution-callback factories—Shader, Geometry, opaque, and target scope/command—use one stored `TState` and a non-capturing state-first callback. Bounds, multi-input bounds, scale, and hit-test factories instead take an ordinary metadata callback that may capture; the engine validates every captured field and folds it into identity field-wise, so a captured mutable value, resource, execution facade, or disposable is rejected at recording. A paired forward/backward contract such as `Create(transformBounds, getRequiredInputBounds)` declares both callbacks together. Authors must keep reusable state stable across those phases; production does not recursively validate it against a state-type allowlist. Callback methods remain structural identity, while the engine's complete field-wise state equality/hashing supplies runtime metadata and output-cache identity. After complete recording, scope-token dependency lowering resolves Full access against the actual finite external root, `TargetLayerScope`, or Layer scope; only then is pure conservative forward metadata finalized/validated, followed by reverse requested-region propagation. + +Forward metadata retains two root aggregates. `RootOutputExtent` is the conservative union of contributing value bounds and every potentially pixel-writing root target-effect region after scope transforms/clips. Read-only captures and order-only accesses do not enlarge it; potentially writing work remains included even when its `QueryBounds` is empty. `QueryBounds` separately unions contributing-value and target-command/scope query provenance for measurement/layout and hit testing; non-contributing capture anchors and order-only effects without query metadata do not enlarge it. A null `RequestedRegion` selects `RootOutputExtent` for the root requirement, final commit, and rasterization domain; a non-degenerate value is clipped to that extent, while an explicitly degenerate value preserves its authored empty bounds and origin. `RenderNodeMeasurement` exposes both aggregates, and `RenderNodeRasterization.Bounds` records the resolved logical raster domain. + +`RequestedRegion` never supplies the available target domain. A real destination supplies the root domain; a target-less caller must set a finite non-empty `TargetDomain` whenever a resolved root `TargetRegion.Full` access requires one. QueryBounds, RootOutputExtent, RequestedRegion, and finite value anchors never substitute. A Full TargetLayerScope nested inside a finite Layer resolves from that Layer and does not require an unrelated root guess; public value-producing Layer already has its explicit finite domain. Target reads may expand reverse ROI up to the finite current target domain. Fan-out requirements union upstream; unknown opaque work requests its full declared input. Required-region state is an explicit `Full`, `Empty`, or finite `Region(Rect)` value; an invalid rectangle from an author mapping is an error, not a synonym for full. + +Per-node resolved ROI is not exposed through `RenderNodeContext.Process`, because it does not exist soundly until downstream dependencies are recorded. Execution-time Shader and Geometry contexts receive final bounds, density, and required regions. + +**Rationale**: The donor's top-down requested-bounds propagation occurs while children are pulled, before later effect bounds are known. That can under-request coordinate-changing operations. A separate region map also keeps provisional internal hints distinct from author-readable metadata and avoids making cache substitution change recording-time query results. + +**Alternatives rejected**: + +- **Expose `RequestedBounds` during `Process`**: invites node structure or allocation decisions based on incomplete information. +- **Always render full input**: correct but defeats ROI goals and hides missing bounds contracts. +- **Infer reverse bounds from forward bounds**: not sound for blur, transform, convolution, clip, and many custom operations. +- **Use QueryBounds as the default output or target domain**: drops pixel-writing commands such as Full Clear when their measurement/hit-test metadata is intentionally empty and conflates layout metadata with target availability. + +## R6. Resolve render caches after graph discovery + +**Decision**: Recording wraps eligible pure-value fragment results in cache candidates but never short-circuits traversal. After bounds/ROI resolution, `RenderCacheResolver` selects valid materialized hits and inserts capture points for selected misses in the current schedule. A candidate transitively containing a target command, current-target scope, target capture/read, or other target-token dependency is ineligible as a whole-subtree boundary unless the planner has a complete immutable prior-token pixel identity and coverage. Borrowed external-root/prior pixels are request-unique, so captures from them never hit across requests. Pure child value candidates remain independently selectable, and substitution preserves every fragment/token edge and publication position. Query metadata/provenance stays attached to the original producer. Cache publication occurs only after complete-request success. + +Render-output cache identity includes subtree revision, the request's `FusionMode`, built-in scalar parameters, canonical Shader uniform values, complete field-wise state identities from reusable deferred-execution and custom metadata factories, logical bounds, covered region, effective density, format, render intent/purpose where relevant, and device/context identity. Request-local execution-callback factories synthesize a unique identity for every recording and therefore disable cross-request pixel-cache reuse without risking stale pixels. Metadata factories retain the same stored state across phases and require a non-capturing callback, but mutable state types are not synchronously rejected; callers are responsible for not mutating reusable state. No author-supplied runtime key can omit either kind of state. A Shader subtree with a reusable custom binder additionally includes request `OutputScale` and `MaxWorkingScale`, because that binder may read both while the surrounding resolved density remains unchanged; unrelated cache candidates retain their existing cross-scale reuse. Nested requests inherit the enclosing `FusionMode`, so neither plan nor pixel payload reuse can cross a fusion-mode boundary. Structural plan and program identities deliberately exclude runtime-only values and resource contents. + +**Rationale**: This preserves existing per-child cache granularity without hiding dependencies. It also allows a static prefix to be cached while an animated tail remains in the same globally visible request. + +**Alternatives rejected**: + +- **Donor `PrefixOutputCache` or nested effect caches**: solves the symptom inside one filter graph and creates competing cache owners. +- **Independent cache-generation pull**: duplicates execution and separates allocation/failure accounting from the request. +- **Use one identity for output and structure**: either recompiles on every parameter frame or reuses stale pixels. + +## R7. Separate request purpose from delivery intent + +**Decision**: Introduce orthogonal request options: + +- `RenderIntent`: `Preview` or `Delivery`, preserving current allocation/failure policy; +- `RenderRequestPurpose`: `Frame`, `HitTest`, `Bounds`, `CacheWarmup`, or `Auxiliary`. + +Purpose is inherited by same-request nested nodes. Separate-target nested requests inherit both values unless they explicitly declare a boundary. `HitTest` and `Bounds` record metadata but never call the GPU executor or mutate persistent frame caches or frame render counts. + +**Rationale**: Current independent pulls for rendering, hit testing, bounds, cache warm-up, and nested work can share mutable state accidentally. Preview/export allocation behavior is a different concern from why the request exists. + +**Alternatives rejected**: + +- **One combined enum**: creates a Cartesian product and encourages missing propagation cases. +- **Treat all auxiliary work as frame rendering**: pollutes persistent cache/frame state and may perform unnecessary GPU work. + +## R8. Share one hardened Shader description + +**Decision**: Add a renderer-neutral shader primitive in `Beutl.Graphics.Effects`, accepted by both `FilterEffectContext.Shader` and `RenderNodeContext.Shader`. The public authoring pair is the immutable `ShaderDefinition` and the per-recording `ShaderCall` it binds state and resources into; `ShaderDescription` is the engine-internal lowered form both contexts consume. [contracts/public-api.md](contracts/public-api.md) carries the delivered authoring shape. + +It has exactly two forms: + +- `CurrentPixel`: a mechanically validated `half4 apply(half4 color)` snippet with identity bounds and fusible post-upstream-coverage semantics; +- `WholeSource`: a complete shader with mandatory bounds contract that may lead a fused run containing only downstream CurrentPixel or opacity stages. + +There is no `IsCoordinateInvariant` setter and no `WholeSourceInvariant` factory. Current-pixel validation uses a lexer/token model, rejects entry-point/coordinate built-ins and source sampling outside the restricted binding grammar, verifies declarations and binding names/types, and rejects unsupported constructs rather than trusting author assertions. Source text and binding names are structural; uniform values, bounds, density, target/device size, and resource contents are execution parameters. A direct unmanaged uniform overload has canonical binding and cache identity. Custom uniform and resource binders need no cache-policy selector because they are unconditionally non-capturing: every changing value reaches them through `TState`, and identity derives from the definition's method handles plus that canonical state, so cross-request reuse is the default rather than an opt-in. Arbitrary author-asserted binder runtime keys are not accepted. Program-cache lookup uses a stable hash for bucketing and full normalized source/signature equality for correctness. + +Child samplers are represented by deferred/provider or owned-resource descriptions resolved by the executor. The canonical API does not accept an eager caller-created native `SKShader` as a fusible child. + +SkiaSharp exposes the active `GRBackend` but not the runtime-effect fragment-uniform, sampler, child, source, or token ceilings needed before drawing. Planning therefore selects a finite conservative engine fusion profile from the actual destination surface: the Skia profiles (Portable, Metal, Vulkan) use 16 stages, 128 declared uniform vectors, 12 samplers, 12 children, 64 KiB generated source, and 16 Ki generated tokens, and retain distinct stable capability identities; the `SpirvVulkan` native-lowering profile records the smaller supported subset at 1 stage, 7 uniform vectors, 1 sampler, and 1 child. The implicit source child consumes one sampler and child slot. Target-less rasterization and unsupported, unknown, OpenGL, Direct3D, or Dawn backends use Portable. These values are fusion-growth policies rather than discovered hardware limits; a valid single stage that exceeds one remains an explicit standalone compatibility pass, and final program-creation failures remain visible. + +CurrentPixel has no output-position coordinate and permits only value-coordinate resources proven independent of destination position. That validator proves coordinate independence, not the premultiplied-coverage property `f(kx) = kf(x)` for every analytic/antialiased coverage value `k`. CurrentPixel therefore consumes pixels after upstream geometry, text, path, or antialiased-clip coverage has been resolved. Arbitrary public stages may fuse with each other and with invariant opacity after that point, but may not fold into the coverage-producing draw. Only an engine-known operation whose coverage homogeneity is mechanically proven may cross that boundary; there is no public author assertion for it. WholeSource receives local output device pixels (`0.5,0.5` at the first pixel center); the execution context exposes the logical origin, complete output bounds, required logical region, device bounds, working density, and input supply density. The implicit `src` child maps that local coordinate back through input logical origin/density. Extra resource bindings declare only the normative `Value` or `OutputDevice` coordinate spaces. An author that needs output-logical coordinates converts explicitly with `LogicalOrigin + outputDevice / WorkingScale` in the execution binder; no undeclared `OutputLogical` enum member is added. + +CurrentPixel preserves the supply of an already coverage-resolved input until the whole eligible run reaches one materialization; the standard supply-driven density is then resolved once for the run. An unbounded vector fragment first materializes its analytic/antialiased coverage before arbitrary CurrentPixel execution, so coordinate validation cannot move a nonlinear public stage into the source draw. WholeSource is a materialization boundary and publishes the standard concrete density from mapped complete bounds. Direct/custom unmanaged uniform values use a validated canonical scalar/vector/matrix allowlist and reject pointers/native handles/padding-dependent blobs. + +*Amended.* Skia runtime effects are no longer the only execution backend. After the main refactor the branch added an engine-internal Vulkan-native lowering: a `ShaderDescription` may carry a `SpirvShaderLowering` beside its SkSL source, and the executor's `ShaderBackendPreference` — `Auto` by default — may run a single-stage run through SPIR-V instead. SkSL remains the compatibility contract, so `Auto` selects the native path only under explicit preconditions (one stage, a lowering that declares bit-exact Skia handoff, RGBA16F on both sides, and matching footprints) and falls back to SkSL on a native compile failure; the native path has its own program cache and its own `SpirvShaderRunExecutions` counter. This does not weaken R8's guarantees: everything above still describes the SkSL lowering every description keeps. The reach is deliberately narrow — `OpacityRenderNode` is the only production node declaring a lowering, and because it declares `supportsBitExactSkiaHandoff: false`, `Auto` still executes it through SkSL today. The explicit `Spirv` preference, which raises rather than falls back when a precondition fails, is how the equivalence tests exercise the native program. + +**Rationale**: This preserves the useful donor snippet-merging model while fixing its trust and recording-time native-allocation gaps. It gives existing effect authors a small opt-in without replacing `ApplyTo`. + +**Alternatives rejected**: + +- **Author-declared invariance**: can silently produce incorrect fusion when a shader reads coordinates or neighboring pixels. +- **Author-declared coverage homogeneity**: a false `f(kx) = kf(x)` claim changes antialiased edge pixels; the engine must prove any participant allowed to cross coverage production. +- **Folding work upstream of a WholeSource shader into the same run**: WholeSource sampling is too broad to prove + that rewrite equivalent. A WholeSource shader may instead lead a run and feed only downstream CurrentPixel or + opacity stages, which preserves its authored sampling before applying per-pixel transforms. +- **Bake animated values into source**: defeats structural/program reuse and makes cache identity unstable. + +## R9. Share one deferred Geometry description + +**Decision**: Add the `GeometryDefinition`/`GeometryCall` authoring pair in `Beutl.Graphics.Effects`, accepted by both authoring contexts; `GeometryDescription` is the engine-internal lowered form. Geometry is a one-input/zero-or-one-output ordered map, has mandatory bounds and CPU hit-test contracts, and explicitly declares whether CPU readback is required. The definition object is itself the structural identity—its `render` method handle plus the declared contracts—so there is no author-supplied structural key to default or override; the definition/state split makes the shape structural by construction and per-recording values reach the callback only through `TState`. Reusable pixel-affecting callback state is stored as supplied, must remain stable by author contract, and contributes its complete engine-owned field-wise identity to the output-cache key. The conservative capturing form that prevents cross-request reuse, `CreateRequestLocal`, is engine-internal in the delivered surface, so a plugin author has no capturing escape hatch. It is a non-fused execution island in this feature. + +The executor invokes its callback with an active-token-guarded `GeometrySession`. Each element uses the standard supply-driven materialization density and the complete mapped bounds clamp. Before callback entry the executor transparently clears the planner-owned output inside the scheduled island. Opaque, Geometry, and TargetCommand sessions share one `RenderExecutionInput` facade rather than duplicating identical input types; owning descriptions control whether its one-shot `UseSnapshot` is enabled. The Geometry session exposes complete output bounds, resolved required region/device bounds, output/working/maximum scales, one borrowed input, and a non-disposable scoped canvas facade. The facade maps canonical rounded device bounds to composition-global logical coordinates, preclips the resolved allocation, and permits one executor-managed `ImmediateCanvas` action whose close does not introduce an implicit flush. Author disposal, snapshot, `SaveLayer`-backed state, nested draw/renderer entry, undeclared native/target use, hidden allocation, and hidden flush/synchronization are rejected; declared resources and the request-owned bitmap are authorized only in their nested same-session scopes. Geometry permits output discard or shrink within allocated forward bounds. Input readback is one-shot when declared; the request disposes the bitmap before return. All facades reject retained use. + +**Rationale**: Geometry is the honest deferred escape hatch for work that is not a current-pixel Shader. Mandatory bounds and readback metadata let the global planner schedule it without executing it during recording. + +**Alternatives rejected**: + +- **Reuse `CustomFilterEffectContext` as the new primitive**: it exposes target creation/opening and therefore owns planning decisions imperatively. +- **Let Geometry allocate arbitrary outputs**: hides fan-out/resource lifetime; dynamic topology belongs to the explicit opaque expansion path. +- **Implicit readback**: introduces uncounted synchronization and backend stalls. + +## R10. Preserve `ApplyTo` and lower legacy items conservatively + +**Decision**: `FilterEffect.ApplyTo(FilterEffectContext, Resource)` remains the only abstract effect entry point. Existing `FilterEffectContext` methods and ordering remain available. `Shader(ShaderCall)` and `Geometry(GeometryCall)` append to the same transactional ordered item list. During render-node recording, the effect resource invokes `ApplyTo` to produce descriptions only; activation, target allocation, GPU access, and custom callback execution are deferred. + +Legacy custom-effect brush and pen data stays in the ordinary `Brush.Resource?`/`Pen.Resource?` representation and resolves through the existing `BrushConstructor` and canvas draw path during execution. No feature-only paint wrapper, registration table, or separate brush-lowering lifecycle is part of the final design. + +Existing color filters, Skia filters, and transforms lower to typed operations only when their equivalence and bounds are proven. Unsupported engine-controlled items lower to the appropriate capability-guarded opaque value or target-scope topology. Existing `CustomEffect` keeps its raw `CustomFilterEffectContext`/materialized `EffectTarget` execution behavior and therefore lowers to a distinct `LegacyCustomEffect` opaque-external island. When its render-node input is symbolic, the legacy `FilterEffectContext` no longer exposes `Bounds` publicly (engine-internal recording tracker only); bounds-dependent parameters such as `TransformEffect`'s origin are resolved from execution-time target bounds. Missing bounds stay symbolic until scope lowering can resolve the local owning target after enclosing transforms, clips, and finite scopes, at which point the retained bounds-transforming items are evaluated again from the resolved input bounds. Once an unknown item starts the runtime sequence, later Skia/custom/Shader/Geometry items remain in that same island and use actual target bounds. The executor crops only its final semantic outputs to the resolved domain; the callback's internal allocations remain unmeasured and unconstrained. Every other retained public/protected raw-`ImmediateCanvas` hook—including custom `IBackdrop.Draw`, audio-visualizer foreground/shape callbacks, `RawTargetScope`, and `RawTargetCommand`—lowers to `LegacyRawCanvas` opaque-external work. Their outer fragment/order/resource ownership is planned, but internal passes/flushes are unmeasured and nothing fuses through them; direct plan assertions and callback probes preserve that limitation in evidence. The built-in SnapshotBackdrop/DrawBackdrop pair instead uses typed capture/binding and never calls the raw interface in the same request. A repository-wide migration census must classify every old `CreateLambda`, decorator, target/surface wrapper, and raw-canvas hook; none remains as an executable-operation escape. + +`FilterEffect.Resource.CreateRenderNode()` remains the customization point, but returned nodes use the new void recording contract. + +**Rationale**: Ordinary effect authors that stay within `FilterEffectContext` operations remain source-compatible while the renderer gains semantic visibility incrementally. The old operation-backed `EffectTarget` escape necessarily migrates with executable `RenderNodeOperation`; unsupported pixel work itself stays correct through an execution-time opaque boundary instead of becoming a false optimization. + +**Alternatives rejected**: + +- **`Describe(EffectGraphBuilder, ...)` replacement lifecycle**: caused the abandoned branch's migration expansion and is explicitly outside the restart. +- **Migrate every built-in effect before proving the seam**: increases risk and diff size without proving renderer-wide fusion. +- **Coalesce each `FilterEffectGroup` into one node**: changes cache granularity and makes group layout, rather than global semantics, the optimization mechanism. + +## R11. Use one request owner for resources and synchronization + +**Decision**: `RenderRequestExecutor` owns all request-scoped planner-acquired intermediates, program leases, owned materialized inputs, deferred sessions, owned declared resources, cache-capture outputs, and synchronization transitions. It owns only the token/lease for an explicitly borrowed external resource and never disposes or permits pixel-affecting mutation of that raw value. Every deferred author resource uses `RenderResource`: `Own` requires a disposable reference type, while `Borrow` accepts any reference type because no disposal transfers. A request-family reference table rejects duplicate `Own` and Own/Borrow conflict. Registration takes no key or version and supplies no persistent render-cache identity of its own: cache eligibility follows the owning node's change reporting. Reusable opaque, Geometry, target-scope, and target-command descriptions derive scalar cache identity from the complete field-wise identity of their author-stable callback state; request-local forms receive a fresh recording identity. The persistent `RenderNodeRenderer` owner retains structural/program caches and pooled targets across requests. The executor schedules exact-size RGBA16F leases by liveness, transparently initializes callback outputs, tracks lease generation, and discharges every acquire by pool return/disposal or successful cache-ownership transfer while preserving cleanup failures and the first primary failure. + +This request-owner design does not change `EngineObject.Resource` property assignment, generated disposal, or concurrency semantics. Those existing lifecycle rules remain independent of the renderer request and source generator. + +Working scale for each ordinary materialization remains: + +`min(max(OutputScale, densest concrete input supply), MaxWorkingScale)` + +followed by the existing per-buffer 16,384-pixel dimension clamp against the complete conservative operation output bounds. The pure helpers move from `RenderNodeContext` to the independent public `RenderScaleUtilities` type because 3D, brushes, export policy, and planning use them outside a recording transaction; all callers migrate together with no forwarding shim. Concrete inputs can resolve supply during recording. Symbolic dependencies keep only an internal hint and make the public handle's metadata query fail for the remainder of that recording transaction; after those handles invalidate, graph-wide analysis establishes internal concrete metadata. `EffectiveScale.Unbounded` continues to mean vector/lossless supply, including a late-bound scope-density-preserving capture. `RenderScaleContract.MapInputSupply(map, mapOutputDemandToInput)` is the declarative bidirectional one-input density map used by Transform and DrawableGroup: a forward supply map plus the backward output-demand-to-input-demand map that matches it. `RenderScaleContract.MapInputSupplyPreservingDemand(map)` declares the forward callback alone, for an operation that does not resample. Both are restricted to element-wise one-input maps, may return `Unbounded`, and are reevaluated after symbolic dependency resolution. A coverage-resolved unbounded input may remain unbounded through a CurrentPixel run until its eventual materialization. Public target capture has no value input supply. Its standard/custom target-capture policies resolve an output-derived concrete density and may intentionally downsample a denser owning target; `TargetCaptureScaleContract.PreserveTargetSupply` instead remains `Unbounded` through planning and materializes at the active root, finite Layer, or TargetLayerScope density. The built-in backdrop uses the same public policy. Later requested-region analysis crops the materialized logical region but does not increase or recompute density. Root target density remains the active destination density. Execution binds final cropped device bounds and other runtime values; recording does not bake them into structural Shader or Geometry descriptions. + +**Rationale**: A single owner can reconcile every acquisition, release, synchronization, and cache publication. Donor pool/program-cache algorithms are useful, but their effect-prefix and Vulkan-lifecycle coupling is not. + +**Alternatives rejected**: + +- **Let each pass allocate/dispose its own target**: prevents liveness reuse and makes cleanup/counter reconciliation incomplete. +- **Cap intermediates at output scale**: violates feature 003 and loses concrete source density. +- **Make every public TargetCapture implicitly inherit its enclosing target density**: hides a reusable operation's sampling semantics and makes output-derived downsampling impossible to declare. A target-specific contract instead makes `MaterializeAtWorkingScale`/`Custom` concrete resampling choices and `PreserveTargetSupply` an explicit late-bound lossless choice shared by plugin authors and the built-in backdrop. +- **Require zero misses for changing target sizes**: exact-size pooling legitimately misses when dimensions change; the zero-allocation gate applies only to stable warmed bounds. + +## R12. Treat 3D and unsupported backends as explicit boundaries + +**Decision**: `Scene3DRenderNode.Process` records an opaque backend source containing scene/version/bounds metadata. Execution later renders the declared full 3D bounds at resolved/clamped density, records the backend transition and synchronization, and publishes one materialized 2D value. Backward 2D ROI does not enter the 3D renderer. + +Every public Shader form has a supported unfused ordinary-2D path. GPU-specific pass-count tests may self-skip without a suitable device, but ordinary rendering must not fail merely because fusion is unavailable. Invalid source/bindings or program creation are explicit render failures, not identity fallback. + +**Rationale**: The feature is a 2D request redesign, not a 3D renderer rewrite. Correct fallback is a product requirement distinct from hardware-gated performance evidence. + +**Alternatives rejected**: + +- **Inspect the 3D graph**: expands scope into a different backend and synchronization model. +- **Silently disable a Shader on unsupported fusion**: corrupts output. +- **Require GPU execution-shape assertions on all CI hosts**: conflicts with the repository's hardware-gated graphics tests. + +## R13. Extract donor algorithms, never donor architecture + +**Decision**: Port leaf implementations and matching invariant tests from donor final HEAD only after adapting them to the renderer-wide ownership model. + +### Extraction candidates + +- `SkslSource`, `SkslLexer`, uniform/child binding logic, and `SkslSnippetMerger`; +- `RenderBoundsContract` concepts and Geometry session/input math; +- program-cache collision, reset, LRU, and re-entrant lease behavior; +- render-target pool exact-size buckets, LRU/byte cap, generation checks, context eviction, and cleanup patterns; +- raw linear-RGBA16F golden storage, Alpha MAE, immutable/provenance tooling; +- ROI, binding, merger, failure-injection, cache, pool, and persistent-lifetime benchmark tests. + +### Explicit denylist + +- `EffectGraphBuilder`, `FilterEffect.Describe`, or removal of current filter contexts; +- `PlanFilterEffectRenderNode`, effect-private graph/compiler ownership, `PlanCache`, `NestedGraphPlanCache`, or `PrefixOutputCache`; +- wholesale `PlanExecutor` or `EffectGraphCompiler` copy; +- `WholeSourceInvariant` and its author-asserted fusion contract; +- eager native `SKShader` child bindings as the canonical recording API; +- FilterEffectGroup coalescing; +- public Compute/Split/Composite/NestedGraph vocabularies as prerequisites; +- donor-wide RenderIntent/lifecycle/resource-tail changes unrelated to this request pipeline; +- donor effect-local telemetry definitions or timing targets. + +**Rationale**: Donor and target differ by hundreds of files and later donor fixes materially changed early commits. Cherry-picking would import the architecture being abandoned. Final leaf code plus focused tests captures the useful work without inheriting its ownership boundary. + +## R14. Establish fresh evidence and a non-friend contract gate + +**Decision**: Add `tests/Beutl.PublicApiContractTests` as a lean NUnit project without `InternalsVisibleTo`. It compiles plugin-style `ApplyTo`, Shader/Geometry authoring, fragment publication/value-input inspection, TargetCapture/ContributeValues, Layer/TargetScope/TargetCommand, RawTargetScope/RawTargetCommand, shared execution-input callbacks, owned/borrowed resources, and high-level renderer use using public API only. Its project shape may be adapted from donor, but no `Describe`-lifecycle tests are copied. + +Before behavior changes, capture a new target-baseline category with starting SHA, generator script/patch, environment, file hashes, immutable `AssertExisting` behavior, and non-vacuity comparisons. The generator is not compiled into feature tests: `docs/specs/004-gpu-pass-fusion/evidence/target-baseline-generator.patch` is applied by `generate-target-baseline.sh` only inside a temporary worktree pinned to the starting SHA, and only immutable RGBA16F artifacts plus their manifest are copied back. `run-paired-visual-evidence.sh` owns the starting-SHA/feature invocation, requires an exact matching fingerprint before comparison, and fails explicitly rather than skipping or selecting a foreign-device reference. The manifest hashes the artifacts, target-baseline patch, generator script, `refresh-intentional-visual-baselines.sh`, and paired runner (`docs/specs/004-gpu-pass-fusion/evidence/generate-target-baseline.sh:398-445`) and records exact OS, architecture, backend, device, driver, graphics-library, and runtime fingerprints. Normal CI uses fusion-disabled versus fusion-enabled rendering in the same process/device and always verifies manifest/hash integrity. An internal request `FusionMode` supplies that evidence seam, is included in structural-plan identity, and is available only to friend tests/internal renderer entry points; the public renderer does not expose an optimization toggle. That check complements rather than replaces the paired provenance proof. + +**Withdrawn.** This evidence tree was never committed and has been retired (tasks T005–T007, T016, T019, T020, T114, T115, T123). No `docs/specs/004-gpu-pass-fusion/evidence/` directory exists, so none of the generators, manifests, runners or hashes described here is produced. Output parity is evidenced instead by the same-process fusion-disabled/enabled A/B in `GpuPassFusionSameProcessParityHarness` on normal CI, and by an out-of-tree differential harness that renders the whole corpus on a target-main build and a feature build of the same machine — both sides on one device, so neither needs a committed device-specific reference. The paragraph is kept as the record of the original design. + +Use linear-light SSIM >= 0.99, linear RGB MAE <= 0.02, and alpha MAE <= 0.02 for scale-1 parity. Antialiased thin-line/path workloads additionally use an edge-band local-MAE and maximum-channel-error oracle so a small number of corrupted edge pixels cannot disappear in a whole-frame average. Normal CI uses a fixed device-independent per-channel maximum error of 0.02 for its same-process pair; the paired runner may enforce a tighter fingerprint-specific bound only from the exact matching manifest. Multiple scales/regions and fallback use freshly recorded baseline tolerances. Every workload must change by more than its parity threshold plus a recorded margin when the operation under test is disabled. + +The donor's `004-parity-strong` eight references may be imported as supplemental regressions because they are independently reproducible from a historical legacy activator — not imported; the same-process fusion A/B in `GpuPassFusionSameProcessParityHarness` covers this ground. Donor absolute timing and effect-local workload observations are historical only. Timings are remeasured on the target baseline with persistent production-equivalent renderer state, while the feature plan and component-local cache/pool statistics are validated in their owning tests. + +**Rationale**: Friend tests can accidentally depend on internals and donor post-redesign images cannot prove current-main parity. Provenance, non-vacuity, and request-wide accounting make performance/correctness claims auditable. + +**Alternatives rejected**: + +- **Regenerate a missing golden from the implementation under test**: allows a regression to approve itself. +- **Use RGB-only metrics**: misses alpha-only corruption. +- **Use a committed device-specific blob as an unconditional CI oracle**: backend/device differences can masquerade as regressions or approvals; normal CI must compare both modes on one device, while historical paired evidence is accepted only under an exact fingerprint. +- **Adopt a fixed historical percentage speedup**: donor timings were machine-specific and included a corrected benchmark-lifetime error. + +## R15. Prove behavior without a request-wide diagnostic subsystem + +**Decision**: Feature 004 adds no completed-request snapshot, event recorder, or renderer-owned diagnostic state. Tests assert immutable compiled-plan topology, component-local plan/program/pool statistics, test-owned callback/allocation/synchronization probes, rendered output, and final lifecycle state at the component that owns each invariant. Neither `IRenderer` nor `RenderNodeRenderer` gains a public telemetry surface. + +Metadata-only Bounds/HitTest requests stop before pixel execution and do not alter persistent frame caches or frame render counts. `LegacyCustomEffect` and `LegacyRawCanvas` remain explicitly opaque-external in the recorded graph and compiled plan; evidence verifies their outer boundary and callback entry directly without claiming visibility into internal passes or flushes. + +**Rationale**: A second whole-request accounting architecture was not needed for correctness and added substantial production code and test coupling. Direct plan, cache, pool, failure, and visual assertions prove the feature while keeping the execution model single-purpose. + +**Alternatives rejected**: + +- **Public or internal completed-request telemetry graph**: duplicates planner/executor state and creates a second lifecycle solely for tests. +- **Public mutable request writer/event recorder or completion sink**: lets observers affect execution and freezes internal scheduling representation. +- **Infer exact opaque callback work**: raw callbacks do not expose enough information to make a sound physical-pass or synchronization claim. + +## Current migration census + +The starting renderer contains 29 production `Process` overrides covering: + +- vector/media sources: geometry, rectangle, ellipse, text, image, video, audio visualization; +- unary/scope work: opacity, transform, rectangle/geometry clip, blend, mask, push; +- pass-through/combine: container, layer, drawable-group nodes, memory/drop; +- destination work: clear, snapshot backdrop, draw backdrop; +- nested/bridges: referenced child, filter effect, operation wrapper, NodeGraph output, scene bitmap; +- opaque/backend work: particle and 3D. + +Seven test overrides and all direct `RenderNodeProcessor`, executable-operation factory/subclass, operation-backed `EffectTarget`, `OperationWrapperRenderNode.SetOperations`, cache replay, hit-test/bounds, NodeGraph measure/preview, ProjectSystem `SceneDrawable`, Editor save-frame, AgentToolkit query, thumbnail, brush, texture-source, and player consumers migrate in the same breaking change. The operation API appears in 24 starting-SHA test files; the golden harness is one of them and hides the migration from its 18 consumers. The feature-003 scale helpers have 24 direct caller/reference files (15 production and 9 tests), all of which migrate to `RenderScaleUtilities` without a forwarding shim or assertion changes beyond the renamed owner. A repository-wide census test or source scan must keep these lists from silently shrinking around an unmigrated executable path. diff --git a/docs/specs/004-gpu-pass-fusion/spec.md b/docs/specs/004-gpu-pass-fusion/spec.md new file mode 100644 index 0000000000..e4d8c6d051 --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/spec.md @@ -0,0 +1,345 @@ +# Feature Specification: Renderer-Wide GPU Pass Fusion + +**Feature Branch**: `speckit/004-gpu-pass-fusion-unified` + +**Created**: 2026-07-19 + +**Status**: Delivered + +**Input**: User description: "Restart feature 004 from current main in a new branch and worktree. Make GPU pass fusion a renderer-wide capability rather than a filter-effect-only subsystem. Keep `FilterEffect.ApplyTo(FilterEffectContext, Resource)`, and add the useful Shader and Geometry recording concepts from `EffectGraphBuilder` to the existing `FilterEffectContext`. Use the abandoned branch only as a source of parts and evidence. Make `RenderNode.Process` return `void` and record through `RenderNodeContext` rather than returning executable operations or using a separate plan builder." + +## Overview + +Beutl currently executes top-level drawables, filter effects, and ordinary 2D render-node work through boundaries that prevent one optimizer from recognizing compatible GPU work across the complete target-surface request. This feature establishes one planning view over the complete ordered 2D request, derives scope-local target-token dependencies, resolves forward metadata and backward regions, substitutes caches, and only then partitions that request into execution islands. Compatible work may therefore fuse across effect, opacity, and other participating render-node boundaries instead of being confined to one filter-effect graph. + +The existing filter-effect authoring lifecycle remains the canonical public entry point. Effect authors continue to override `FilterEffect.ApplyTo(FilterEffectContext, Resource)`. `FilterEffectContext` gains Shader and Geometry recording capabilities so authors can describe optimizable work without adopting a replacement lifecycle or waiting for a repository-wide effect migration. + +The render-node authoring contract intentionally changes. `RenderNode.Process` returns `void` and records into its supplied `RenderNodeContext`; there is no returned executable operation array and no separate public plan-builder argument. The context is the sole render-node recording surface: it exposes borrowed read-only `RenderFragmentHandle` instances for ordered effectful fragments, accepts one unified publication order for value contributions, target commands, target captures, and target-local scopes, records semantic or opaque work, and keeps nested recording in the same complete request. This is a documented breaking change for custom `RenderNode` and executable `RenderNodeOperation` implementations. Ordinary `FilterEffect.ApplyTo` implementations that use `FilterEffectContext` operations remain source-compatible; code that reached through operation-backed `EffectTarget` members was consuming the removed executable render-node lifecycle and must migrate. + +The previous feature-004 branch is historical evidence and an extraction source only. Its effect-local planner, replacement filter-effect authoring lifecycle, and effect migration strategy are not the foundation of this feature. + +## Scope + +### In Scope + +- Planning the complete 2D preview, delivery, nested-draw, and auxiliary render request before choosing cache or materialization boundaries. +- Representing every encountered 2D operation as either an operation with declared semantics or an explicit opaque boundary. +- Forming cache-aware execution islands after complete-request dependencies are known. +- Fusing compatible GPU work across filter-effect and ordinary render-node boundaries, with opacity as the first required non-effect proof. +- Retaining the existing `FilterEffect.ApplyTo` and `FilterEffectContext` authoring model while adding Shader and Geometry recording capabilities to that context. +- Replacing the executable-array `RenderNode.Process` contract with transactional, recording-only `void Process(RenderNodeContext)` and migrating every in-tree render node and direct operation consumer. +- Giving public custom render nodes explicit pass-through, source, semantic map, combine, expansion, nested-recording, target-command, target-capture, current-target-scope, scope-relative `TargetLayerScope`, finite value-producing `Layer`, materialized-input, guarded opaque fallback, and marked raw-target compatibility capabilities through `RenderNodeContext`. +- Preserving legacy filter-effect operations through semantic lowering where sound and conservative opaque execution otherwise. +- Preserving the current per-child effect-group and render-cache granularity while allowing compatible uncached work to optimize across those boundaries. +- Correct bounds, requested-region, scale, cache, synchronization, resource-lifetime, and failure behavior for the planned 2D request. +- Establishing provenance-checked visual and performance baselines from the new branch's starting commit before judging the redesign. + + *Amended.* The pinned starting-commit baseline was withdrawn with the evidence tree; see the FR-043 note. + +### Out of Scope + +- Inspecting or fusing work inside the 3D renderer. A 3D result is an explicit opaque/backend boundary in a 2D request. +- Replacing `ApplyTo` with `Describe`, removing `FilterEffectContext`, or requiring all existing effects and scripts to migrate to a new authoring lifecycle. +- Requiring new public Compute, Split, or Composite effect primitives as a prerequisite for renderer-wide fusion. +- Adding a separate public `RenderPlanBuilder` or retaining executable `RenderNodeOperation` factories beside `RenderNodeContext`; the context is the one public render-node recording vocabulary. +- Inferring safe semantics from arbitrary custom callbacks. A custom node either selects declared recording primitives or explicitly records opaque work that remains a correctness-preserving boundary. +- Normalizing allocation-failure behavior that differs among existing preview and delivery paths; this feature preserves the behavior of its current-main baseline. + + *Amended.* Custom-effect target allocation was deliberately normalized inside this feature; see the FR-039 note. +- Changes to audio processing, media decoding, project persistence, editor UI, or unrelated resource-lifecycle systems. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Faster complete 2D rendering without visual changes (Priority: P1) + +An editor user previews or exports a composition containing compatible effects and ordinary 2D operations. Beutl recognizes compatible work across the original render-node boundaries, executes fewer GPU passes and intermediate materializations, and produces the same image. + +**Why this priority**: Cross-boundary optimization is the reason for restarting the feature. Effect-only fusion would repeat the architectural limitation of the abandoned attempt. + +**Independent Test**: Build a scene with a deterministic coverage-resolved semitransparent materialized source followed by two independently authored coordinate-invariant Shader effects separated by an opacity render node. Verify that the nodes remain distinct, the complete-request schedule contains one compatible GPU pass with at most one intermediate target, and the rendered image meets the parity threshold. + +**Acceptance Scenarios**: + +1. **Given** two eligible Shader effects separated by invariant opacity in distinct render nodes, **When** the complete 2D request is planned and rendered, **Then** all three stages execute in one GPU pass with at most one intermediate target and parity is preserved. +2. **Given** the same effects separated by an operation whose equivalence has not been proven, **When** the request is planned, **Then** the schedule splits at that explicit boundary and the image remains correct. +3. **Given** compatible stages on opposite sides of a filter-effect render-node boundary, **When** no semantic or cache dependency requires materialization there, **Then** that historical boundary alone does not prevent fusion. +4. **Given** multiple top-level drawables contributing to one target surface, **When** the request is planned, **Then** their complete ordered contributions are visible before any planner-controlled 2D GPU work executes. +5. **Given** an arbitrary CurrentPixel Shader after vector, text, path, or antialiased-clip coverage is produced, **When** coverage homogeneity has not been mechanically proven by the engine, **Then** the coverage-producing draw is materialized before the Shader run rather than folding the Shader ahead of coverage. + +--- + +### User Story 2 - Existing effect authors keep their workflow and can opt in (Priority: P1) + +A plugin, script, or built-in effect author continues to implement `ApplyTo` against `FilterEffectContext`. Existing calls keep their behavior, while the author may record Shader or Geometry work in the same ordered context to make new work visible to the renderer-wide planner. + +**Why this priority**: Preserving the authoring model avoids another repository-wide migration and lets renderer-wide planning be proven independently of effect conversion. + +**Independent Test**: Compile and render an existing plugin-style effect in a non-friend test assembly without source changes, then author separate Shader and Geometry examples using only public API and verify their recorded ordering, deferred execution, bounds, and planner participation. + +**Acceptance Scenarios**: + +1. **Given** an existing effect that overrides `ApplyTo`, uses current `FilterEffectContext` operations, and does not read author-time `Bounds` or assume `WorkingScale` is available for symbolic/branch-dependent inputs, **When** it is compiled and rendered after this feature, **Then** it requires no lifecycle migration and preserves its baseline behavior. Metadata-dependent effects deliberately migrate those reads to deferred bounds/scale binding under FR-018. +2. **Given** a public-API consumer that records a coordinate-invariant Shader operation, **When** the operation is placed between other eligible stages, **Then** it can participate in cross-node fusion without accessing engine internals. +3. **Given** a public-API consumer that records Geometry with declared bounds and readback needs, **When** `ApplyTo` runs, **Then** recording performs no drawing, allocation, flush, or readback, and the geometry callback executes later under engine-owned lifetime rules. +4. **Given** legacy, Shader, and Geometry operations recorded in one context, **When** the effect renders, **Then** their authored order is preserved and unsupported legacy work becomes an explicit barrier rather than being reordered or dropped. +5. **Given** an effect whose `Resource` supplies a custom render node, **When** the node is migrated to the recording contract, **Then** the `CreateRenderNode` customization point is still honored and the node may record declared semantics or an explicit opaque boundary. +6. **Given** a Shader description whose source or bindings violate the selected current-pixel contract, **When** it is validated, **Then** it is rejected with an explicit diagnostic and is never silently fused or rendered as identity. + +--- + +### User Story 3 - Render-node authors record work through one context (Priority: P1) + +A built-in or plugin render-node author implements `void Process(RenderNodeContext)`. The author inspects borrowed fragment metadata, records every value/effect fragment, and publishes them in one order. Target commands are returned fragment handles rather than a void global side list, so a parent Layer, transform, opacity, clip, or filter can retain their local target scope. The author selects declared Shader, Geometry, or other semantic operations where possible and an explicit opaque fallback otherwise. No drawing or resource materialization occurs while `Process` is running. + +**Why this priority**: Renderer-wide fusion requires ordinary render nodes to contribute inspectable work to the same request. Merely moving the old executable callbacks into a context would preserve the opacity that prevents cross-node planning. + +**Independent Test**: In a non-friend assembly, implement separate custom nodes for no output, pass-through, a source, a one-to-one map, a many-to-one combine, N-to-M expansion, a target command, a typed target capture, a current-target scope, a scope-relative TargetLayerScope, a finite value-producing Layer, nested recording, a materialized input, and opaque fallback. Verify publication order, target scope, metadata, and value cardinality; prove that every `Process` call is GPU-side-effect-free; and verify that only the declared compatible run may fuse. + +**Acceptance Scenarios**: + +1. **Given** a custom node with ordered input handles, **When** `Process` explicitly passes them through, **Then** the same outputs, order, bounds, effective scales, and hit-test behavior are published without transferring disposal responsibility to the node. +2. **Given** a custom node whose `Process` records nothing, **When** recording completes, **Then** the node intentionally publishes zero outputs rather than implicitly passing its inputs through. +3. **Given** a node that records an eligible semantic map and publishes its returned handle, **When** the complete request is planned, **Then** the planner can inspect and fuse that work without executing the node's rendering callback during recording. +4. **Given** a custom operation that cannot expose safe semantics, **When** the author records it through the opaque fallback, **Then** it preserves declared bounds, density, hit testing, input order, and rendering behavior while forming an explicit fusion boundary. +5. **Given** a node that records another node or subtree, **When** the child is traversed, **Then** it remains in the same request, planner, render purpose, cache policy, region, scale policy, and failure owner instead of creating an isolated processor. +6. **Given** `Process` throws after recording work or transferring a resource, **When** the node transaction is abandoned, **Then** no partial output enters the request, every transferred resource is released exactly once, and retained contexts or handles reject later use. +7. **Given** a child publishes `[A, Clear, B]`, **When** a parent passes it through or places it in an offscreen scope, **Then** Clear remains between A and B and consumes the token of its actual current target scope rather than escaping to a global list. +8. **Given** a target capture is published and transformed, **When** it has not been wrapped with `ContributeValues`, **Then** it anchors/materializes the preceding target once but does not automatically redraw those pixels; its explicit later contribution occurs exactly once. +9. **Given** an old zero-input or decorator raw-canvas callback, **When** it migrates through RawTargetCommand or RawTargetScope, **Then** its ordering and behavior are preserved, fusion/caching do not cross it, and the recorded graph and compiled plan mark its internal physical work as opaque external. +10. **Given** root domain `[0, 100)`, an outer translation of `+10`, and `PushLayer(default)` containing a Full clear, **When** normal bottom-up `Process` recording completes, **Then** the symbolic Full `TargetLayerScope` resolves during target-token lowering to child-local `[-10, 90)` and the rendered root has no missing `[0, 10)` strip. + +--- + +### User Story 4 - Animation and render caching remain efficient and correct (Priority: P2) + +An editor user animates effect parameters or replays cached content. Beutl reuses structural plans, programs, and warmed intermediate resources while still invalidating the correct output when structure, bounds, scale, or cache identity changes. + +**Why this priority**: A fused first frame is not useful if ordinary animation recompiles the pipeline or if global visibility breaks render-cache behavior. + +**Independent Test**: Render 100 frames of a structurally constant scene with animated parameters, then make one structural change. Count plan compilations, program creation, target allocation, cache use, and output invalidation. + +**Acceptance Scenarios**: + +1. **Given** a structurally unchanged scene with animated Shader parameters, **When** 100 frames render, **Then** its structural plan is compiled once and no new program is created after the first frame. +2. **Given** one declared structural change, **When** the next frame renders, **Then** exactly the affected plan is invalidated and recompiled once. +3. **Given** a valid materialized render-cache entry within a globally visible request, **When** the request is partitioned, **Then** the cache forms a correct execution-island boundary and its reusable output is not needlessly recomputed. +4. **Given** stable bounds and structure after target-pool warm-up, **When** subsequent frames render, **Then** no new intermediate target is created. +5. **Given** a cached static prefix followed by an animated compatible tail, **When** post-warm-up frames render, **Then** the prefix records a cache hit and zero executed prefix passes while the tail updates correctly. + +--- + +### User Story 5 - Scales, regions, fallbacks, and boundaries stay correct (Priority: P2) + +An editor user changes preview quality, exports at full quality, renders a cropped region, includes 3D content, or runs without the preferred GPU backend. The planner preserves the established scale and bounds contracts and uses safe boundaries or fallback execution where fusion is not valid. + +**Why this priority**: Incorrect density, requested-region propagation, or fallback behavior can silently corrupt output even when the common full-frame GPU case looks correct. + +**Independent Test**: Render representative scenes at multiple output and working scales, with shifted and empty requested regions, with a 3D-produced input, and on the supported non-preferred backend. Compare bounds, densities, schedules, and images to the fresh baseline. + +**Acceptance Scenarios**: + +1. **Given** mixed-density inputs and a non-default output scale, **When** the request renders, **Then** the resolved working density, maximum-density ceiling, and per-buffer dimension clamp match feature 003. +2. **Given** a cropped requested output region, **When** a typed operation has a backward bounds contract, **Then** only its required upstream region is requested; an operation without such a contract conservatively requests its full input. +3. **Given** a 3D result consumed by the 2D request, **When** the request is planned, **Then** the 3D result is represented as an explicit opaque/backend boundary and no 2D fusion crosses it. +4. **Given** a supported environment without the preferred GPU path, **When** the same composition including a new Shader operation renders, **Then** the Shader executes through its required unfused path and meets visual parity. +5. **Given** a root Full clear or another potentially pixel-writing target effect whose query bounds are empty, **When** no explicit requested region is supplied, **Then** rendering and rasterization use the resolved root output extent while measurement and hit testing retain their separate query bounds. + +--- + +### User Story 6 - Maintainers can prove whole-request improvement and safety (Priority: P3) + +A renderer maintainer compares the redesign with its current-main baseline using deterministic scenes, direct compiled-plan and component-statistics assertions, failure injection, and production-representative benchmarks. The evidence accounts for ordinary render-node work as well as effects without adding a second request-wide telemetry architecture. + +**Why this priority**: Effect-local counters or one-off microbenchmarks can report a win while hiding materializations and synchronization elsewhere in the same request. + +**Independent Test**: Run deterministic correctness, failure, and benchmark scenes before and after the behavioral change using persistent production-like renderer state. Assert the immutable compiled-plan topology, relevant plan/program/pool statistics, test-owned execution probes, rendered output, and final resource state at their owning components. + +**Acceptance Scenarios**: + +1. **Given** a complete 2D request containing effects, opacity, cache boundaries, and opaque work, **When** its evidence suite runs, **Then** the compiled plan exposes every planner-controlled island and boundary, component statistics expose cache/program/pool reuse, and test-owned probes observe execution/materialization/synchronization without claiming visibility inside opaque callbacks. +2. **Given** an injected failure after one or more resources are acquired, **When** rendering aborts, **Then** all owned resources are reclaimable, cleanup continues after a cleanup fault, and the primary render failure is preserved. +3. **Given** a benchmark comparison, **When** results are reported, **Then** both versions use the same scene, output, warm-up, persistent renderer lifetime, measurement method, and starting-commit provenance. + + *Amended.* The starting-commit provenance half was withdrawn with the evidence tree; see the FR-043 note. + +### Edge Cases + +- Empty, zero-area, invalid, non-finite, or dynamically shrinking bounds must not accidentally turn a required operation into an identity result or trigger an invalid allocation. +- A requested output region may be shifted relative to the source, extend outside it, or become empty after clipping; forward output bounds and backward required-input bounds must remain in the correct coordinate space. +- A Shader offered through the restricted `CurrentPixel` form must be coordinate-independent under the validated grammar (`src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs:198-283`, `src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs:501-596`). Coordinate-dependent source/bindings or work the validator cannot prove must use `WholeSource`/another non-fused path or be rejected; author declaration alone never permits fusion. +- Coordinate independence does not prove commutation with analytic or antialiased coverage. Arbitrary public CurrentPixel work applies to pixels after upstream coverage is resolved and must not fold into vector, text, path, or antialiased-clip coverage generation; only an engine-known stage with mechanically proven premultiplied-coverage homogeneity may cross that boundary. +- A compatible Shader run may exceed a backend's shader, sampler, child, or uniform budget; it must split into valid ordered passes without changing output. +- A legacy custom effect, custom render node, destination-dependent Blend or otherwise unproven composite, dynamic fan-out, explicit readback, externally owned target, or backend transition may reveal too little information to optimize; it must remain executable as an explicit barrier. +- A render node may publish no fragments, publish an effectful fragment with no materializable value, forward one pure input more than once, combine several inputs, expand inputs into a runtime-discovered number of values, capture the current target as a typed value, or record an ordered target command with an empty query region but `Full` target access. These cases must remain explicit and must not be pruned or converted to identity based only on output bounds or value cardinality. +- A render-node input handle may be forwarded, mapped, shared for declared fan-out, or dropped, but it is borrowed from one active context. Cross-context use, author disposal, or retention beyond the recording transaction must fail without corrupting request ownership. +- A backdrop, snapshot, target-dependent blend, or mask may depend on the exact framebuffer state established by earlier top-level drawables; planning must preserve those read/write dependencies and painter order. +- A full-domain Clear has empty query bounds but remains full-domain output at the root and inside a Layer. Root output extent and Layer output bounds must include potentially pixel-writing target-effect regions without treating those regions as measurement or hit-test metadata. +- A scope-relative `TargetLayerScope(TargetRegion.Full)` may be recorded below a later parent transform or clip. Full must remain symbolic until scope-token lowering; resolving it against the root before the parent scope is known can under-render, as in `Transform(+10) -> PushLayer(default) -> Full clear`. +- A geometry operation may require CPU readback, return no output, throw, or attempt to retain a borrowed session or input beyond its callback; synchronization and lifetime behavior must be deterministic. +- `ApplyTo` may throw after recording some work, or an author may try to retain the engine-owned context after it returns; partial recording must not enter the request and use-after-recording must fail deterministically. +- `RenderNode.Process` may throw after recording fragments, target commands/captures/scopes, nested work, or owned resources. Its entire node transaction must roll back while preserving the primary failure and releasing transferred ownership exactly once. +- Parameter animation may alter values, bounds, requested regions, or a declared structural choice. Only structural changes may recompile the structural plan, while every change must update output and cache identity correctly. +- Render-cache hits, misses, scale changes, and invalidation may occur inside the same globally planned request; cached materialization must neither conceal required dependencies nor retain deferred frame resources. +- Nested draws, brushes, hit testing, boundary recalculation, cache warm-up, and other auxiliary pulls may occur during or beside frame rendering; their runtime state and cache policy must not contaminate or reuse an incompatible frame plan. +- A small high-density input may raise the working density of a much larger boundary under feature 003. Fusion must preserve the same density decision and dimension clamp rather than silently substituting the output scale. +- A public TargetCapture inside a denser finite Layer or TargetLayerScope may deliberately select output-derived resampling or `PreserveTargetSupply`. The former may downsample; the latter must retain the resolved enclosing density through downstream consumers. +- Resource acquisition, recording transfer, plan construction, program creation, execution, or disposal may fail. Every owner must release what it acquired on all completed and exceptional paths while preserving the first primary failure. +- Shader source validation or program creation may fail. The request must surface an explicit render failure, publish no partial cache result, and never substitute an identity operation. +- A preferred GPU backend or device may be unavailable. Correct fallback execution and self-skipping hardware-gated tests must remain possible. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Complete-request planning and boundaries + +- **FR-001**: The system MUST give one planner semantic visibility over the complete ordered 2D request for a target surface, including all contributing top-level drawables, before it executes planner-controlled 2D GPU work or chooses optimization, cache, materialization, or execution-island boundaries. +- **FR-002**: Every 2D contribution encountered by that planner—including clears; geometry, text, image, and video draws; transforms; clips; opacity; masks; blend and layer scopes; filter operations; backdrop and snapshot dependencies; cached results; and custom work—MUST be represented either by declared semantics sufficient for safe reasoning, a cached materialized input, or an explicit opaque operation that preserves existing execution behavior and blocks unsupported optimization. +- **FR-003**: A filter-effect render-node boundary, an effect-group child boundary, or another historical implementation boundary MUST NOT by itself prevent fusion when the operations on both sides are compatible and no cache or semantic dependency requires materialization. +- **FR-004**: The planner MUST use the stage order `record complete fragments -> lower/discover scope-local target-token dependencies -> resolve forward metadata -> propagate required regions backward -> substitute valid cached subtrees -> partition execution islands`. A cache decision MUST NOT precede target-token dependency discovery. A cache hit MUST become a materialized island input whose internal operations do not execute or fuse into changing work, while render-cache reuse, invalidation, scale identity, and output lifetime remain correct. +- **FR-005**: A 3D-produced surface consumed by the 2D request MUST be an explicit opaque/backend boundary with declared bounds, effective density, ordering, invalidation, and synchronization metadata. This feature MUST NOT inspect, reorder, or fuse operations inside the 3D renderer, and a downstream 2D requested region MUST NOT be forwarded into unknown 3D internals. +- **FR-006**: Frame rendering, nested drawing, and auxiliary pulls such as hit testing, boundary recalculation, and cache warm-up MUST use an explicitly identified render purpose so incompatible runtime state and cache decisions are not shared accidentally. + +#### Render-node recording contract + +- **FR-007**: The public render-node entry point MUST be `void RenderNode.Process(RenderNodeContext context)`. `RenderNodeContext` MUST be the sole public recorder for that invocation; `Process` MUST NOT return executable operations or receive a separate public plan builder. +- **FR-008**: `RenderNodeContext` MUST expose its ordered inputs as borrowed, read-only `RenderFragmentHandle` instances. A handle MUST expose value cardinality, contribution state, and value-input eligibility directly. It MUST expose recording-time bounds and effective scale only through `TryGetMetadata(out RenderFragmentMetadata)` and CPU hit testing only through `TryHitTest(Point, out bool)`. Both methods MUST return `false` with a default out value when the fragment or any ordinary descendant still depends on an `OwningTargetDomain`; finite placeholders MUST NOT be presented as authoritative metadata, and neither method may resolve graph-wide state or execute deferred work. The handle MUST NOT expose rendering, disposal, a backing target, or mutable ownership. `RenderNodeContext.TryCalculateInputBounds(out Rect)` MUST likewise return `false` and `default(Rect)` when any input metadata is symbolic, while an empty input list MUST succeed with `default(Rect)`. The existing executable `RenderNodeOperation` public type MUST be removed in the same breaking change rather than retained as a second lifecycle or compatibility name. +- **FR-009**: Node fragment publication MUST be explicit. The context MUST support publishing one or more recorded handles in authored order and explicitly passing through its inputs; returning from `Process` without publishing anything MUST mean zero fragments, not implicit pass-through. Publication order MUST define value and painter order, while recording an unreferenced intermediate MUST NOT publish it accidentally. +- **FR-010**: The recording vocabulary MUST represent all existing render-node graph shapes: zero-fragment/drop; pass-through; leaf or source emission; one-to-one input mapping; ordered many-to-one combination; static or runtime-discovered N-to-M expansion; an ordered target command with value cardinality `None`; a typed target-token-to-value capture; a same-target state scope; a scope-relative offscreen `TargetLayerScope`; a finite value-producing `Layer`; cached, external, or otherwise materialized input; nested node or subtree recording; a guarded opaque fallback; and RawTargetScope/RawTargetCommand compatibility for retained unguarded behavior. It MUST preserve input selection, materializable value cardinality, fragment publication order, contribution state, value-input eligibility, bounds, effective scale, hit testing, target dependencies/regions, target-read values, and readback declarations for each shape. +- **FR-010a**: Target commands MUST be ordinary returned/published fragment handles in the same ordered stream as value fragments. Parent scopes MUST receive and replay them on the correct local target. The IR MUST preserve cases such as `A -> Clear -> B`, `Layer { A -> Clear -> B }`, `TargetLayerScope { A -> Clear -> B }`, and `Snapshot -> Clear -> scoped/filter draw of snapshot`; a command MUST NOT escape to a root-global list before scope lowering. Public `TargetLayerScope(inputs, TargetRegion)` MUST record normal bottom-up as a symbolic scope-relative offscreen-isolation effect and MUST remain value-input-ineligible. It MUST resolve `Full` only during scope-token lowering after every enclosing transform, clip, root, and finite Layer scope is known. A non-empty resolved scope MUST receive a local target-token chain, replay once into a transparently initialized isolation target, and composite once unless equivalent elision is proven; `Empty` MUST preserve authored ordering without allocating a target or executing pixel work. Existing `PushLayer(default)` MUST lower through this typed public shape; the engine MUST NOT special-case it with pre-order child traversal or freeze a root-sized domain. Public `Layer(inputs, Rect domain, bool domainIsQueryFootprint = false)` MUST remain the explicit one-value constructor and require a finite non-empty domain; the optional flag selects whether the finite domain is reported as the layer's query footprint, leaving output bounds, rasterization, and hit testing content-derived either way. It MUST publish `EffectiveScale.Unbounded`; demand resolution MUST select the materialization density from every child supply, `OutputScale`, `MaxWorkingScale`, and downstream demand, so a denser downstream consumer can raise the Layer density without changing its public supply contract (`src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs:28-44`). With only concrete inputs it MUST retain the normal tight child-derived bounds and hit test. With any symbolic input it MUST immediately reestablish conservative concrete public metadata using the complete finite `domain` as bounds and domain containment as hit testing; it MUST still retain its internal symbolic dependencies so final graph-wide resolution and fan-out analysis use the resolved children. A real destination MUST supply the root domain; a target-less caller MUST provide non-empty `TargetDomain` whenever a resolved root Full access requires it. Neither query bounds, `RootOutputExtent`, nor `RequestedRegion` substitutes for that target domain. A target capture MUST remain non-contributing until `ContributeValues` is recorded. +- **FR-010b**: `CanBeUsedAsValueInput` MUST be computed during recording by a fixed public rule rather than guessed by the planner. Sources, materialized inputs, target captures, finite and owning-target Layers, and valid Shader/Geometry/opaque value maps MUST be eligible; combine/expand MUST require every input eligible; Opacity and OpacityMask MUST preserve the eligibility of their primary child, with the mask carried as a declared resource dependency rather than another value input; Blend, public target scopes, `TargetLayerScope`, raw target forms, and commands MUST be ineligible. An engine-owned TargetScope value-replay map MAY preserve eligibility only for one contributing, self-contained `Single` input that is already eligible and a mechanically restricted callback that performs allocation-free target-state changes plus exactly one replay. `ContributeValues` MUST require and preserve eligibility. Public tests MUST cover every public rule, including an eligible `Shader -> Opacity -> Shader` chain, an ineligible pure-child Blend, and a value-ineligible symbolic Full TargetLayerScope. +- **FR-011**: The context MUST record inspectable typed semantics for participating built-in operations, including opacity, and MUST accept the same canonical Shader and Geometry descriptions exposed by `FilterEffectContext`. A public custom node MUST be able to record a source, Shader, Geometry, nested work, materialized input, or opaque work without engine-internal access. An arbitrary callback or generic state map MUST be recorded as opaque unless the engine can prove an equivalent declared semantic operation. +- **FR-012**: Every `Process` invocation MUST be recording-only and transactional. It MUST NOT draw, decode or read a media frame, allocate or materialize a target, initialize or access a GPU device, compile a program, execute nested rendering, flush, synchronize, snapshot, or perform readback. Built-in source and mask nodes MUST capture ordinary brush/pen resources and declare their `Own`/`Borrow` dependencies during recording; actual brush construction, including nested DrawableBrush materialization, occurs only after an execution session is active. If `Process` fails, none of that node's partial operations, fragments, resources, or cache-policy changes may enter the request, and the context and its handles MUST reject use after the transaction ends. +- **FR-013**: The context MUST own input and recorded-handle lifetimes. Node authors MUST NOT dispose borrowed inputs. Forwarding, mapping, combining, expansion, explicit fan-out, dropping, output publication, nested transfer, and captured resources MUST have deterministic ownership rules. `Own` MUST require a disposable reference type and release or transfer each owned resource exactly once on recording, planning, execution, and cleanup failures. `Borrow` MUST accept any reference type and never dispose it; resource registration MUST confer no persistent render-cache identity, so cache eligibility follows the node's own change reporting rather than a resource key. Feature 004 MUST NOT add a separate `EngineObject.Resource` assignment/disposal gate or source-generator ownership protocol; existing engine-object lifecycle semantics remain unchanged. +- **FR-014**: Nested-node recording MUST reuse the current request's planner, render purpose, requested region, output and maximum working scale, cache and target-factory policy, and failure owner. A node MUST NOT create an isolated processor merely to obtain a nested executable-operation array. +- **FR-015**: `RenderNodeContext` MUST provide a monotonic way to disable render caching for the current result; a node MUST NOT re-enable an inherited disabled policy. Cache eligibility and availability-checked input metadata queries, including `TryCalculateInputBounds(out Rect)`, MUST be recordable without executing callbacks, resolving an owning target domain, or mutating incompatible frame or auxiliary-request state. +- **FR-016**: All in-tree `RenderNode.Process` overrides, direct `RenderNodeOperation` subclasses and factories, operation-wrapper consumers, processor pull/query/rasterize consumers, operation-backed effect-target members, cache replay, renderer bounds/hit-test queries, NodeGraph consumers, feature-003 scale-helper callers, and public contract tests MUST migrate in the same change. The replacement high-level renderer MUST expose one painter-ordered disposable `RenderNodeRasterization` result rather than a partial list-returning compatibility path. That caller-owned result MUST retain its logical output bounds and output scale, MUST own its optional bitmap, and MUST represent an empty output without throwing merely because a zero-area bitmap cannot be allocated. The renderer-wide `RenderBoundsContract` and independent `RenderScaleUtilities` MUST live in `Beutl.Graphics.Rendering`; pure scale helpers MUST NOT remain members of the transaction-scoped context. No returning overload, `[Obsolete]` bridge, forwarding scale-helper shim, public executable-operation compatibility adapter, or parallel public builder vocabulary may remain. +- **FR-017**: This render-node contract change MUST be documented as breaking for `Beutl.Engine`, `Beutl.NodeGraph`, `Beutl.Editor`, and downstream custom render-node authors. The migration MUST cover pass-through, empty output, ordered output publication, cache disabling, nested recording, opaque callbacks, state-first bounds/scale/hit-test metadata, effective scale, and resource ownership. Custom metadata factories MUST replace capturing delegates with a `TState` argument plus non-capturing state-first callbacks. Reusable callers are responsible for keeping that state stable for every metadata/cache phase; the engine MUST use complete field-wise equality/hashing for state identity but MUST NOT perform a recursive state-type allowlist validation pass. `FilterEffect.Resource.CreateRenderNode()` MUST remain the customization seam, but a custom node returned from it MUST use the new recording contract. + + *Amended.* The state-first metadata half of this migration was not carried out. State-first, non-capturing `TState` callbacks apply to the shader, geometry, custom-effect, and target-scope/command definition calls; the bounds, scale, and hit-test *metadata* factories ship as plain delegates whose captures a recording-time purity validator rejects when they are mutable, disposable, resource-holding, or an execution facade, and structural identity is the callback's `MethodInfo` rather than a field-wise state equality. The closing clause holds as written: production performs no recursive state-type allowlist pass. `data-model.md` ("Metadata contracts") carries the delivered rule. + +#### Filter-effect authoring contract + +- **FR-018**: `FilterEffect.ApplyTo(FilterEffectContext, Resource)` MUST remain the sole abstract filter-effect authoring entry point, and existing public `FilterEffectContext` operations MUST remain available in their current-main authored order. An effect that does not subclass or directly consume the removed executable render-node/operation-backed `EffectTarget` API MUST require no source migration to a replacement lifecycle. This operation-call compatibility MUST NOT expose provisional author-time metadata: the legacy `Bounds` property is removed from the public surface (kept as an engine-internal recording tracker), and symbolic or branch-dependent input MUST make `WorkingScale` unavailable. An operation whose parameters depend on unavailable bounds MUST append deferred pure bounds mapping and execution binding that are reevaluated from the resolved target bounds; scale-dependent authoring MUST use `TryGetWorkingScale` and defer binding when it returns `false`. The engine MUST invoke `ApplyTo` only once and MUST NOT replay it after metadata resolution. This stricter author-time metadata availability is an intentional compatibility break. +- **FR-019**: `FilterEffectContext` MUST expose public Shader and Geometry recording capabilities that are usable by an out-of-tree, non-friend assembly without engine-internal access. +- **FR-020**: Calling Shader or Geometry while `ApplyTo` is recording MUST NOT draw, allocate a render target, compile a program, access a GPU device, flush, synchronize, or perform readback. Recording MUST be transactional per effect: if `ApplyTo` fails, none of that invocation's partial recording may enter the request. Execution MUST occur only after the complete recording has transferred to renderer-owned state, and retaining the engine-owned context after `ApplyTo` MUST NOT be supported. +- **FR-021**: A recorded Shader operation MUST declare enough information to distinguish restricted current-pixel work from coordinate-dependent or whole-source work, bind runtime parameters and child inputs, define output and required-input bounds, and identify structure independently of animated values. The public Shader contract MUST define input and output working color space, alpha representation, coordinate origin and units, execution-time bounds and density, and child/sampler coordinate and density behavior. CurrentPixel semantics MUST apply after upstream analytic/antialiased coverage has been resolved. Fusion eligibility MUST be established by a restricted current-pixel form whose source and bindings the engine validates, but coordinate validation alone MUST NOT imply premultiplied-coverage homogeneity. An arbitrary shader, author assertion, or public self-declared coverage flag alone MUST remain insufficient to cross a coverage-producing boundary. Final bounds, density, and device-size-dependent values MUST be bound at execution time rather than assumed final during `ApplyTo`. + + *Amended.* Fusion eligibility was widened past the restricted current-pixel form during implementation. A validated `WholeSource` shader may now head a fused run — its implicit source mapping governs the run input and downstream current-pixel stages are appended — because a whole-source stage that could only form its own pass split every chain it led. Folding work *upstream* of a whole-source head remains rejected, since its sampling cannot be proven equivalent, and FR-027's hard barriers are unchanged. `WholeSourceHeadFusionParityTests` is the parity proof. +- **FR-022**: A recorded Geometry operation MUST describe single-input/zero-or-one-output 2D drawing, declare forward output bounds, backward required-input bounds or a conservative full-input requirement, whether CPU readback is required, and any structural identity. Its callback MUST execute later through an engine-owned session whose input and drawing surface are borrowed only for the callback duration. Readback MUST form an explicit synchronization barrier, and fan-out or dynamic output topology MUST remain on the opaque compatibility path in this feature. +- **FR-023**: Existing Skia filter, color filter, and custom-effect recordings MUST coexist in authored order with Shader and Geometry recordings. They MUST lower to declared semantics where equivalence is sound and otherwise execute through an explicit guarded opaque or marked raw compatibility boundary. Legacy custom-effect brush/pen data MUST use ordinary `Brush.Resource?`/`Pen.Resource?` values plus the existing execution-time `BrushConstructor`/canvas path; the feature MUST NOT add a parallel paint-wrapper, registration-table, or brush-lowering lifecycle. Legacy `CustomEffect`, custom `IBackdrop.Draw`, audio-visualizer raw hooks, RawTargetScope, and RawTargetCommand callbacks MUST be classified as opaque external work whose internal pass/synchronization cost is not claimed by the compiled plan or evidence harness. The legacy `Bounds` property is removed from the public `FilterEffectContext` surface; the engine tracks recording bounds internally, and bounds-dependent parameters (e.g. `TransformEffect`'s origin when `ApplyToTarget == false`) MUST be resolved from the execution-time target bounds rather than read during `ApplyTo`. A `CustomEffect` with no bounds transformer MUST preserve a symbolic unknown bound rather than assume identity. Scope-domain lowering MUST resolve that symbol to the finite owning target domain only after enclosing transforms, clips, and target scopes are known, then MUST reevaluate the retained bounds-transforming effect items from the resolved input bounds. An owner may come from the real destination, an explicit root `TargetDomain`, or an enclosing finite target scope. A target-less root request with no such owner MUST fail before invoking the callback. Every later legacy Skia/custom/Shader/Geometry item after the first unknown item MUST execute from actual runtime target bounds in the same opaque island, and its final semantic outputs MUST be cropped to the resolved owning domain. Internal opaque allocations remain uninspectable and are not constrained by that final crop. A guarded callback MUST reject nested execution, hidden allocation/synchronization, and every `SaveLayer`-backed layer/opacity/blend/mask/paint operation. +- **FR-024**: Ownership of resources captured by a recorded Shader or Geometry operation MUST transfer exactly once from the disposable recording context to renderer-owned execution state, including clone, child-context, multi-input, lowering-failure, and execution-failure paths. +- **FR-025**: The semantics, ownership, callback lifetime, and public capability names are fixed here; the exact normative descriptor, session, helper, and overload surface is fixed by `contracts/public-api.md`. `FilterEffectContext` and `RenderNodeContext` MUST accept the same canonical composable description for each Shader and Geometry operation and MAY provide convenience overloads only when they preserve those semantics and lifetime rules (`src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs:204-236`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs:365-457`). + +#### Fusion, visual correctness, bounds, and scale + +- **FR-026**: The planner MUST fuse maximal compatible runs of coordinate-invariant Shader stages and participating invariant 2D operations across render-node boundaries within one resolved-coverage domain. The first required non-effect participant is opacity. The primary cross-node proof MUST start from a coverage-resolved materialized source so the one-pass claim does not depend on moving arbitrary Shader work across coverage generation. + + *Amended.* A maximal compatible run may also be headed by a validated `WholeSource` shader instead of consisting only of coordinate-invariant stages; see the FR-021 note. +- **FR-027**: Fusion MUST occur only when output equivalence is established. Coordinate-changing work without a proven fold, analytic/antialiased coverage generation without an engine-proven premultiplied-coverage-homogeneous participant, explicit readback, destination-dependent Blend or otherwise unproven composite behavior, dynamic fan-out, externally owned targets, backend transitions, 3D results, and opaque operations MUST split the run predictably. No public author assertion may waive these barriers. +- **FR-028**: A compatible run that exceeds a backend capability or resource limit MUST be split deterministically into valid ordered passes without dropping stages, changing bounds, or changing visual output. +- **FR-029**: Planned execution MUST preserve authored fragment/value order, painter order across top-level drawables, scope-local target-token/read dependencies, blend and premultiplied-alpha behavior, analytic/antialiased coverage and Shader application order, working color semantics, hit testing, output bounds, clipping, and source-to-output coordinate mapping. Forward metadata MUST retain separate `RootOutputExtent` and `QueryBounds`: the former unions contributing value bounds with potentially pixel-writing root target-effect regions after scope transforms/clips, while the latter unions contributing-value and target-command/scope query provenance for measurement and hit testing while excluding non-contributing read-only capture anchors or order-only access without query metadata. +- **FR-030**: Planned execution MUST preserve feature 003's `OutputScale`, per-fragment `EffectiveScale`, supply-driven working scale, `MaxWorkingScale`, scale-1 rounding behavior, and per-buffer dimension clamp for the same inputs. `RenderScaleContract.MapInputSupply(TState state, Func map, structuralKey)` MUST provide a pure one-input map over the input's resolved supply for operations such as Transform and DrawableGroup; its mapping MUST be reevaluated after symbolic input resolution with the same stored state rather than treating a recording hint as final. Every custom bounds, scale, and hit-test callback MUST be non-capturing and state-first. Callers MUST keep reusable state stable across forward bounds, backward ROI, scale reevaluation, hit testing, and cache use; mutating it is a contract violation rather than a synchronously rejected input. The engine MUST derive state identity from the complete field graph without invoking author-provided equality/hash overrides. A custom scale resolver MUST return a finite value greater than zero; throws and invalid results MUST fail and roll back recording rather than being sanitized to a fallback. A working-scale-only `FilterEffectRenderNode` customization MUST override the protected `GetWorkingScaleContract()` hook and reuse base `Process` lowering. After finite or owner-relative value isolation, the base MUST fold the standard or custom policy into the first surviving Shader, Geometry, or legacy operation, MUST NOT record an identity map or extra opaque/pass boundary, and MUST pass the original inputs through when `ApplyTo` records no items. Mixed inputs containing a symbolic `TargetLayerScope(Full)` MUST resolve the contract only after the actual owning scope is known. `FilterEffectContext.TryGetWorkingScale` MUST return `false` and its `WorkingScale` getter MUST throw while the nominal effect-input density is symbolic or branch-dependent; a concrete single-input context remains author-readable, subject to a later operation-specific dimension clamp. Public TargetCapture MUST declare a `TargetCaptureScaleContract`: `MaterializeAtWorkingScale` and `Custom` are explicit materialization/resampling boundaries whose concrete density is output-derived and whose custom resolver receives an empty `InputSupplies` list, while `PreserveTargetSupply` MUST remain `EffectiveScale.Unbounded` during recording and materialize at the resolved density of its active root, finite Layer, or TargetLayerScope. For an affine active target transform it MUST use the maximum singular value of the linear 2x2 part, so shear cannot lose supply; perspective is position-dependent and MUST be rejected explicitly before allocation rather than approximated by one scalar. The built-in backdrop MUST use the same public preserving mode. `Render(ImmediateCanvas)` MUST use the active destination density/transform/clip/state and prior pixels without clearing or flushing the borrowed target. A null `RequestedRegion` MUST select the resolved `RootOutputExtent` for backward root requirements, final commit, and rasterization domain; a non-degenerate non-null region MUST select its intersection with `RootOutputExtent`, while an explicitly degenerate region MUST preserve its authored zero-area bounds and origin. Neither case shrinks the available external-root or offscreen target domain, so target-read ROI may expand to that domain when required. `RenderNodeMeasurement` MUST report `OutputBounds` and `QueryBounds` separately, and hit testing MUST use query metadata rather than target-write extent. + The standard filter policy MUST retain its `OutputScale` floor, while an explicit `Custom` filter policy MAY choose a positive density below `OutputScale`. The filter callback MUST receive one surviving branch per invocation (`InputSupplies.Count == 1`) and that branch's isolated effect-input bounds as `OutputBounds`. Legacy multi-input lowering MUST aggregate the densest concrete mapped result and use `OutputScale` only when every result is `Unbounded`. Allocation clamping MUST be independent of callback cardinality: it MUST retain branch-local, local-origin intermediate footprints before an opaque Custom callback. Immediately before callback entry, the forced legacy compatibility materialization MUST remove renderer-owned aprons and present a dimension-sized local backing with the historical local origin and final placement. A compatible legacy-local target MAY be reused. The callback may then combine, split, move, or shrink targets without declaring topology, so the transformed results MUST be unioned and later semantic footprints tracked conservatively in that aggregate domain without inserting a canonical normalization pass for retained legacy storage. A no-item effect MUST commit no provisional isolation or owned resource and MUST leave an unprobed hook/resolver unevaluated. Current-pixel fusion MUST split a concrete effective-scale mismatch with a `ScaleTransition` boundary and MAY let an `Unbounded` predecessor adopt its concrete successor density. Every merged binder MUST receive stage-local logical bounds plus the actual runtime-clamped run working scale/device footprint; the first input scale comes from the materialized run input and every later input scale is that run density, matching disabled execution. Structural plan identity MUST include the compatibility class of every candidate fusion edge so a runtime scale-relation change recompiles exactly one replacement plan. + + *Amended.* The state-first metadata design stated here was not implemented, and the one-input density contract gained a direction it did not have. `RenderScaleContract.MapInputSupply` ships as a two-callback contract — the forward supply map plus the backward map from output demand to input demand — because a forward-only map let an unbounded input under an enlarging map rasterize at the map's own output demand and be magnified from there; the forward-only form survives as the narrower `MapInputSupplyPreservingDemand`, whose name states its precondition. Neither takes a state argument or a caller-supplied structural key, and both are reevaluated after symbolic input resolution as required above. The bounds, scale, and hit-test metadata callbacks stayed plain delegates: capturing is permitted, a recording-time purity validator synchronously rejects a mutable, disposable, resource-holding, or execution-facade capture, and structural identity is the callback's `MethodInfo` rather than a complete field graph. State-first non-capturing callbacks are the rule for execution and definition calls only; see the FR-017 note. `data-model.md` ("Metadata contracts"), `contracts/render-request.md` ("Working density"), `contracts/public-api.md`, and `contracts/breaking-changes.md` carry the delivered contract. +- **FR-030a**: Every concrete planner-owned RGBA16F target allocation MUST have a positive device size, pass checked `width * height * 8` arithmetic, respect feature 003's 16,384-axis clamp and any smaller active backend/factory limit, satisfy the exact pixel-format/backend/context descriptor, and transfer ownership through the target pool. Compiler liveness MAY schedule reuse but MUST NOT reserve allocations or enforce a separate request-wide live byte/target budget before execution. Actual allocation failure MUST preserve the characterized `RenderIntent` behavior, release every acquired lease exactly once, and publish no partial cache result. +- **FR-030b**: Target capture MUST classify only an exactly collapsed or non-finitely invertible target transform as empty. A finite affine transform with a nonzero determinant MUST remain visible whenever its mapped device footprint is non-empty; approximate determinant tolerances MUST NOT discard a small but nonzero scale. Perspective remains explicitly unsupported by `TargetCaptureScaleContract.PreserveTargetSupply` because its density is position-dependent. For target-less execution, a finite `TargetDomain` MUST supply root `TargetRegion.Full` access regardless of whether `Rasterize.Purpose` is `Auxiliary`, `CacheWarmup`, or `Frame`; only destination-backed rendering replaces it with the actual viewport. For a non-empty selection, `RenderNodeRasterization.Bounds` MUST have one meaning: the canonical device-pixel cover converted back to logical coordinates, so replaying its bitmap at that position preserves size and device phase. The unsnapped semantic selection remains request metadata rather than a second meaning for the returned property. +- **FR-031**: Every non-invariant semantic operation MUST provide sound forward output bounds and backward required-input bounds. When a tighter required-input region cannot be proven, the planner MUST request the complete declared input rather than under-render. +- **FR-032**: Empty value output, zero-area value output, an intentionally dropped value, an effectful target command with value cardinality `None`, a non-contributing public or engine target-capture value, and allocation failure MUST remain distinguishable outcomes. None may be silently converted to an identity pass or pruned merely because no intermediate value was produced. + +#### Plans, caching, resources, and failure behavior + +- **FR-033**: Structural plan identity MUST exclude runtime-only parameter values and resource contents while including every choice that changes fragment order/scope, bindings, bounds behavior, fusion legality, or execution shape. Output-cache identity MUST additionally include parameter/resource versions, bounds, scale, format, device identity, and target-token identity/coverage wherever they affect pixels. A target-dependent whole subtree MUST bypass persistent reuse unless the complete preceding-token pixel identity and coverage are proven; external-root prior pixels are request-unique. Identity comparison MUST remain correct under hash collisions. +- **FR-033a**: `RenderCacheOptions.Default` MUST be the same disabled policy as `RenderCacheOptions.Disabled`, and an ordinary `RenderNodeRenderRequest` used directly or through `RenderNodeRendererOptions.DefaultRequest` MUST NOT opt into persistent render caching. `RenderCacheOptions.Enabled` is the explicit opt-in for cache-specific callers and experiments. This default-path contract is guarded by `RenderNodeCacheHelperTest.DefaultPolicy_IsDisabledAndCacheRequiresExplicitOptIn`, `ComposedSceneRenderCacheTests.PlainGroup_DefaultRenderNodeRendererOptionsDoNotUsePersistentCache`, and the GPU-gated `ComposedSceneRenderCacheTests.DefaultPolicy_DoesNotAdmitPlainAntialiasedGeometryOnGpu` regression. +- **FR-034**: Parameter-only animation of a structurally stable request MUST invalidate affected rendered output while reusing its structural plan and compatible programs. A structural change, device recreation, or incompatible cache transition MUST invalidate exactly the affected cached plans and programs and cause one replacement compilation on the next applicable request. +- **FR-035**: One request-scoped execution owner MUST account for intermediate target acquisition, ownership discharge, synchronization, backend transition, and failure cleanup within and between execution islands. Synchronization MUST occur only for declared dependencies, backend transitions, explicit target/input readbacks, or a declared legacy opaque-external callback whose internals are explicitly unmeasured; a same-backend compatible run MUST NOT introduce hidden per-stage flushes. Every acquisition MUST be discharged exactly once by pool return, disposal, or atomic transfer to an accepted render-cache payload; a failed or partial island output MUST NOT be published to a cache. +- **FR-036**: For a stable workload after warm-up, new intermediate-target creation MUST be zero. Peak live intermediate ownership MUST follow the planned dependency schedule rather than grow linearly with the number of compatible stages. +- **FR-037**: Cleanup MUST continue after an individual cleanup failure and MUST preserve the first primary render or planning exception. Pool and GPU-object release MUST occur on the valid rendering lifetime and thread. +- **FR-038**: Each nested 2D target-surface render MUST receive the same complete-request planning semantics as its own request and MUST inherit the parent request's target-factory policy, render purpose, requested bounds, scale policy, cache policy, and failure owner unless an explicit boundary declares a different value. +- **FR-039**: Allocation-failure outcomes for existing paths MUST match the freshly recorded current-main baseline. Any later normalization between preview and delivery requires a separate explicit behavioral decision and is not part of this feature. + + *Amended.* That explicit behavioral decision was taken inside this feature for one path rather than deferred. `RenderIntent` moved into the custom-effect allocator, so `CustomFilterEffectContext.CreateTarget` and `CreateTargetLike` throw on a real allocation failure under `RenderIntent.Delivery` and keep returning an empty target under `RenderIntent.Preview`, instead of leaving every caller to invent its own policy and letting a delivery export ship an unprocessed frame. Every other allocation-failure path still matches the current-main baseline — the nested-target preview degradation was deliberately preserved for this requirement's sake. `contracts/breaking-changes.md` ("Render intent, brushes, and allocation behavior") carries the migration. +- **FR-040**: Ordinary 2D rendering MUST preserve current-main behavior on supported environments without the preferred GPU backend. Every public Shader description MUST have an unfused execution path on every supported ordinary 2D backend, so the request does not fail solely because fusion is unavailable. Invalid Shader source, invalid bindings, or program-creation failure MUST surface as an explicit render failure, publish no partial output to a cache, and MUST NOT silently become an identity operation. GPU-specific fused execution-shape and performance validation MAY remain hardware-gated. + +#### Evidence and observability + +- **FR-041**: Evidence MUST assert planner-controlled execution shape directly from immutable recorded/compiled plans, boundary reasons, shader runs, and cache resolution; reuse and lifetime MUST be asserted from component-local structural-plan, program-cache, and target-pool statistics plus test-owned allocation/synchronization/callback probes. The feature MUST NOT add a request-wide completed snapshot, event recorder, verification pass, or renderer-owned diagnostic state solely to support tests. Opaque-external callbacks MUST remain explicit boundaries, and evidence MUST NOT claim visibility into their internal physical passes or flushes. +- **FR-042**: Whole-request performance and correctness claims MUST cover ordinary render nodes, compatibility work, cache boundaries, nested work, and final output. Effect-only counters MUST NOT be used to claim renderer-wide improvement; compiled-plan topology, component statistics, execution probes, visual parity, and persistent-lifetime benchmarks MUST be evaluated together. +- **FR-043**: Parity against the pre-feature renderer MUST be evidenced, and the evidence MUST NOT be fabricated for the baseline: feature-only plan, cache, and pool statistics are validated against the feature engine, and timings MUST be remeasured with the production-equivalent lifetime on the new baseline rather than carried over from donor-branch runs. Normal CI visual coverage MUST compare fusion-disabled and fusion-enabled execution on the same process and device. Any committed reference image MUST record the exact OS, architecture, backend, device, driver, graphics-library, and runtime fingerprints it was produced on, and a runner that compares against one MUST verify every fingerprint field before the parity oracle and fail explicitly on a missing field or mismatch, never silently selecting a foreign-device reference. + + *Amended.* This requirement originally also mandated a pinned starting-SHA baseline, fingerprinted RGBA16F references and manifests, and a committed paired visual-evidence runner, and forbade the same-process comparison from standing in for that paired proof. Those artifacts were withdrawn with the evidence tree (tasks T005-T007, T016, T019, T020, T114, T115, T123); no evidence tree is committed under this directory, and no committed reference image remains for the fingerprint rule above to govern. The requirement was therefore narrowed to what this branch actually delivers rather than left as an unmet MUST. Parity against target-main is evidenced by the same-process fusion-disabled/enabled A/B in `GpuPassFusionSameProcessParityHarness` and by an out-of-tree differential harness that renders the corpus on a target-main build and a feature build of the same machine, neither of which needs a committed device-specific reference. **What this costs: target pixel parity against the starting commit is not reproducible from this branch alone.** `tasks.md` records the same conclusion under Phase 2's checkpoint status. +- **FR-044**: Automated coverage MUST include public non-friend filter-effect and render-node authoring tests, concrete and symbolic `TryGetMetadata`/`TryHitTest`/`TryCalculateInputBounds` outcomes (including nested-recording propagation and finite-Layer recovery), resolved-supply `MapInputSupply` behavior for Transform and DrawableGroup, metadata-callback capture-purity rejection and `MethodInfo`-based structural-identity stability (the field-wise state-equality, non-capturing-metadata, and mutable-state items lapsed with the design they belonged to; see the FR-030 note), a migration census for every production `Process` override and every old `CreateLambda`/raw-canvas hook, root/Layer/TargetLayerScope command order, non-empty and Empty TargetLayerScope execution, `Transform(+10) -> PushLayer(default) -> Full clear`, `SnapshotBackdrop -> Clear -> DrawBackdrop` ordering at root and inside Blend/transform/filter scopes, RootOutputExtent-versus-QueryBounds, empty/shifted rasterization results, recording-side-effect tests, cross-render-node fusion and barrier tests, a non-coverage-homogeneous CurrentPixel Shader after an antialiased thin line/path with edge-local parity and exact boundary assertions, public declared-density TargetCapture inside denser finite Layer/TargetLayerScope targets, parity at multiple scales and requested regions, cache and animation tests, fallback tests, target-pool/program/cache lifetime and failure tests, and the full request-owner failure matrix, while retaining all applicable existing feature-003 and engine regression tests. + +### Key Entities + +- **2D Render Request**: One preview, delivery, nested-draw, or auxiliary request with a declared purpose, output scale, finite destination/target-less domain, requested region, root output, and complete dependency graph. +- **Render-Node Recording Context**: The transaction-scoped public surface passed to `RenderNode.Process`; it owns borrowed fragment inputs, recorded values/commands/captures/scopes, one explicitly ordered publication stream, nested recording, cache policy, and transferred resources until they move atomically into the complete request. +- **Render Fragment Handle**: A context-owned, non-executable `RenderFragmentHandle` reference to an ordered fragment stream. Value cardinality/contribution and value-input eligibility are always readable while the handle is active; concrete recording-time bounds/scale and CPU hit testing are availability-checked through `TryGetMetadata` and `TryHitTest`. It can be used only by its active recording/request owner and never grants rendering or disposal access to a node author. +- **Semantic Operation**: Ordered 2D work whose bounds, inputs, output behavior, scale behavior, side effects, and fusion properties are known well enough for safe planning. +- **Target Command**: A returned/published effect fragment such as clear, guarded target draw, or readback. It has value cardinality `None`, explicit target access and query metadata, and consumes the token of its current target scope. +- **Target Capture**: A scope-local token-to-value edge that samples the preceding target once at a declared concrete density and remains non-contributing until explicitly wrapped by `ContributeValues`. The public form is an intentional resampling boundary rather than a promise to preserve the owning target's eventual density. +- **Target Scope / Target Layer Scope / Layer**: `TargetScope` is a same-target decorator. `TargetLayerScope` is a value-ineligible, scope-relative offscreen-isolation effect whose Full region remains symbolic until target-token lowering; a non-empty resolution uses an isolated local target, while Empty is order-only and performs no pixel work. `Layer` is the separate finite-domain constructor that emits exactly one reusable value. Raw target forms preserve uninspectable legacy behavior and are marked opaque external. +- **Root Output Extent / Query Bounds**: `RootOutputExtent` is the conservative execution/output union of contributing values and potentially pixel-writing root target effects. `QueryBounds` is separate measurement and hit-test metadata; it never substitutes for a target domain. +- **Render Node Rasterization**: One caller-owned disposable painter-ordered result carrying logical bounds and output scale and owning its optional bitmap, including a normal empty form. +- **Opaque Boundary**: Existing or external work that remains executable but does not expose sufficient semantics for optimization; it forces materialization or schedule separation where needed. +- **Execution Island**: A dependency-consistent part of the globally visible request that can be planned and executed with one cache, lifetime, and backend policy without hiding dependencies from the request-level planner. +- **Shader Description**: A recorded stage with structural identity, runtime parameters, child inputs, coordinate behavior, bounds behavior, and owned resources. +- **Geometry Description**: Deferred drawing or geometry work with structural identity, bounds behavior, readback declaration, borrowed callback inputs, and engine-owned output lifetime. +- **Bounds Contract**: The pair of a forward output-bounds mapping and a backward required-input mapping, with a conservative complete-input alternative. +- **Structural Plan**: A reusable ordered schedule, fusion partition, execution-island partition, and resource-lifetime shape independent of per-frame parameter values. +- **Runtime Parameter Set**: The current frame's values, resources, bounds, and regions bound to a compatible structural plan without changing its identity. +- **Resource Plan**: The ownership intervals, formats, sizes, reuse opportunities, synchronization points, and release responsibilities for materialized intermediates in a request. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A deterministic scene starting from a coverage-resolved semitransparent materialized source and containing two eligible Shader effects separated by opacity, represented by distinct render nodes, keeps `CanBeUsedAsValueInput == true` through the opacity handle, executes in exactly one GPU pass with at most one intermediate target, and meets the visual parity thresholds. A filter-effect-group-only chain does not satisfy this criterion. +- **SC-002**: Each required hard-boundary scene splits at the expected boundary with zero illegal cross-boundary fusions, and its output meets the visual parity thresholds. The antialiased thin-line/path coverage case MUST also meet an edge-band local-error limit and exact boundary-count oracle rather than relying only on whole-image averages. +- **SC-003**: An existing plugin-style effect that uses `ApplyTo` without subclassing the changed render-node API compiles without source changes and matches its baseline render; a non-friend assembly can author both Shader and Geometry operations using only public `FilterEffectContext` API, and its invariant Shader participates in the SC-001 fusion shape. +- **SC-004**: Every production and test `RenderNode.Process` override on the starting commit uses the new `void` recording contract. A non-friend assembly can implement no-output, pass-through, source, semantic-map, many-to-one, N-to-M, target-command, target-capture, target-scope, TargetLayerScope, finite Layer, RawTargetScope/RawTargetCommand, nested, materialized-input, custom-scale, and opaque nodes; invoking each `Process` performs zero GPU dispatches, target allocations, surface snapshots, media reads, or nested renderer execution. +- **SC-005**: Across 100 parameter-only animated frames, the structural plan compiles exactly once and compatible program creation is zero after frame 1. One declared structural change causes exactly one affected plan recompilation. +- **SC-006**: Stable frames after warm-up create zero new intermediate targets, and peak live intermediates for a 10-stage compatible linear chain are no greater than for a 3-stage chain with the same bounds and dependency shape. +- **SC-007**: Representative effects and mixed render-node chains achieve SSIM at least 0.99, linear-RGB mean absolute error at most 0.02, and alpha mean absolute error at most 0.02 against provenance-verified current-main references at output scale 1.0; antialiased coverage workloads additionally satisfy their recorded edge-band local-MAE threshold and a per-channel maximum error of at most 0.02 in the normal-CI same-process pair, while the dedicated paired workflow also satisfies any tighter bound stored in its exact matching fingerprinted manifest; multi-scale, shifted-region, empty-region, and fallback cases meet their freshly recorded baseline tolerances; and the existing feature-003 golden suite remains green. + + *Amended.* The pinned fingerprinted manifest and the "dedicated paired workflow" tighter bound were withdrawn with the evidence tree (tasks T005–T007, T115, T123): no device-specific reference blob is committed, so no committed artifact can supply that bound. The criterion is evidenced by the two mechanisms that remain reproducible — the same-process fusion-disabled/enabled A/B in `GpuPassFusionSameProcessParityHarness`, which holds the fixed per-channel AA edge maximum error of `0.02` on normal CI, and the out-of-tree differential harness, which renders the whole corpus on a target-main build and a feature build of the same machine. Because both sides of each comparison run on one device, neither needs a committed device-specific oracle. The feature-003 golden suite remaining green is unchanged. +- **SC-008**: On the deterministic cross-boundary color workload, the warmed paired benchmark's 95% confidence interval for the post-feature/pre-feature median frame-time ratio is entirely below 1.0 on the same test system and production-equivalent lifetime. The implemented CI method runs baseline A, feature, and baseline B with BenchmarkDotNet's Monitoring strategy, one launch, three warm-up iterations, fifteen measured iterations, one invocation and unroll factor one per iteration, after five setup frames. Each required case MUST supply exactly fifteen finite positive `Statistics.OriginalValues` samples in baseline A, feature, and baseline B. For each case, baseline repeat stability bootstraps `median(B) / median(A)` 100,000 times from the two 15-sample runs; its linearly interpolated 95% interval MUST contain 1.0, and `factor = max(ci.upper, 1 / ci.lower)` MUST be at most 1.20. Every case must pass before the 30 baseline samples are pooled. The analyzer independently resamples that stable pool and the feature samples with replacement 100,000 times, computes `median(feature resample) / median(baseline resample)`, and reports the linearly interpolated 2.5th and 97.5th percentiles as the 95% interval; the point estimator is `median(feature) / median(pooled baseline)`. The base seed is `20040719`, deterministically combined with the case-name FNV-1a hash (and a distinct fixed xor for the baseline-repeat analysis). The analyzer applies no additional outlier removal, clipping, or winsorization and intentionally consumes BenchmarkDotNet `OriginalValues` rather than its outlier-classified summary. The manifest MUST record the global `baselineRepeatStabilityRule`, `maximumBaselineRepeatSymmetricToleranceFactor`, and `baselineRepeatStable`, plus per-case baseline A/B/pooled sample counts, repeat medians and ratio, repeat 95% interval, contains-one flag, symmetric factor/interval, and stable flag. Required control and barrier workloads show no regression beyond the unclipped symmetric tolerance derived from the baseline A/B repeat interval; historical donor-branch timing percentages are not acceptance targets. + + *Amended.* The paired same-fingerprint benchmark runner, its target harness and the acceptance report were withdrawn with the evidence tree (tasks T114, T115, T123). The methodology above is retained as the definition the workload would be judged by, and `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` stays runnable on demand for those workloads, but no committed artifact reproduces the confidence interval. The bootstrap analyzer specified above and the manifest it would write were withdrawn with them, so only the BenchmarkDotNet job configuration survives in-tree, in `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs`. The performance improvement is therefore **not asserted as a met acceptance criterion** for this feature; it is measurable on demand and is not a merge gate. +- **SC-009**: In every injected failure phase, every planner-owned target, program, recorded resource, session, deferred input, or recording handle is discharged exactly once by release/disposal or documented cache-ownership transfer after request teardown; a secondary cleanup fault does not replace the primary failure. +- **SC-010**: Every planner-controlled execution-shape claim is proven by direct compiled-plan assertions plus the applicable execution probe and component statistics, and every failure-matrix case ends with no active request-owned target, program, resource, session, or handle. No acceptance criterion depends on a separate request-wide diagnostic subsystem. +- **SC-011**: All applicable engine, public API contract, no-preferred-GPU, and feature-003 regression tests pass with no change to the freshly recorded allocation-failure behavior. + + *Amended.* Custom-effect target allocation failure under `RenderIntent.Delivery` changed deliberately; see the FR-039 note. +- **SC-012**: After warming a static-prefix/animated-tail scene, each of 100 animated frames records one reusable prefix-cache hit, zero executed prefix passes, zero prefix plan recompilations, and output matching a fresh uncached render. +- **SC-013**: Every visual-parity workload is demonstrably non-vacuous: disabling its operation under test changes linear RGB or alpha by more than the applicable parity tolerance plus a recorded margin. + +## Assumptions + +- Feature 003's scale and density contracts on the new branch's current-main starting commit are normative. This feature composes with them rather than redefining them. +- Existing premultiplied-alpha and working-color behavior is part of visual compatibility even if the implementation used to produce it through different pass boundaries. +- Complete-request visibility does not imply one monolithic executable plan. Cache, backend, readback, and opaque boundaries may form multiple execution islands after dependencies have been inspected. +- An explicit opaque fallback is an acceptable representation for an operation that has not yet exposed safe optimization semantics; silently assuming semantics is not acceptable. +- The `void RenderNode.Process(RenderNodeContext)` shape, explicit fragment/pass-through behavior, context-owned non-executable handles, unified ordered fragment stream, typed symbolic TargetLayerScope, finite value-producing Layer, scoped target tokens, recording-only transaction, nested same-request recording, monotonic cache disabling, and guarded/raw opaque fallbacks are fixed product decisions. Their semantics and lifetime rules are fixed here; the normative helper, descriptor, callback-session, and overload shapes are fixed by `contracts/public-api.md` and must be reviewed as an extensibility surface before implementation. +- Existing executable `RenderNodeOperation` subclasses and custom `RenderNode.Process` implementations are intentionally source-breaking. No compatibility shim is assumed; the implementation migrates in-tree consumers to `RenderFragmentHandle` and documents the public replacement in the same change. +- Existing `FilterEffect.ApplyTo` source compatibility applies to effect authors that do not also subclass the changed render-node or executable-operation APIs. `FilterEffect.Resource.CreateRenderNode()` remains available, but a returned custom node follows the new contract. +- The public capability names Shader and Geometry are fixed by product direction. Their canonical descriptions are shared by `FilterEffectContext` and `RenderNodeContext`; exact descriptor and convenience-overload shapes are the normative surface declared in `contracts/public-api.md`. +- Current-main behavior is the compatibility baseline. The abandoned feature-004 branch supplies algorithms, tests, fixtures, and failure knowledge only; no result from it is accepted without provenance review and remeasurement. +- The first cross-node proof uses opacity because it is a common invariant non-effect operation. Additional operations participate only when their equivalence, bounds, and lifetime contracts are explicit. +- The primary one-pass proof starts from coverage-resolved materialized pixels. CurrentPixel coordinate validation does not prove premultiplied-coverage homogeneity, so arbitrary public Shader work remains after analytic/antialiased coverage production unless the engine mechanically proves a participating operation can commute across it. +- Public TargetCapture exposes output-derived `MaterializeAtWorkingScale`/`Custom` modes and a `PreserveTargetSupply` mode that late-binds the active target density. The preserving handle remains `EffectiveScale.Unbounded` during recording, and the built-in backdrop uses the same public contract. +- Existing code may read `FilterEffectContext.WorkingScale` during `ApplyTo` only after `TryGetWorkingScale` succeeds for one concrete input; symbolic or branch-dependent inputs make the probe return `false` and the getter throw rather than expose stale metadata. The available value is the nominal effect-input density. Shader, Geometry, and CustomEffect execution contexts bind operation-specific density and device-space values later so owner-domain resolution, cache partitioning, and per-buffer clamps cannot make recorded values stale. +- Moving `SKSLScriptEffect` from legacy `CustomEffect` execution to declarative `ShaderDescription` recording intentionally changes its built-in shader values. The old path exposed raster-padded backing dimensions inherited from `CreateTargetLike` on the source target through `width`, `height`, and `iResolution`, and copied the source target's actual density verbatim to `iScale`. The declarative path exposes the semantic output backing dimensions instead, smaller by the raster-padding amount, and resolves `iScale` through feature 003's supply-driven model while honoring `MaxWorkingScale`. The `ShaderMigrationPhysicalFootprintTests` fixture illustrates the resulting change from `9x8 @2x` to `5x4 @1x`. Existing user scripts that normalize coordinates with `iResolution` may therefore render slightly differently; this is an explicitly accepted behavior change, not a regression to fix. +- Changing `ImmediateCanvas.PushOpacity` intentionally improves group-opacity precision. At the starting commit, group opacity was quantized to 8 bits through an `SKPaint` byte alpha and the DstIn mask drawn on pop, leaving opacity as the only 8-bit value in the RGBA16F linear pipeline. The current SaveLayer paint instead carries an SkSL runtime color filter that multiplies all four premultiplied components by the float opacity; a color-matrix filter is unsuitable because it clamps to [0, 1] and would destroy values outside that range that RGBA16F can carry. A 0.5 group therefore now produces `0.5` rather than `127 / 255 = 0.49804688`, a difference of about `0.00196`. This explicitly accepted output change is a precision improvement and resolves the existing inconsistency in which the fused path's `OpacityRenderNode` CurrentPixel shader already used float opacity while the non-fused path did not. +- Legacy `CustomEffect` remains a behavior-compatible opaque-external filter-effect escape. Render-node fan-out/dynamic topology uses the declared OpaqueExpand/Combine vocabulary, while retained raw target callbacks use RawTargetScope/RawTargetCommand; none is a second optimizable geometry pipeline. + +### Dependencies + +- The existing `FilterEffect`, `FilterEffectContext`, render-node, render-cache, and feature-003 scale contracts on the new branch's starting commit. +- A breaking public-API migration for custom render nodes, executable operation subclasses and factories, processor pull consumers, NodeGraph integrations, and non-friend contract tests, with no parallel legacy lifecycle. +- Hardware-gated GPU verification for execution-shape and performance criteria, plus supported fallback tests for environments without the preferred backend. +- A deterministic reference-render and benchmark harness that can run both the unmodified starting commit and the redesigned implementation with equivalent persistent renderer lifetimes. + + *Amended.* The starting-commit half was withdrawn with the evidence tree; see the FR-043 note. +- Public API contract coverage from a non-friend assembly so plugin-author capabilities are validated independently of engine internals. diff --git a/docs/specs/004-gpu-pass-fusion/tasks.md b/docs/specs/004-gpu-pass-fusion/tasks.md new file mode 100644 index 0000000000..9efb41ac7b --- /dev/null +++ b/docs/specs/004-gpu-pass-fusion/tasks.md @@ -0,0 +1,487 @@ +--- + +description: "Dependency-ordered implementation tasks for renderer-wide GPU pass fusion" +--- + +# Tasks: Renderer-Wide GPU Pass Fusion + +**Input**: Design documents from `docs/specs/004-gpu-pass-fusion/` + +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, and all files in `contracts/` + +**Tests**: Required. Add each behavior or contract test before its production implementation and confirm that it fails for the intended missing behavior. The pinned-SHA baseline characterization tasks were withdrawn with the evidence tree (T005–T007, T016, T019, T020, T114, T115, T123); parity against target-main is evidenced by the same-process fusion A/B harness on normal CI and by the out-of-tree differential harness, neither of which needs a committed device-specific reference. + +**Organization**: Tasks are grouped by user story. US1, US2, and US3 are all P1, but their implementation order is US3 → US2 → US1 because the renderer-wide fusion proof requires the recorder migration and canonical Shader/Geometry descriptions first. US4 and US5 can proceed in parallel after US1. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel with adjacent marked tasks because it owns different files and has no incomplete dependency. +- **[Story]**: Maps the task to one user story from `spec.md`. +- **`[~]`**: Marks a task withdrawn with the evidence tree (see T123); it was not delivered. +- Every task names the exact file or files it changes. + +## Phase 1: Setup (Shared Test and Evidence Infrastructure) + +**Purpose**: Establish the non-friend API gate, deterministic visual-evidence utilities, and benchmark workload scaffolding before production behavior changes. + +- [X] T001 Create the non-friend NUnit project with only public project references and no `InternalsVisibleTo` dependency in `tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj` and `tests/Beutl.PublicApiContractTests/PublicApiContractTestBase.cs` +- [X] T002 Register `tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj` in `Beutl.slnx` +- [X] T003 [P] Add immutable linear-premultiplied RGBA16F read/write, SHA-256, SSIM, linear RGB MAE, alpha MAE, edge-band local-MAE, and per-channel maximum-error helpers in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStore.cs` and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetrics.cs` +- [X] T004 [P] Add fixed-seed scene definitions reusable by baseline and feature benchmarks in `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkScenes.cs` and `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs` + +--- + +## Phase 2: Foundational Evidence and Request Primitives (Blocking Prerequisites) + +**Purpose**: Freeze target-main behavior and add shared value, request-resource, and planning primitives without changing scheduling decisions. + +**Gate**: The request, resource, and planning primitives in this phase precede any renderer scheduling change. The frozen-baseline half of the original gate was withdrawn with the evidence tree (T005–T007, T016, T019, T020, T123): no pinned device-specific reference is committed, and output parity is evidenced instead by the same-process fusion A/B harness and the out-of-tree differential harness. + +- [~] T005 **Retired** with the evidence tree (see T123). The starting-SHA baseline generator existed to produce `docs/specs/004-gpu-pass-fusion/evidence/`, which is not part of the repository, so there is nothing for the generator or the paired visual runner to populate. `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` carries the surviving parity evidence: a same-process fusion-disabled/enabled A/B with the fixed per-channel AA edge maximum error of `0.02`, exercised by `WholeSourceHeadFusionParityTests` and `GpuPassFusionScaleRegionTests` on normal CI. +- [~] T006 **Retired** with the evidence tree (see T123). The pinned RGBA16F references and fingerprinted manifest would live under `docs/specs/004-gpu-pass-fusion/evidence/target-baseline/`, which is not part of the repository. Output parity against target-main is instead measured out-of-tree by the differential harness, which renders the whole corpus on both builds of the same machine and therefore needs no committed device-specific blob. +- [~] T007 [P] **Retired** with the evidence tree (see T123). The hash-integrity and non-vacuity tests verify T005/T006 artifacts that do not exist, and a committed foreign-device blob was explicitly ruled out as a CI oracle. `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` carries the surviving parity evidence: a same-process fusion-disabled/enabled A/B with the fixed per-channel AA edge maximum error of `0.02`, exercised by `WholeSourceHeadFusionParityTests` and `GpuPassFusionScaleRegionTests` on normal CI. +- [X] T008 [P] Add direct evidence tests for immutable compiled-plan topology, boundary reasons, shader-run membership, cache substitutions, and component-local structural-plan/program-cache/target-pool statistics in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ExecutionIslandPlannerCacheBoundaryTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs` +- [X] T009 Keep evidence read-only and component-owned by exposing only immutable compiled-plan data and local plan/program/pool statistics to friend tests; add no completed-request snapshot or event recorder in `src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/StructuralPlanCache.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetPool.cs` +- [X] T010 [P] Add failing unit tests for `RenderValueCardinality`, `TargetRegion`, `RenderBoundsContract`, and feature-003 scale-helper validation in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderContractPrimitiveTests.cs` +- [X] T011 Implement `RenderValueCardinality`, `TargetRegion`, `RenderBoundsContract`, and the relocated scale helpers in `src/Beutl.Engine/Graphics/Rendering/RenderValueCardinality.cs`, `src/Beutl.Engine/Graphics/Rendering/Operations/TargetRegion.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderBoundsContract.cs`, and `src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs` +- [X] T012 [P] Add failing ownership tests for owned/borrowed resource registration, duplicate/conflicting registrations, key/version coalescing, null-key isolation, and exact-once discharge in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderResourceOwnershipTests.cs` +- [X] T013 Implement `RenderResource` and `RenderResourceIdentity` with request-family registration and ownership states in `src/Beutl.Engine/Graphics/Rendering/Operations/RenderResource.cs` +- [X] T014 [P] Add failing request-model tests for option sanitization, internal fusion-mode inheritance/plan identity, lifecycle transitions, fragment/value ID uniqueness, authored order, provenance, and cache-candidate recording in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderRequestModelTests.cs` +- [X] T015 [P] Add failing request-owner tests for strict LIFO cleanup, continued cleanup after an individual fault, first-primary/secondary-failure aggregation, and exact discharge versus cache-transfer ownership in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderRequestOwnerTests.cs` +- [~] T016 [P] **Retired** with the evidence tree (see T123). Probe neutrality is a property of the baseline evidence probes; no evidence observer is wired into production frame or request execution, so there is no probe whose neutrality could be violated. +- [X] T017 Implement immutable request options including internal production-enabled/friend-test-selectable `FusionMode`, request lifecycle state, fragment/value IDs, provenance, cache candidates, and the ordered graph container in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOptions.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequest.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs` +- [X] T018 Implement the shared request owner, reverse-order best-effort cleanup, primary/cleanup failure aggregation, and cache-transfer discharge in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOwner.cs` +- [~] T019 **Retired** with the evidence tree (see T123). Both named artifacts belong to the withdrawn baseline harness. The constraint the task protected — no evidence observer in production frame or request execution — holds by construction and is covered by T009, which keeps evidence read-only and component-owned. +- [~] T020 **Retired** with the evidence tree (see T123). The report records the command, source state, fingerprint and artifact hashes of a baseline run that is no longer produced. + +**Checkpoint status**: Not satisfied by the committed artifacts on this branch. The same-process parity harness survived, but the pinned target pixels, integrity/non-vacuity tests, workload-shape record, and visual-evidence runners did not; target pixel parity is therefore not reproducible from this branch. + +--- + +## Phase 3: User Story 3 — Render-Node Authors Record Through One Context (Priority: P1) + +**Goal**: Replace executable/disposable operations with transaction-scoped recording, migrate every in-tree author and consumer, and retain correct rendering through conservative compatibility islands with fusion disabled. + +**Independent Test**: From the non-friend project, implement every required node shape; verify order, target scope, metadata, cardinality, contribution, eligibility, ownership, and high-level renderer results; then prove every `Process` call performs zero GPU/media/allocation/readback/nested-execution work. + +### Tests for User Story 3 — write and observe failures first + +- [X] T021 [P] [US3] Add a source census over compiled `src/**/*.cs` and `tests/**/*.cs` only that fixes the starting baseline at 29 production and 7 test `Process` overrides and rejects returning overrides, `RenderNodeOperation`, operation factories, `Pull`/`PullToRoot`, list rasterization, `SetOperations`, operation-backed `EffectTarget`, isolated nested processors, independent cache pulls, unclassified raw callbacks, and references to the four removed `RenderNodeContext` scale helpers in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderPipelineMigrationCensusTests.cs`. The census retains an explicit exclusion for `docs/specs/004-gpu-pass-fusion/evidence/target-baseline-generator.patch`, but that exclusion is inert because the patch was never committed on this branch. +- [X] T022 [P] [US3] Add non-friend tests for no-output, pass-through, source/materialized input, opacity/mask/blend, opaque map/combine/expand, contribution, fan-out rejection, custom scale, `RenderScaleUtilities`, Own/Borrow, all applicable cardinalities, and all non-Shader/Geometry `CanBeUsedAsValueInput` rows in `tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs` +- [X] T023 [P] [US3] Add non-friend tests for guarded/raw target commands, target/input readback declarations, resource-slot binding, empty-query hit-test rejection, and target domains in `tests/Beutl.PublicApiContractTests/TargetAuthoringContractTests.cs`; the rest of this contract is proved by its neighbours — capture plus `ContributeValues` and target scopes in `tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs`, symbolic `TargetLayerScope` and finite `Layer` in `tests/Beutl.PublicApiContractTests/OrphanedTargetEffectContractTests.cs`, `tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs`, and `tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs`, painter order by T068's `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs`, and the declared resampling of a public output-derived-density `TargetCapture` inside a denser finite scope by T095's `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs`. The empty-`InputSupplies` proof is for a `RenderScaleContract.Custom` resolver, not a `TargetCapture` one, and lives in `tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs` +- [X] T024 [P] [US3] Add non-friend tests for `RenderNodeRenderer` option sanitization, render/measure/hit-test, command/capture measurement flags, separate output/query bounds, shifted raster bounds, ordinary empty rasterization, bitmap ownership, target factory ownership/validation, and disposal in `tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs` +- [X] T025 [P] [US3] Add transaction tests for atomic publication, rollback, monotonic cache disablement, nested facade remapping, direct/indirect/separate-target recursion, resource cleanup, and retained context/handle rejection in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NodeRecordingTransactionTests.cs` +- [X] T026 [P] [US3] Add recording probes covering GPU context, target factory, snapshots, media reads/decodes, nested renderers, flush/synchronization, readback, and all migrated node shapes in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs` +- [X] T027 [P] [US3] Add target-token ordering tests for `SnapshotBackdrop -> Clear -> DrawBackdrop` at the root and inside Blend, transform, and filter scopes, requiring exactly one capture, no implicit capture contribution, Clear between capture and draw, and the later draw to consume exactly the captured snapshot in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs` +- [X] T028 [P] [US3] Add HeadlessUI tests proving selected-drawable `Measure().OutputBounds` agrees with the paired `Rasterize` result for shifted and empty output and that the caller owns/disposes the rasterization in `tests/Beutl.HeadlessUITests/SelectedDrawableRenderTests.cs` +- [X] T029 [P] [US3] Add HeadlessUI tests for DrawableBrush thumbnail rendering, update propagation, and disposal, using the existing GPU gate only where the real thumbnail backend requires it, in `tests/Beutl.HeadlessUITests/DrawableBrushThumbnailTests.cs` + +### Implementation for User Story 3 + +- [X] T030 [US3] Change `RenderNode.Process` to `void`, introduce sealed non-executable handles, make contexts engine-created/sealed, and implement explicit publication/cardinality/contribution/eligibility in `src/Beutl.Engine/Graphics/Rendering/RenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, and `src/Beutl.Engine/Graphics/Rendering/RenderFragmentHandle.cs` +- [X] T031 [US3] Implement checkpointed node transactions, owner validation, child facade remapping, atomic commit/rollback, handle invalidation, and cache-disable rollback in `src/Beutl.Engine/Graphics/Rendering/Planning/NodeRecordingTransaction.cs` and `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs` +- [X] T032 [P] [US3] Implement guarded opaque source/map/combine/expand and materialized-input descriptions with explicit topology, bounds, hit-test, scale, resource, and runtime identities in the internal `src/Beutl.Engine/Graphics/Rendering/Operations/OpaqueRenderDescription.cs` behind the public `OpaqueRenderDefinition`/`OpaqueRenderCall` in `src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs`, and in the public `src/Beutl.Engine/Graphics/Rendering/Operations/MaterializedInputDescription.cs` +- [X] T033 [P] [US3] Implement target command, capture, guarded scope, raw scope/command, finite Layer, and symbolic TargetLayerScope descriptions in the internal `src/Beutl.Engine/Graphics/Rendering/Operations/TargetCommandDescription.cs` and `src/Beutl.Engine/Graphics/Rendering/Operations/TargetScopeDescription.cs` behind the public target `*Definition`/`*Call` pairs in `src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs`, and in the public `src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureDescription.cs` +- [X] T034 [US3] Implement active-token-guarded `RenderExecutionInput`, callback canvas capability checks, output/session lifetimes, declared readback, no-flush close, and composition-global shifted-origin mapping in `src/Beutl.Engine/Graphics/Rendering/RenderExecutionInput.cs`, `src/Beutl.Engine/Graphics/Rendering/Operations/RenderCallbackCanvas.cs`, and `src/Beutl.Engine/Graphics/ImmediateCanvas.cs` +- [X] T035 [US3] Implement same-request `RecordNode`/`RecordSubtree`, request-family active-node cycle detection, child publication remapping, and nested request declarations in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs` +- [X] T036 [US3] Implement ordered fragment/value recording and scope-local target-token lowering for root, finite Layer, non-empty/empty TargetLayerScope, target commands, captures, and typed/raw scopes in `src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs` +- [X] T037 [US3] Implement the fusion-disabled compatibility compiler/executor and disposable high-level render/rasterize/measure/hit-test facade in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs`, and `src/Beutl.Engine/Graphics/Rendering/RenderNodeRasterization.cs` +- [X] T038 [P] [US3] Migrate pass-through, drop, transform/clip, opacity/blend, and Layer/Push nodes to typed context recording in `src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs`, `src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs`, and `src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs` +- [X] T039 [P] [US3] Migrate geometry, shape, text, image, video, and both drawable-group source overrides to deferred typed/opaque recording; use one engine-internal plain non-capturing draw callback over ordinary `ImmediateCanvas`, `Brush.Resource?`, `Pen.Resource?`, and author-stable state while keeping fill/pen declarations in request cache identity in `src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs`, and `src/Beutl.Engine/Graphics/DrawableGroup.cs` +- [X] T040 [P] [US3] Migrate clear, snapshot/draw backdrop, and opacity-mask nodes to ordered command/capture/scope records; satisfy every root and nested `BackdropOrderingTests` sequence; add built-in typed backdrop binding; register captured mask brushes through ordinary request resources; resolve brush shaders only after the execution session is active; materialize nested DrawableBrush content through the executor hook; and classify unknown/custom backdrop hooks as raw external work in `src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs`, and `src/Beutl.Engine/Graphics/BrushConstructor.cs` +- [X] T041 [P] [US3] Migrate filter, referenced-child, operation-wrapper, NodeGraph output, and ProjectSystem scene bridges to request-local recording without retaining handles in `src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs`, `src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs`, and `src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs` +- [X] T042 [P] [US3] Migrate audio visualizer, particle, and 3D overrides to declared raw/opaque/backend records without execution in `Process` in `src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs`, `src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs`, and `src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs` +- [X] T043 [P] [US3] Migrate only the seven test-local `Process` overrides to the void recording contract in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeProcessorExceptionSafetyTests.cs` (renamed to `RenderNodeRendererExceptionSafetyTests.cs` by the `RenderNodeProcessor` removal in T051), `tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs`, and `tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs`; the remaining old-API test consumers are owned by the following migration tasks +- [X] T044 [P] [US3] Migrate the nine render-node authoring suites from executable operations to recording/rasterization contracts in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ClearRenderNodeTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/EllipseRenderNodeTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectRenderNodeTest.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryRenderNodeTest.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectangleRenderNodeTest.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/VideoSourceRenderNodeTest.cs` +- [X] T045 [P] [US3] Migrate the ten execution, hit-test, scale, and cross-project suites from old pull/operation APIs while preserving their existing assertions in `tests/Beutl.UnitTests/Engine/Graphics/Backend/BackdropScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestParityTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics3D/Scene3DRenderNodeScaleTests.cs`, `tests/Beutl.UnitTests/Engine/TextBlockTests.cs`, `tests/Beutl.UnitTests/Graphics/ProxyVideoLogicalSizeTests.cs`, and `tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs` +- [X] T046 [US3] Migrate `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GoldenImageHarness.cs` from `PullToRoot` and list results to one caller-owned `RenderNodeRasterization`, preserving union/raster logical origin and output density. All 18 existing golden consumers kept their assertions, but not all of them stayed consumers: the same commit rewrote `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/RenderScaleBenchmarkTests.cs` onto its own render session, so it no longer uses the harness at all and its `HalfScale_IsMateriallyFaster` test became `HalfScale_IsSignificantlyFaster` +- [X] T047 [US3] Migrate or retire every production scale-helper caller with a member/type rename only and preserve all feature-003 formulas and clamping behavior in `src/Beutl.Editor/Models/SaveFrameScale.cs`, `src/Beutl.Engine/Graphics/BrushConstructor.cs`, `src/Beutl.Engine/Graphics/ImmediateCanvas.cs`, `src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs`, `src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs`, `src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs`, `src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs`, `src/Beutl.Engine/Graphics/Rendering/Renderer.cs`, `src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs`, `src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs`, `src/Beutl/Helpers/ExportSupersampling.cs`, `src/Beutl/ViewModels/Dialogs/SaveFrameDialogViewModel.cs`, and `src/Beutl/ViewModels/Tools/OutputViewModel.cs` +- [X] T048 [US3] Migrate every test scale-helper caller to `RenderScaleUtilities` while preserving feature-003 assertions verbatim in `tests/Beutl.UnitTests/Editor/ExportSupersamplingTests.cs`, `tests/Beutl.UnitTests/Editor/SaveFrameScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomTargetClampConsistencyTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs`, and `tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs` +- [X] T049 [US3] Migrate Engine pull/raster/cache/thumbnail/texture/canvas consumers to `RenderNodeRenderer` or same-request recording in `src/Beutl.Engine/Graphics/Rendering/Renderer.cs`, `src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs`, `src/Beutl.Engine/Graphics/SourceVideo.Thumbnails.cs`, `src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs`, and `src/Beutl.Engine/Graphics/ImmediateCanvas.cs` +- [X] T050 [P] [US3] Migrate NodeGraph, AgentToolkit, and application query/preview/player consumers to high-level render/measure/hit-test/rasterize ownership; make `PlayerViewModel` use `Measure().OutputBounds` for its selected-drawable measure/raster pair, make `MeasureNode` and `QueryTools` use `Measure().QueryBounds`, and update `QueryTools` runtime `MeasurementNote`/coordinate strings to describe contributing query fragments rather than operations in `src/Beutl.NodeGraph/Nodes/Utilities/MeasureNode.cs`, `src/Beutl.NodeGraph/Nodes/Utilities/PreviewNode.cs`, `src/Beutl.AgentToolkit/Tools/QueryTools.cs`, `src/Beutl/Helpers/AvaloniaTypeConverter.cs`, and `src/Beutl/ViewModels/PlayerViewModel.cs` +- [X] T051 [US3] Remove executable `RenderNodeOperation` and public `RenderNodeProcessor`, delete operation retention and operation-backed `EffectTarget` members, remove every scale-helper shim after all explicit caller migrations, and update `EffectiveScale` provenance XML documentation from operations to recorded fragments/values in `src/Beutl.Engine/Graphics/Rendering/RenderNodeOperation.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs`, `src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs`, `src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, and `src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs` +- [X] T052 [US3] Run the migration census, public authoring contracts, transaction/recording-side-effect/backdrop-ordering suites, every starting-SHA direct old-API test scan hit plus the explicitly named adjacent authoring suites, the standalone golden harness with the 17 original consumers that still use it, and both HeadlessUI consumer suites from `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderPipelineMigrationCensusTests.cs`, `tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NodeRecordingTransactionTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs`, `tests/Beutl.HeadlessUITests/SelectedDrawableRenderTests.cs`, and `tests/Beutl.HeadlessUITests/DrawableBrushThumbnailTests.cs` + +**Checkpoint**: Every production/test override and direct consumer uses one recording context; compatibility rendering matches the frozen baseline with fusion disabled; no executable-operation escape or recording-time side effect remains. + +**Withdrawn** with the evidence tree (see T123). No pinned reference, manifest or generator is committed under `docs/specs/004-gpu-pass-fusion/`, so nothing can be compared against a frozen baseline. The delivered substitute is the same-process fusion-disabled/enabled A/B in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` on normal CI, plus the out-of-tree differential harness. The passage is kept as the record of the original method. + +--- + +## Phase 4: User Story 2 — Existing Effect Authors Keep Their Workflow and Can Opt In (Priority: P1) + +**Goal**: Preserve `FilterEffect.ApplyTo` and all existing ordered context items while adding shared deferred Shader and Geometry descriptions to both authoring contexts. + +**Independent Test**: Compile and render an unchanged plugin-style effect in the non-friend assembly, then author Shader and Geometry effects using public API only and verify order, bounds, deferred execution, rollback, resource ownership, and unfused planner participation. + +### Tests for User Story 2 — write and observe failures first + +- [X] T053 [P] [US2] Add a non-friend unchanged `ApplyTo` source/render compatibility test covering existing color, Skia, transform, group, and legacy custom item order in `tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs` +- [X] T054 [P] [US2] Add non-friend CurrentPixel and WholeSource Shader authoring, uniform/resource binding shape, captured-binder rejection, input eligibility/rejection, and the `ShaderDescription_IsNotPartOfTheExternalAuthoringSurface` assertion that keeps the internal description off the authoring surface in `tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs`; eligible Shader → Opacity → Shader propagation is proved by T069's `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs`, the explicit analytic/antialiased coverage boundary by T095's `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs`, and unfused fallback by T096's `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs` +- [X] T055 [P] [US2] Add non-friend Geometry authoring tests for input eligibility/rejection, zero-or-one mapping, bounds/hit-test contracts, declared resources/readback, shrink/discard, and retained-facade rejection in `tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs` +- [X] T056 [P] [US2] Add `FilterEffectContext` tests for synchronous bounds updates, mixed legacy/new ordering, invalid/throwing append rollback, nested group rollback, clone/child semantics, and exact-once resource cleanup in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/FilterEffectRecordingTransactionTests.cs` +- [X] T057 [P] [US2] Add Shader lexer/validator/binding tests for restricted CurrentPixel grammar, coordinates, declarations, names/types, canonical unmanaged values, request-unique binders, resource spaces, full equality after hash collision, and the rule that coordinate independence alone does not prove premultiplied-coverage homogeneity in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderDescriptionTests.cs` +- [X] T058 [P] [US2] Add Geometry session tests for transparent initialization, canonical shifted device bounds, composition-global mapping, one-shot canvas/input/readback use, capability violations, shrink/discard, and no close-induced flush in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/GeometrySessionTests.cs` + +### Implementation for User Story 2 + +- [X] T059 [P] [US2] Implement normalized SkSL source storage, restricted CurrentPixel validation, post-upstream-coverage CurrentPixel semantics, WholeSource form, bounds/color contracts, and full structural comparison with no public coverage-homogeneity assertion flag in `src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs` and the internal `src/Beutl.Engine/Graphics/FilterEffects/ShaderDescription.cs` behind the public `ShaderDefinition`/`ShaderCall` in `src/Beutl.Engine/Graphics/FilterEffects/ShaderDefinitionCalls.cs` +- [X] T060 [P] [US2] Implement canonical direct uniform values, custom uniform/resource binders, coordinate spaces, structural/runtime identities, scoped writers, and execution context in `src/Beutl.Engine/Graphics/FilterEffects/ShaderBindings.cs` +- [X] T061 [P] [US2] Implement deferred Geometry description, mandatory bounds/hit-test/readback/resource declarations, callback-scoped session, and zero-or-one output control in the internal `src/Beutl.Engine/Graphics/FilterEffects/GeometryDescription.cs` behind the public `GeometryDefinition`/`GeometryCall` in `src/Beutl.Engine/Graphics/FilterEffects/GeometryDefinitionCalls.cs`, and in the public `src/Beutl.Engine/Graphics/FilterEffects/GeometrySession.cs` +- [X] T062 [US2] Add atomic ordered `Shader`, `Geometry`, `Own`, and `Borrow` recording while preserving every existing `ApplyTo` member and synchronous `Bounds` semantics in `src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs` and `src/Beutl.Engine/Graphics/FilterEffects/FilterEffect.cs` +- [X] T063 [US2] Lower existing color/Skia/transform items when equivalence is proven and lower new Shader/Geometry plus retained custom work into typed or `LegacyCustomEffect` islands in `src/Beutl.Engine/Graphics/FilterEffects/FEImpl.cs`, `src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs`, and `src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs` +- [X] T064 [US2] Implement the ordinary unfused 2D Shader execution and runtime binding path, including explicit validation/program/binder failures and deferred native child creation, in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` and `src/Beutl.Engine/Graphics/FilterEffects/ShaderBindings.cs` +- [X] T065 [US2] Implement the Geometry execution island with standard working density, transparent output initialization, optional input readback, shrink/discard validation, and exact cleanup in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` and `src/Beutl.Engine/Graphics/FilterEffects/GeometrySession.cs` +- [X] T066 [US2] Restrict `EffectTarget` to execution-time materialized targets while preserving legacy `CustomFilterEffectContext` behavior and marking its uninspectable work external in `src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs`, `src/Beutl.Engine/Graphics/FilterEffects/EffectTargets.cs`, and `src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs` +- [X] T067 [US2] Run the unchanged ApplyTo, Shader, Geometry, bounds/rollback, and legacy custom-effect suites in `tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs`, `tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs`, `tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectCrashSafetyTests.cs` + +**Checkpoint**: Ordinary effect source remains compatible; Shader and Geometry are public deferred opt-ins on the old `ApplyTo` lifecycle; no effect callback performs rendering during recording. + +--- + +## Phase 5: User Story 1 — Faster Complete 2D Rendering Without Visual Changes (Priority: P1) 🎯 MVP + +**Goal**: Record all target-surface roots before execution and fuse a distinct CurrentPixel Shader → Opacity render node → CurrentPixel Shader chain into one compatible GPU pass without visual regression. + +**Independent Test**: Keep the three stages as distinct nodes, compare the fusion-disabled result to the frozen baseline, then require one compiled GPU-pass island with one fused shader run, at most one intermediate, no per-stage synchronization probe, warmed program/target reuse, and all visual/non-vacuity thresholds with fusion enabled. + +**Withdrawn** with the evidence tree (see T123). No pinned reference, manifest or generator is committed under `docs/specs/004-gpu-pass-fusion/`, so nothing can be compared against a frozen baseline. The fusion-disabled result is compared against fusion-enabled execution in the same process and device instead, through `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` on normal CI, with the out-of-tree differential harness covering parity against target-main. The passage is kept as the record of the original method. + +### Tests for User Story 1 — write and observe failures first + +- [X] T068 [P] [US1] Add complete-target request tests proving every tree is updated and all top-level roots, root clear, target commands, captures, and painter ordering are recorded before any planner-controlled 2D execution; mark hardware execution cases `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs` +- [X] T069 [P] [US1] Add the distinct-node Gamma CurrentPixel Shader → OpacityRenderNode → Invert CurrentPixel Shader golden from a deterministic materialized semitransparent source, comparing internal `FusionMode.Disabled` and `Enabled` in the same process/device, with non-vacuity, eligibility, direct compiled-plan one-pass assertions, test-owned materialization/synchronization probes, warmed program/target statistics, plan-identity isolation, and `[Category("GpuPassFusionGpu")]` assertions in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs` +- [X] T070 [P] [US1] Add exact same-process internal `FusionMode.Disabled`-versus-`Enabled` split and parity tests for analytic/antialiased coverage, WholeSource, Geometry, opaque callback, readback, destination-dependent Blend, dynamic expansion, external/materialized input, cache boundary, 3D/backend transition, and backend Shader-limit barriers; use `return color * color.a;` after an antialiased thin stroke for the edge-focused/max-error coverage control, prove the exact materialization boundary, and mark hardware cases `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs` + + *Amended (`991f49e70`).* The WholeSource entry became a one-directional barrier — read it as WholeSource-as-successor. `WholeSourceStagesStartRunsAndNeverBecomeSuccessors` asserts that a WholeSource stage *starts* a fused run and only refuses to become a successor, and `FusionDisabled_KeepsWholeSourceInACompatibilityIsland` shows the old whole-pass shape surviving under `FusionMode.Disabled`. Same change as the T076 note. + +- [X] T071 [P] [US1] Add token-aware merge tests for identifier isolation, functions/constants/arrays, binding layout, stage/uniform/sampler/child/source limits, deterministic splits, hash collisions, stage order, and coverage-homogeneity metadata; mark hardware execution cases `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslSnippetMergerTests.cs` + +### Implementation for User Story 1 + +- [X] T072 [US1] Change production frame sequencing to build every tree, record one ordered request for the target surface, execute once, and commit bounds/render counts/cache state only after success in `src/Beutl.Engine/Graphics/Rendering/Renderer.cs` and `src/Beutl.Engine/Graphics/Rendering/GraphicsContext2D.cs` +- [X] T073 [US1] Resolve request-wide root provenance, ordered publications, separate output/query metadata, and scope-token dependencies before planning in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs` +- [x] T074 [US1] Partition maximal dependency-consistent islands with explicit boundary reasons, honor internal `FusionMode.Disabled` without changing semantic lowering, and preserve value/target order, scope, contribution, cardinality, cache, backend, and synchronization contracts in `src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs` +- [X] T075 [P] [US1] Implement lexer/token-aware CurrentPixel snippet composition, symbol renaming, binding-layout merge, and deterministic backend-budget splitting in `src/Beutl.Engine/Graphics/FilterEffects/SkslSnippetMerger.cs` +- [X] T076 [US1] Compile eligible CurrentPixel and invariant-opacity stages into `CompiledShaderRun` records while keeping analytic/antialiased coverage-producing source boundaries, WholeSource, Geometry, opaque, readback, target, cache, dynamic, external, and backend work as barriers unless an engine-known stage is mechanically proven premultiplied-coverage-homogeneous in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs` + + *Amended (`991f49e70`).* WholeSource left the unconditional barrier list after this task was written. A validated WholeSource stage may now lead a fused run — the merger keeps the head's own uniform shader source, renames only its `main`, and appends downstream CurrentPixel stages — and `CompiledShaderRun.WholeSourceHead` records which run it heads. It remains a barrier in one direction only: folding work *upstream* of a whole-source shader is still rejected because its sampling cannot be proven equivalent, and `FusionMode.Disabled` keeps the previous whole-pass shape so the same-process parity harness still proves the change. Every other barrier in this list is unchanged. `spec.md` (the FR-021 and FR-026 notes) carries the current contract. + +- [X] T077 [US1] Implement full-key program lookup, merged program creation, re-entrant leases, runtime binding reset, and warmed hit accounting in `src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs` +- [X] T078 [US1] Execute compiled islands once in dependency/painter order, bind final runtime values after plan selection, and avoid implicit per-stage materialization/flush in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T079 [US1] Canonicalize eligible `OpacityRenderNode` output into the CurrentPixel run only when value eligibility, scope-token equivalence, color/alpha behavior, premultiplied-coverage homogeneity, and ordering are engine-proven in `src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs` +- [X] T080 [US1] Make primary and barrier execution shape directly testable through immutable `ExecutionIslandPlan`/compiled shader-run data, component-local program/pool statistics, and test-owned executor probes without adding production request events in `src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T081 [US1] Run the primary fusion, complete-request ordering, merger, and boundary suites in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs` + +**Checkpoint (MVP)**: Renderer-wide recording is active, the distinct cross-node chain renders in exactly one pass with parity, and every required barrier splits deterministically. + +--- + +## Phase 6: User Story 4 — Animation and Render Caching Remain Efficient and Correct (Priority: P2) + +**Goal**: Reuse structural plans, programs, output-cache values, and exact-size intermediates across stable requests while invalidating every pixel-affecting change safely. + +**Independent Test**: Render 100 parameter-only frames, then one structural change; verify one structural compilation, no program creation after frame 1, one affected replacement compilation, correct component-local cache/pool statistics, and parity with a fresh uncached render. + +### Tests for User Story 4 — write and observe failures first + +- [X] T082 [P] [US4] Add 100-frame parameter animation, bounds-only runtime change, structural toggle, direct uniform, custom binder cache-policy, and full-key collision tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs` +- [X] T083 [P] [US4] Add parent/child hit selection, parent supersession, command/raw/target-dependent bypass, coverage/density/format/purpose/device invalidation, and static-prefix/animated-tail tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs` +- [X] T084 [P] [US4] Add stable/changing-size pool, 3-stage versus 10-stage peak live, fan-out last use, LRU/byte/idle eviction, generation, stale/double release, and context recreation tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs` +- [X] T085 [P] [US4] Add renderer-disposal and cache-publication ownership tests proving accepted factory targets, plans, and programs are released while root/cache/factory/raster results remain borrowed or independently owned in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderNodeRendererLifetimeTests.cs` + +### Implementation for User Story 4 + +- [X] T086 [US4] Implement complete structural identity including internal fusion mode, parameter-independent plan reuse, full comparison after hash bucketing, and one affected replacement on mismatch in `src/Beutl.Engine/Graphics/Rendering/Planning/StructuralPlanCache.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs` +- [X] T087 [US4] Resolve render-cache candidates only after graph/region discovery, preserve provenance and token edges, select parent/child boundaries, and stage successful miss captures in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderCacheResolver.cs` +- [X] T088 [P] [US4] Add program-cache byte/LRU/device eviction, re-entrant lease safety, runtime reset, and full source/signature equality to `src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs` +- [X] T089 [P] [US4] Implement renderer-owned exact-size RGBA16F buckets, factory validation, byte/LRU/idle/context eviction, generation tags, and exact lease states in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetPool.cs` +- [X] T090 [US4] Compute first/last-use intervals, exact target reuse, fan-out lifetimes, peak-live accounting, and cache-transfer discharge in `src/Beutl.Engine/Graphics/Rendering/Planning/ResourcePlanUseSchedule.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T091 [US4] Publish cache captures atomically after complete request success and transfer accepted payload ownership from the request/pool to the existing cache lifecycle in `src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCache.cs`, `src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T092 [US4] Run the animation, cache selection, pool lifetime, static-prefix parity, and renderer-disposal suites in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs` + +**Checkpoint**: Stable warmed frames allocate no new targets or programs, structural/runtime identities invalidate at the correct layer, and cache ownership transfers reconcile exactly. + +--- + +## Phase 7: User Story 5 — Scales, Regions, Fallbacks, and Boundaries Stay Correct (Priority: P2) + +**Goal**: Complete post-record bounds/ROI analysis, preserve feature-003 density behavior, execute every public Shader on the supported fallback backend, and isolate 3D as one explicit materialized boundary. + +**Independent Test**: Compare bounds, densities, schedules, and images across multiple scales, shifted/outside/empty regions, target-domain forms, 3D input, and the supported non-preferred backend using the frozen baseline. + +**Withdrawn** with the evidence tree (see T123). No pinned reference, manifest or generator is committed under `docs/specs/004-gpu-pass-fusion/`, so nothing can be compared against a frozen baseline. The delivered substitute is the same-process fusion-disabled/enabled A/B in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` on normal CI, plus the out-of-tree differential harness. The passage is kept as the record of the original method. + +### Tests for User Story 5 — write and observe failures first + +- [X] T093 [P] [US5] Add shifted/outside/empty/full ROI, forward growth/shrink, full-input fallback, invalid mapping, fan-out union, target-read apron, and density-stability tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RegionAnalyzerTests.cs` +- [X] T094 [P] [US5] Add root and finite-Layer `[A, Clear, B]`, symbolic/empty TargetLayerScope, transformed Full resolution, missing target domain, output/query bounds, shifted raster, and empty raster tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs` +- [X] T095 [P] [US5] Add multi-density vector/bitmap/text, maximum working scale, 16,384-axis clamp, late device binding, shifted guarded callback canvas, and antialiased thin-stroke edge golden tests; compare output-derived `TargetCapture` resampling inside denser finite Layer/TargetLayerScope targets with public `PreserveTargetSupply` late binding (including the built-in backdrop) and verify the resulting density/cache identity; mark hardware cases `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs` +- [X] T096 [P] [US5] Add ordinary no-preferred-GPU Shader fallback tests that never self-skip plus hardware-gated 3D boundary/pass-shape tests marked `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs` and `tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs` +- [X] T097 [P] [US5] Add regression coverage for all existing feature-003 density/golden requirements and characterized preview/delivery allocation outcomes; mark hardware cases `[Category("GpuPassFusionGpu")]` in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs` + +### Implementation for User Story 5 + +- [X] T098 [US5] Implement forward metadata and reverse required-region propagation with explicit Full/Empty/finite states, unioned fan-out, conservative full-input fallback, target-read expansion, and invalid-map failures in `src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs` +- [X] T099 [US5] Resolve symbolic Full only after enclosing transform/clip/root/TargetLayerScope/finite-Layer domains are known, preserve empty order-only scopes, and reject target-less unresolved Full in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs` +- [X] T100 [US5] Keep `RootOutputExtent`, `QueryBounds`, `TargetDomain`, `RequestedRegion`, final commit crop, measurement, hit testing, and rasterization bounds independent in `src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs`, and `src/Beutl.Engine/Graphics/Rendering/RenderNodeRasterization.cs` +- [X] T101 [US5] Apply feature-003 working-density resolution and complete-bounds clamping during recording, preserve eligible stages until materialization while resolving vector/text/path coverage before arbitrary public CurrentPixel work, and bind cropped device values without recomputing density in `src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T102 [US5] Record 3D metadata without execution, render full declared 3D bounds into one RGBA16F value, count transition/synchronization, block inward 2D ROI, and allow eligible downstream 2D work after the boundary in `src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T103 [US5] Implement an unfused supported ordinary-2D path for every valid public Shader when preferred fusion/backend capability is unavailable while preserving explicit invalid-source/program failures in `src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T104 [US5] Run region, target-scope, scale/golden, fallback, feature-003, and 3D boundary suites in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RegionAnalyzerTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs`, and `tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs` + +**Checkpoint**: ROI and density remain correct for shifted/empty/full requests, fallback rendering works without a preferred GPU, and 3D is an explicit one-value boundary rather than part of the 2D optimizer. + +--- + +## Phase 8: User Story 6 — Maintainers Can Prove Whole-Request Improvement and Safety (Priority: P3) + +**Goal**: Prove planner-controlled execution shape and every owned-resource lifetime on success/failure, then produce provenance-locked visual/performance evidence using persistent production-equivalent lifetimes. + +**Independent Test**: Run deterministic correctness, failure, and paired baseline/feature benchmarks; assert compiled-plan topology, component statistics, execution probes, and final acquisition state; verify primary exceptions and cleanup behavior; require the primary warmed post/pre 95% confidence interval to lie below 1.0. + +**Withdrawn** in part with the evidence tree (see T114, T115, T123). The paired baseline/feature benchmark run, its analyzer and its confidence interval are not produced by any committed artifact, so the performance improvement is not asserted as an acceptance result; the checkpoint status at the end of this phase records that. The correctness, failure and compiled-plan halves of this test are delivered. + +### Tests for User Story 6 — write and observe failures first + +- [X] T105 [P] [US6] Add direct compiled-plan, target-pool lease, external-root, nested-request, opaque-external callback-entry, success/failure, and authored-order evidence tests in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs` +- [X] T106 [P] [US6] Add recording/ApplyTo/resource-transfer, Own/Borrow conflict, recursion, bounds/ROI, and cache lookup/substitution/staging/publication failure injection in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs` +- [X] T107 [P] [US6] Add materialization, target acquisition, Shader validation/merge/program/binding/provider, and program/pool disposal failure injection in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs` +- [X] T108 [P] [US6] Add Geometry/opaque/target/input readback, canvas open/close, dispose/snapshot/nested-draw/SaveLayer/undeclared-resource/hidden-allocation/hidden-flush, dynamic output, and retained-facade failure injection in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs` +- [X] T109 [P] [US6] Add nested request, 3D/backend transition, target command/capture/scope, raw callback, cache-transfer, cleanup-fault, primary-exception, and no-partial-publication failure injection in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs` + +### Implementation and Evidence for User Story 6 + +- [X] T110 [US6] Enforce execution-ledger completion, exact request-owner discharge after cleanup or cache transfer, no partial cache publication, and opaque callback-entry isolation directly in `src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOwner.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T111 [US6] Make recorder, analyzer, cache resolver, compiler, and executor preserve the first primary exception, mark dependent outcomes, reject partial output/cache publication, continue cleanup, and classify secondary failures in `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderCacheResolver.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs`, and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` +- [X] T112 [US6] Implement persistent-lifetime BenchmarkDotNet cases for no effect, one Shader, primary cross-node chain, hard barrier, long chain, parameter animation, structural toggle, static prefix, mixed spatial/color, small object, and multiple target-dependent roots in `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` +- [X] T113 [US6] Include output verification, compiled-plan shape, and applicable component-local plan/program/pool statistics in benchmark setup/results while keeping renderer/node/cache/pool lifetimes production-equivalent in `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs` and `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` +- [~] T114 [US6] **Retired** with the evidence tree (see T123). None of the paired-runner or target-harness artifacts is part of the repository. `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs`, `RenderPipelineBenchmarkConfig.cs` and `RenderPipelineBenchmarkScenes.cs` stay runnable on demand for the SC-008 workloads; the same-fingerprint paired comparison and its confidence interval are not produced, so the performance improvement is not asserted as a committed acceptance result. +- [~] T115 [US6] **Retired** with the evidence tree (see T123). The acceptance report aggregates T005–T007 and T114 outputs that are no longer produced. `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` carries the surviving parity evidence: a same-process fusion-disabled/enabled A/B with the fixed per-channel AA edge maximum error of `0.02`, exercised by `WholeSourceHeadFusionParityTests` and `GpuPassFusionScaleRegionTests` on normal CI. +- [X] T116 [US6] Run the direct execution-plan evidence and complete failure matrix suites in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs`, and `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs` + +**Checkpoint status**: The direct execution-plan, failure-matrix and parity suites are green, and the benchmarks are runnable on demand. The paired visual and benchmark evidence tree was deliberately withdrawn (T114, T115, T123), so pixel parity is evidenced by the same-process fusion A/B harness and the out-of-tree differential harness rather than by a committed manifest, and the performance improvement is not asserted as a committed acceptance result. + +--- + +## Phase 9: Polish and Cross-Cutting Validation + +**Purpose**: Update author guidance, audit the breaking public surface, and run repository-wide gates. + +- [X] T117 [P] Update filter-effect authoring guidance for `ApplyTo`, Shader, Geometry, opaque fallbacks, and the removed executable API in `.claude/skills/beutl-filter-effect/SKILL.md` and `docs/ai-workflow/resolution-independent-rendering.md` +- [X] T118 [P] Add or complete XML documentation and nullable contracts for the public `*Definition`/`*Call` authoring family and update `EffectiveScale` provenance language from executable operations to recorded fragments/values in `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderFragmentHandle.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs`, `src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs`, `src/Beutl.Engine/Graphics/FilterEffects/ShaderDefinitionCalls.cs`, `src/Beutl.Engine/Graphics/FilterEffects/GeometryDefinitionCalls.cs`, and `src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs` +- [X] T119 Run `dotnet format Beutl.slnx --verify-no-changes` against `Beutl.slnx` and resolve every reported source-file formatting finding +- [X] T120 Run dual-target `dotnet build Beutl.slnx` and resolve build/public-contract failures in `Beutl.slnx` and `tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj` +- [X] T121 Run `dotnet test Beutl.slnx -f net10.0 --settings coverlet.runsettings` and resolve all regressions in `Beutl.slnx` and `coverlet.runsettings` +- [X] T122 Run the ordinary fallback command on every host and the two hardware-required commands on a capable host: `dotnet test tests/Beutl.UnitTests/Beutl.UnitTests.csproj -f net10.0 --filter "FullyQualifiedName~ShaderFallbackTests"`, `BEUTL_REQUIRE_GPU=1 dotnet test tests/Beutl.UnitTests/Beutl.UnitTests.csproj -f net10.0 --filter "(TestCategory=GpuPassFusionGpu|FullyQualifiedName~GpuGoldenSuiteCanaryTests)"`, and `BEUTL_REQUIRE_GPU=1 dotnet test tests/Beutl.Graphics3DTests/Beutl.Graphics3DTests.csproj -f net10.0 --filter "TestCategory=GpuPassFusionGpu"`; the ordinary fallback command must never self-skip +- [~] T123 **Retired**. The gate verified committed raw results against `docs/specs/004-gpu-pass-fusion/evidence/acceptance-report.md`, and the evidence tree was dropped from the repository, leaving no artifact to verify against. `tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs` stays runnable on demand for the SC-008 workloads +- [X] T124 Run public-design, GPL/MIT, XAML, NUnit, and source-generator impact reviews against the complete diff using `.claude/agents/beutl-design-reviewer.md` and `.claude/agents/beutl-reviewer.md`, then resolve every in-scope finding in the affected source/test files +- [X] T125 Verified against HEAD. `docs/specs/004-gpu-pass-fusion/contracts/breaking-changes.md` now covers the final `Beutl.Engine` surface and the migrated `Beutl.Editor`, `Beutl.NodeGraph`, `Beutl.ProjectSystem`, `Beutl.AgentToolkit`, application, and downstream author call sites; every one of the sixteen `!` commits on the branch — not only `35e7f28b0` and `699332cc5` — retains its breaking Conventional Commit subject and a literal `BREAKING CHANGE:` footer. Seven of the commit SHAs quoted inside `contracts/breaking-changes.md` are pre-rewrite objects that are not ancestors of HEAD and resolve only from this worktree's dangling objects; that document owns their correction. +- [X] T126 Re-run the requirement-traceability audit against `docs/specs/004-gpu-pass-fusion/spec.md` and the matrix below, require every `FR-001` through `FR-044` (including every suffixed requirement) and every `SC-001` through `SC-013` to map to at least one concrete task ID, and fail completion on any unknown, duplicate, stale, or unmapped requirement identifier in `docs/specs/004-gpu-pass-fusion/tasks.md` +- [X] T127 Remove the feature-only paint-handle/paint-session family and four-way painted-source authoring surface; keep one engine-internal plain source callback over ordinary canvas/brush/pen values, preserve fill/pen request-resource declarations, resolve ordinary paint after session acquisition, and materialize nested DrawableBrush content through the executor hook in `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, `src/Beutl.Engine/Graphics/BrushConstructor.cs`, `src/Beutl.Engine/Graphics/ImmediateCanvas.cs`, and the built-in source nodes +- [X] T128 Remove request-wide diagnostic recording, cache-verification duplication, whole-request allocation preflight, live byte/target reservation, and recursive state-type allowlist rejection; retain compiled-plan invariants, component-local cache/pool statistics, per-allocation validation, liveness reuse, request-owner cleanup, non-capturing callback enforcement, and engine-owned complete field-wise state equality/hashing in `src/Beutl.Engine/Graphics/Rendering/Planning/`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs`, and the matching tests +- [X] T129 Restore custom filter-effect brush/pen execution to ordinary `Brush.Resource?`/`Pen.Resource?` data and the existing `BrushConstructor`/canvas draw path; remove feature-only brush registration/lowering APIs, and keep `FilterEffect.ApplyTo`, Shader/Geometry, custom-effect ordering, scale, and resource contracts intact in `src/Beutl.Engine/Graphics/FilterEffects/` and `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Effects.cs` +- [X] T130 Remove the feature-only `EngineObject.Resource` assignment/disposal gate and generated replacement helper, restore plain generated nested-resource assignment and the existing lifecycle, update source-generator snapshots, and run exact-symbol/census checks plus the affected build/test suites in `src/Beutl.Engine/Engine/EngineObject.cs`, `src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs`, `tests/SourceGeneratorTest/`, and `docs/specs/004-gpu-pass-fusion/` +- [X] T131 Restore main-equivalent serial built-in Blur execution by directly composing safe Skia filter segments over one destination, preserve cache/custom/cardinality/working-scale/ROI boundaries, add static-prefix and mixed CustomEffect regression coverage, and verify byte-identical RGBA16F output plus depth scaling against the same public-API harness on current main in `src/Beutl.Engine/Graphics/FilterEffects/`, `src/Beutl.Engine/Graphics/Rendering/`, `tests/Beutl.UnitTests/Engine/Graphics/Rendering/`, and `tests/Beutl.Benchmarks/Rendering/` + + *Amended (`d53b155e8`).* The byte-identity claim holds for unsheared bases only. `fix(engine)!: hold the filter apron a pixel from every sheared edge` landed after this verification and makes content under a sheared transform render differently — its layer is wider and keeps antialiased coverage that used to be clipped away — while leaving unsheared transforms unchanged bit-for-bit. Blur is the affected case whenever the filter's own margin is smaller than the shortfall. The committed exactness evidence is the in-branch suite in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/DirectSkiaFilterReplayTests.cs`, which compares against in-branch references rather than against a build of current main. `contracts/breaking-changes.md` ("A sheared filter layer keeps its perpendicular pixel") carries the migration. + +## Post-T131 Delivery + +*Amended.* The task list closes at T131 and was last edited by `d803801fb`, and the branch kept moving after both. The work below landed without a task ID; it is recorded here rather than retro-fitted as new task IDs, which would misrepresent when it was planned. `contracts/breaking-changes.md` is the migration contract for every breaking entry. + +- `991f49e70 perf(engine)!: widen GPU pass fusion beyond current-pixel colour shaders` widened the fusion envelope past the restricted CurrentPixel form: six built-in Skia colour filters now record CurrentPixel stages, a filter-effect segment derives its cardinality from its items instead of always declaring `Dynamic`, the Vulkan and Metal sampler/children budget rose to 12, `SKSLScriptEffect` records declaratively, and a WholeSource shader may lead a fused run. See the T070 and T076 notes. +- `87c746488 feat(engine): let external authors map backward render density`, renamed by `c8314e40f refactor(engine)!: name the supply map that drops backward demand`, made the public `RenderScaleContract.MapInputSupply` a two-callback forward/backward contract and kept the forward-only form as `MapInputSupplyPreservingDemand`. +- `1ecec3159 feat(engine): let a public FilterEffectActivator supply a drawable-brush materializer` published the `DrawableBrushMaterializer` seam and its image-ownership rule. +- Sixteen `!` commits sit on the branch in total, each with a literal `BREAKING CHANGE:` footer; T125 names only two of them. Eleven of the remaining fourteen are `fix(engine)!` rendering-correctness commits. Three breaking commits — `c8314e40f`, `449e71258` and `d53b155e8` — landed after this task list was last edited. +- Five benchmark scenes beyond T112's eleven: `SpatialGroupChain` and `SpatialNodeChain` (`6a09384d7`), and `StaticSpatialPrefixAnimatedBlurTail`, `LayerCustomEffect` and `BlurCustomBlur` (`b37927c8f`). + +## Requirement Traceability + +Every normative requirement and success criterion has a concrete test, implementation, evidence, or final-gate owner. T126 validates this matrix against `spec.md`; ranges are inclusive. + +*Amended.* That no longer holds for every row. Nine owners were withdrawn with the evidence tree, so the rows marking an owner `(retired)` below carry fewer committed owners than the sentence promises; the note under the table says which parts of FR-039, FR-043, SC-008 and SC-013 no longer have one. + +| Requirement | Concrete task owners | +|---|---| +| FR-001 | T068, T072 | +| FR-002 | T022, T032, T038–T042, T049 | +| FR-003 | T054, T063, T069, T074, T076 | +| FR-004 | T073, T074, T083, T087, T098 | +| FR-005 | T042, T070, T096, T102 | +| FR-006 | T014, T017, T035, T072, T083 | +| FR-007 | T021, T030, T043, T051 | +| FR-008 | T022, T030, T051, T118 | +| FR-009 | T022, T025, T030, T031, T036 | +| FR-010 | T022, T023, T032–T036 | +| FR-010a | T023, T027, T033, T036, T040, T094, T099 | +| FR-010b | T022, T023, T030, T054, T079 | +| FR-011 | T022, T032, T038–T042, T059, T061, T062 | +| FR-012 | T025, T026, T031, T040, T056, T062, T108, T127, T129 | +| FR-013 | T012, T013, T015, T018, T022, T025, T031, T085, T109, T130 | +| FR-014 | T025, T035, T041, T105, T109 | +| FR-015 | T022, T025, T030, T031, T083 | +| FR-016 | T021, T043–T052, T100, T118, T125 | +| FR-017 | T030, T041, T047, T117, T124, T125, T128 | +| FR-018 | T053, T056, T062, T066, T067, T129 | +| FR-019 | T001, T002, T054, T055, T059–T062 | +| FR-020 | T026, T056, T062, T064, T065 | +| FR-021 | T054, T057, T059, T060, T064, T069, T070, T076 | +| FR-022 | T055, T058, T061, T065, T070, T093, T098 | +| FR-023 | T053, T056, T063, T066, T070, T108, T129 | +| FR-024 | T012, T013, T056, T060, T061, T065, T106, T108 | +| FR-025 | T022, T054, T055, T059–T062 | +| FR-026 | T054, T069, T074, T076, T079, T081 | +| FR-027 | T057, T070, T074, T076, T079 | +| FR-028 | T071, T075, T076, T081 | +| FR-029 | T027, T068, T070, T073, T074, T078, T094, T100 | +| FR-030 | T010, T011, T023, T024, T028, T045, T047, T048, T050, T095, T097, T101, T118, T128 | +| FR-030a | T084, T089, T107, T128 | +| FR-030b | T023, T024, T095, T100 | +| FR-031 | T022, T054, T055, T093, T098 | +| FR-032 | T022, T023, T024, T033, T036, T094, T100 | +| FR-033 | T014, T017, T057, T071, T082, T083, T086–T088 | +| FR-033a | T083, T087, T091, T092 | +| FR-034 | T077, T082, T086, T088, T092 | +| FR-035 | T015, T018, T058, T078, T084, T089–T091, T105, T107–T111 | +| FR-036 | T069, T084, T089, T090, T092 | +| FR-037 | T015, T018, T085, T107–T111 | +| FR-038 | T025, T035, T105, T109, T111 | +| FR-039 | ~~T005–T007, T020~~ (retired), T097, ~~T115~~ (retired) | +| FR-040 | T054, T064, T096, T103, T104, T122 | +| FR-041 | T008, T009, ~~T016~~ (retired), T069, T080, T105, T110, T113, T128 | +| FR-042 | ~~T019~~ (retired), T068, T069, T080, T105, T112, T113, ~~T114, T115~~ (retired) | +| FR-043 | T003, T004, ~~T005–T007, T020~~ (retired), T112, T113, ~~T114, T115~~ (retired) | +| FR-044 | T021–T029, T043–T058, T068–T071, T082–T085, T093–T097, T105–T109, T121, T122, T126–T130 | +| SC-001 | T054, T069, T076, T079, T081 | +| SC-002 | T070, T076, T081, T095 | +| SC-003 | T001, T002, T053–T067 | +| SC-004 | T021–T052 | +| SC-005 | T077, T082, T086, T092 | +| SC-006 | T084, T089, T090, T092 | +| SC-007 | T003, ~~T006, T007~~ (retired), T069, T070, T095–T097, T104, ~~T115~~ (retired), T121, T122 | +| SC-008 | T004, T112, T113, ~~T114, T115, T123~~ (retired) | +| SC-009 | T012, T015, T018, T085, T105–T111, T116 | +| SC-010 | T008, T009, T105, T110, T116, T128 | +| SC-011 | ~~T007~~ (retired), T052, T067, T097, T104, T120–T122 | +| SC-012 | T083, T087, T091, T092 | +| SC-013 | ~~T007~~ (retired), T069, T070, ~~T115~~ (retired) | +| Constitution quality gates | T119, T124 | +| Traceability meta-gate | T126 | + +Owners marked `(retired)` were withdrawn with the evidence tree (T005–T007, T016, T019, T020, T114, T115, T123) and produced no committed artifact. Four rows lose part of their evidence with them: + +- **FR-039** keeps only T097. The pinned starting-SHA comparison of preview/delivery allocation outcomes is gone; what remains is the in-tree feature-003 regression suite. +- **FR-043** keeps T003, T004, T112 and T113, which build the metric helpers, the fixed-seed scenes and the benchmark cases. The provenance-locked references and the paired runner that consumed them are gone. +- **SC-008** keeps T004, T112 and T113, which build the scenes, the cases and the job shape. Nothing committed produces the paired comparison, the analyzer or the confidence interval the criterion requires, so the performance improvement is not asserted as met — Phase 8's checkpoint status says the same. +- **SC-013** keeps T069 and T070, which prove fusion-disabled/enabled parity in the same process. The pinned acceptance aggregate that would have carried the cross-machine claim is gone. + +--- + +## Dependencies and Execution Order + +### Phase Dependencies + +- **Phase 1 — Setup**: Starts immediately. +- **Phase 2 — Foundational evidence and primitives**: Depends on Phase 1 and blocks every user story. The baseline must be generated by the evidence-only patch/script from SHA `83e63689d8c72bd0b7fbd4cb01d9e468d7a78c53` before scheduling changes; no generator source may remain in a compiled project. + + **Withdrawn** in part with the evidence tree (see T123): no generator, patch or script is committed, so nothing is generated from that SHA. The primitives-before-scheduling half of the gate holds as written, and Phase 2's checkpoint status records what the withdrawal costs. +- **Phase 3 — US3 recording/migration (P1)**: Depends on Phase 2. It creates the only public recorder and the conservative compatibility executor. +- **Phase 4 — US2 FilterEffect opt-in (P1)**: Depends on US3 context/resource/executor contracts. T059–T061 may proceed in parallel after T030–T034. +- **Phase 5 — US1 renderer-wide fusion (P1)**: Depends on completed US3 migration and US2 canonical descriptions. This is the first product MVP checkpoint. +- **Phase 6 — US4 cache/animation (P2)**: Depends on US1's request-wide planner/compiler/executor. +- **Phase 7 — US5 scale/ROI/fallback/3D (P2)**: Depends on US1 and can run in parallel with US4 except where both touch `RenderRequestExecutor.cs` or shared plan/cache structures. +- **Phase 8 — US6 proof/safety (P3)**: Depends on US4 and US5. Story-local failure tests remain test-first in their phases; this phase closes the full matrix and evidence. +- **Phase 9 — Polish**: Depends on all selected stories and acceptance evidence. + +### User Story Dependencies + +- **US3 (P1)**: Independently demonstrable after Foundation with fusion disabled; blocks US2 and US1 because the old executable lifecycle is removed without a shim. +- **US2 (P1)**: Independently demonstrable after US3 with unfused Shader/Geometry execution; blocks US1's canonical CurrentPixel fusion proof. +- **US1 (P1)**: Depends on US3 and US2; independently proves renderer-wide fusion and is the MVP outcome. +- **US4 (P2)**: Depends on US1; independently proves repeated-request plan/program/cache/pool efficiency and invalidation. +- **US5 (P2)**: Depends on US1; independently proves region/scale/fallback/3D correctness and can be developed alongside US4. +- **US6 (P3)**: Integrates the completed stories into reproducible whole-request evidence and safety gates. + +### Within Each Story + +- Add the story's tests first and confirm they fail for the missing behavior before editing production files. +- Keep baseline characterization green; never regenerate a missing golden from the implementation under test. + + **Withdrawn** in part with the evidence tree (see T123): the pinned baseline no longer exists, so what stays green is the in-tree golden suite and the same-process fusion-disabled/enabled comparison. The second clause holds unchanged. +- Implement immutable descriptions/IR before planners, planners before execution, and execution before integration. +- Add direct plan assertions, component-statistics assertions, and test-owned execution probes in the same task as each scheduling/resource behavior; do not add a production request-wide telemetry layer after the fact. +- Complete each story's checkpoint before treating downstream stories as unblocked. + +## Parallel Opportunities + +Every parallel cluster starts with read-only verification of its assigned findings and overlap surface. Each verified implementation cluster uses an isolated worktree; its branch is reviewed before integration. After integrating the cluster branches, the main session audits the combined diff, contract consistency, ownership boundaries, and requested validation before treating the work as complete. + +- T003 and T004 can run in parallel after the test project exists. +- T007, T008, T010, T012, and T014–T016 own separate foundational test files and can run in parallel. +- T021–T029 are independent test-first workstreams for US3. +- After T030–T037, migration batches T038–T046 plus consumer tasks T049 and T050 can run in parallel where their named files are disjoint; T047 and T048 are explicit scale-helper sweeps and must coordinate with any overlapping migration batch. +- T053–T058 can run in parallel; T059–T061 can then run in parallel before context lowering/integration. +- T068–T071 can run in parallel before the US1 planner/compiler work. +- T082–T085 can run in parallel; T088 and T089 can run in parallel after their tests. +- US4 and US5 can be assigned to separate developers after US1, coordinating edits to `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` and shared compiled-plan/cache structures. +- T105–T109 are independent failure/reconciliation test files and can run in parallel. +- T117 and T118 can run in parallel after the public API is final. + +## Parallel Example: US3 Migration + +```text +Task T038: migrate pure/scope compositor nodes +Task T039: migrate source nodes +Task T040: migrate target/capture/mask nodes +Task T041: migrate nested/bridge nodes +Task T042: migrate opaque/backend nodes +Task T043: migrate test-local Process overrides only +Task T044: migrate render-node authoring tests +Task T045: migrate execution/scale/cross-project tests +Task T046: migrate the standalone golden harness +Task T047: migrate production scale-helper callers +Task T048: migrate test scale-helper callers +``` + +## Parallel Example: P2 Stories + +```text +Developer A: T082–T092 (US4 cache, program, pool, ownership) +Developer B: T093–T104 (US5 ROI, scale, fallback, 3D) +Coordinate: RenderRequestExecutor.cs and shared compiled-plan/cache structures +``` + +## Implementation Strategy + +**Withdrawn** in part with the evidence tree (see T123). MVP First steps 1 and 6, and Incremental Delivery step 1, describe the target-baseline evidence that was never committed; parity is evidenced instead by the same-process fusion-disabled/enabled A/B in `tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs` and by the out-of-tree differential harness. The strategy is kept as the record of the intended method. + +### MVP First + +1. Complete Setup and freeze target-baseline evidence. +2. Complete Foundation. +3. Complete US3's breaking recorder migration with fusion disabled. +4. Complete US2's canonical Shader/Geometry opt-in with unfused execution. +5. Complete US1 and stop at the one-pass cross-node fusion checkpoint. +6. Validate US1 independently against the frozen visual baseline, direct compiled-plan expectations, and applicable component statistics before continuing. + +### Incremental Delivery + +1. **Evidence/Foundation** → auditable baseline and shared ownership primitives. +2. **US3** → one recording lifecycle and conservative parity. +3. **US2** → source-compatible effects plus public Shader/Geometry opt-in. +4. **US1** → renderer-wide one-pass fusion MVP. +5. **US4 + US5** → persistent efficiency and full correctness boundaries in parallel. +6. **US6** → reproducible safety and performance proof. + +## Notes + +- A `[P]` marker means the task owns different files and all of its prerequisites are already complete; tasks that share `RenderRequestExecutor.cs` or compiled-plan/cache structures must still coordinate or run serially. +- Evidence uses immutable compiled-plan topology, component-local statistics, and test-owned execution probes; it does not require a completed-request event stream or universal fragment-outcome counter. +- Baseline blobs are immutable and fail integrity checks when missing. A paired starting-SHA comparison requires an exact environment fingerprint and fails hard on mismatch; normal CI instead compares fusion-disabled and enabled execution in the same process/device and never silently selects a foreign blob. + + **Withdrawn** in part with the evidence tree (see T123): there are no baseline blobs, no manifest and no paired starting-SHA comparison, so only the last clause is live — normal CI compares fusion-disabled and enabled execution in the same process and device, and there is no foreign blob to select. +- Ordinary fallback and public-contract tests must run without a GPU; only GPU execution-shape assertions may self-skip. +- Commit evidence separately before behavior changes, and commit the public migration with the breaking footer in `contracts/breaking-changes.md`. + + **Withdrawn** in part with the evidence tree (see T123): no evidence tree is committed, so the first clause has nothing to order. The second holds — `contracts/breaking-changes.md` carries every breaking footer on the branch. diff --git a/src/Beutl.AgentToolkit/Rendering/StillRenderer.cs b/src/Beutl.AgentToolkit/Rendering/StillRenderer.cs index 199ebc624b..c15f0d3da7 100644 --- a/src/Beutl.AgentToolkit/Rendering/StillRenderer.cs +++ b/src/Beutl.AgentToolkit/Rendering/StillRenderer.cs @@ -3,10 +3,10 @@ using Beutl.Graphics; using Beutl.Graphics.Backend; using Beutl.Graphics.Rendering; -using Beutl.Graphics.Rendering.Cache; using Beutl.Graphics.Shapes; using Beutl.Graphics3D; using Beutl.Media; +using Beutl.Models; using Beutl.ProjectSystem; namespace Beutl.AgentToolkit.Rendering; @@ -168,11 +168,7 @@ public async ValueTask RenderBitmapAsync( float normalizedScale = float.IsFinite(renderScale) && renderScale > 0f ? renderScale : 1f; return await RenderThread.Dispatcher.InvokeAsync(() => { - // Agent still render is a final output, so force original media (proxies are preview-only); - // otherwise the default PreferProxy setting would decode cached proxies here. - using var renderer = new SceneRenderer( - scene, normalizedScale, disableResourceShare: true, maxWorkingScale: float.PositiveInfinity, forceOriginalSource: true); - renderer.CacheOptions = RenderCacheOptions.Disabled; + using var renderer = ExportRendererFactory.Create(scene, normalizedScale); ThrowIfSourcesMissing(scene, time + scene.Start); var frame = renderer.Compositor.EvaluateGraphics(time + scene.Start); @@ -198,11 +194,7 @@ public async ValueTask RenderFrameAnalysisAsync( float normalizedScale = float.IsFinite(renderScale) && renderScale > 0f ? renderScale : 1f; return await RenderThread.Dispatcher.InvokeAsync(() => { - // Agent still render is a final output, so force original media (proxies are preview-only); - // otherwise the default PreferProxy setting would decode cached proxies here. - using var renderer = new SceneRenderer( - scene, normalizedScale, disableResourceShare: true, maxWorkingScale: float.PositiveInfinity, forceOriginalSource: true); - renderer.CacheOptions = RenderCacheOptions.Disabled; + using var renderer = ExportRendererFactory.Create(scene, normalizedScale); ThrowIfSourcesMissing(scene, time + scene.Start); var frame = renderer.Compositor.EvaluateGraphics(time + scene.Start); diff --git a/src/Beutl.AgentToolkit/Rendering/VideoExporter.cs b/src/Beutl.AgentToolkit/Rendering/VideoExporter.cs index cd62018a9b..007afefb61 100644 --- a/src/Beutl.AgentToolkit/Rendering/VideoExporter.cs +++ b/src/Beutl.AgentToolkit/Rendering/VideoExporter.cs @@ -7,7 +7,6 @@ using Beutl.Extensions.FFmpeg.Encoding; using Beutl.FFmpegIpc; using Beutl.Graphics.Rendering; -using Beutl.Graphics.Rendering.Cache; using Beutl.Media; using Beutl.Media.Encoding; using Beutl.Models; @@ -103,11 +102,7 @@ async Task EncodeWithAsync(ControllableEncodingExtension en $"Missing source files required to export: {string.Join(", ", missingSources)}"); } - // Video export is a final output, so force original media (proxies are preview-only); - // otherwise the default PreferProxy setting would encode from cached proxies here. - using var renderer = new SceneRenderer( - scene, normalizedScale, disableResourceShare: true, maxWorkingScale: float.PositiveInfinity, forceOriginalSource: true); - renderer.CacheOptions = RenderCacheOptions.Disabled; + using var renderer = ExportRendererFactory.Create(scene, normalizedScale); using var frameProgress = new Subject(); using var frameProvider = new FrameProviderImpl(scene, frameRate, renderer, frameProgress); using var composer = CreateExportComposer(scene, normalizedSampleRate); diff --git a/src/Beutl.AgentToolkit/Tools/QueryTools.cs b/src/Beutl.AgentToolkit/Tools/QueryTools.cs index a4737222a0..75177e15f7 100644 --- a/src/Beutl.AgentToolkit/Tools/QueryTools.cs +++ b/src/Beutl.AgentToolkit/Tools/QueryTools.cs @@ -933,7 +933,7 @@ public ToolResult ReadDocumentSummary() } [McpServerTool(Name = "measure_object_bounds")] - [Description("Measures RenderNode operation bounds for Drawable objects in the current scene. Use before positioning text, backing plates, or centered objects; default Drawable TranslateTransform values are offsets from the alignment-resolved position, not top-left coordinates.")] + [Description("Measures contributing RenderNode query bounds for Drawable objects in the current scene. Use before positioning text, backing plates, or centered objects; default Drawable TranslateTransform values are offsets from the alignment-resolved position, not top-left coordinates.")] public ToolResult MeasureObjectBounds( string? objectId = null, string? elementId = null, @@ -1073,8 +1073,8 @@ private ObjectBoundsMeasurementResponse MeasureObjectBoundsCore( new ObjectBoundsPoint(scene.FrameSize.Width / 2d, scene.FrameSize.Height / 2d), time.ToString("c"), timeFiltered, - "Scene pixel coordinates. TransformedBounds are authoritative axis-aligned scene-space bounds measured from RenderNodeOperation.Bounds. LocalBounds are normalized from the render-node extents for size only and are not Drawable.MeasureCore results.", - "Default Drawable AlignmentX/AlignmentY is Center, so a pure TranslateTransform(x, y) moves the object relative to the alignment-resolved position. For a centered object in a 1920x1080 scene, TranslateTransform(0, 0) centers it at (960, 540). Bounds are measured through DrawableRenderNode and RenderNodeProcessor rather than per-type Drawable.Measure/FilterEffect.TransformBounds estimates.", + "Scene pixel coordinates. TransformedBounds are authoritative axis-aligned scene-space RenderNodeMeasurement.QueryBounds from contributing query fragments. LocalBounds are normalized from the query extents for size only and are not Drawable.MeasureCore results.", + "Default Drawable AlignmentX/AlignmentY is Center, so a pure TranslateTransform(x, y) moves the object relative to the alignment-resolved position. For a centered object in a 1920x1080 scene, TranslateTransform(0, 0) centers it at (960, 540). Bounds are measured through DrawableRenderNode and RenderNodeRenderer.Measure().QueryBounds rather than per-type Drawable.Measure/FilterEffect.TransformBounds estimates.", measurements); } @@ -1441,8 +1441,8 @@ private static ObjectBoundsMeasurement MeasureDrawable( ? new ObjectBoundsPoint(translate.X, translate.Y) : null; string? note = renderNodeBounds.Note is null - ? "Measured through DrawableRenderNode and RenderNodeProcessor.PullToRoot(). LocalBounds is normalized from render-node extents for size only." - : $"{renderNodeBounds.Note} LocalBounds is normalized from render-node extents for size only."; + ? "Measured through DrawableRenderNode and RenderNodeRenderer.Measure().QueryBounds from contributing query fragments. LocalBounds is normalized from query extents for size only." + : $"{renderNodeBounds.Note} LocalBounds is normalized from query extents for size only."; ObjectBoundsPoint? geometryBoundsOrigin = null; if (drawable is Shape shapeDrawable) @@ -1471,7 +1471,7 @@ private static ObjectBoundsMeasurement MeasureDrawable( drawable.IsEnabled, alignmentX.ToString(), alignmentY.ToString(), - "render-node-operation-bounds", + "render-node-query-bounds", ToBoundsRect(localBounds), ToBoundsRect(transformedBounds), ToBoundsPoint(transformedBounds.Center), @@ -1493,42 +1493,26 @@ private static RenderNodeBounds MeasureDrawableRenderNodeBounds( drawable.Render(graphicsContext, resource); } - var processor = new RenderNodeProcessor(node, useRenderCache: false, outputScale: 1f, maxWorkingScale: 1f); - RenderNodeOperation[] operations = processor.PullToRoot(); - Rect bounds = Rect.Empty; - bool hasBounds = false; - try - { - foreach (RenderNodeOperation operation in operations) + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions { - Rect operationBounds = operation.Bounds; - bounds = hasBounds ? bounds.Union(operationBounds) : operationBounds; - hasBounds = true; - } - } - finally - { - DisposeRenderNodeOperations(operations); - } - - return hasBounds - ? new RenderNodeBounds(bounds, null) - : new RenderNodeBounds(Rect.Empty, "The drawable produced no RenderNode operations at the requested time."); - } + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = new Rect(default, canvasSize), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); - private static void DisposeRenderNodeOperations(RenderNodeOperation[] operations) - { - foreach (RenderNodeOperation operation in operations) - { - try - { - operation.Dispose(); - } - catch - { - // Match renderer cleanup behavior: disposal faults must not hide measurement results. - } - } + return measurement.HasFragments + ? new RenderNodeBounds(measurement.QueryBounds, null) + : new RenderNodeBounds( + Rect.Empty, + "The drawable produced no contributing RenderNode query fragments at the requested time."); } private static Rect NormalizeBoundsSize(Rect bounds) diff --git a/src/Beutl.AgentToolkit/Tools/RenderTools.cs b/src/Beutl.AgentToolkit/Tools/RenderTools.cs index fb73f24254..b76a299be5 100644 --- a/src/Beutl.AgentToolkit/Tools/RenderTools.cs +++ b/src/Beutl.AgentToolkit/Tools/RenderTools.cs @@ -2109,18 +2109,18 @@ internal static float ValidateRenderScale(Scene scene, float renderScale, string PixelSize frameSize = scene.FrameSize; double requestedWidth = GetRootDeviceExtent(frameSize.Width, normalizedScale); double requestedHeight = GetRootDeviceExtent(frameSize.Height, normalizedScale); - if (requestedWidth <= RenderNodeContext.MaxBufferDimension - && requestedHeight <= RenderNodeContext.MaxBufferDimension) + if (requestedWidth <= RenderScaleUtilities.MaxBufferDimension + && requestedHeight <= RenderScaleUtilities.MaxBufferDimension) { return normalizedScale; } double maximumScaleLimit = Math.Min( frameSize.Width > 0 - ? RenderNodeContext.MaxBufferDimension / (double)frameSize.Width + ? RenderScaleUtilities.MaxBufferDimension / (double)frameSize.Width : double.PositiveInfinity, frameSize.Height > 0 - ? RenderNodeContext.MaxBufferDimension / (double)frameSize.Height + ? RenderScaleUtilities.MaxBufferDimension / (double)frameSize.Height : double.PositiveInfinity); float maximumScale = (float)maximumScaleLimit; while (!RootOutputExtentFits(frameSize, maximumScale)) @@ -2140,7 +2140,7 @@ internal static float ValidateRenderScale(Scene scene, float renderScale, string throw new ReconcileException(new ToolError( ErrorCode.ValidationRejected, $"{toolName} renderScale {normalizedScale.ToString("G9", CultureInfo.InvariantCulture)} requests an output extent of {requestedExtent} pixels. " - + $"Each output axis is limited to {RenderNodeContext.MaxBufferDimension} pixels; " + + $"Each output axis is limited to {RenderScaleUtilities.MaxBufferDimension} pixels; " + $"the maximum usable renderScale for frame {frameSize.Width}x{frameSize.Height} is {maximumScaleText}.", "renderScale", $"Use renderScale <= {maximumScaleText} for this frame size.")); @@ -2148,8 +2148,8 @@ internal static float ValidateRenderScale(Scene scene, float renderScale, string private static bool RootOutputExtentFits(PixelSize frameSize, float renderScale) { - return GetRootDeviceExtent(frameSize.Width, renderScale) <= RenderNodeContext.MaxBufferDimension - && GetRootDeviceExtent(frameSize.Height, renderScale) <= RenderNodeContext.MaxBufferDimension; + return GetRootDeviceExtent(frameSize.Width, renderScale) <= RenderScaleUtilities.MaxBufferDimension + && GetRootDeviceExtent(frameSize.Height, renderScale) <= RenderScaleUtilities.MaxBufferDimension; } private static float GetRootDeviceExtent(int logicalExtent, float renderScale) diff --git a/src/Beutl.Editor.Components/Helpers/EngineObjectHelper.cs b/src/Beutl.Editor.Components/Helpers/EngineObjectHelper.cs index 4b77185f8a..4df36619f1 100644 --- a/src/Beutl.Editor.Components/Helpers/EngineObjectHelper.cs +++ b/src/Beutl.Editor.Components/Helpers/EngineObjectHelper.cs @@ -1,12 +1,19 @@ using System.Reactive; +using System.Reactive.Disposables; using Beutl.Composition; using Beutl.Engine; using Beutl.Engine.Expressions; +using Beutl.Graphics.Rendering; +using Beutl.Logging; +using Beutl.Threading; +using Microsoft.Extensions.Logging; namespace Beutl.Editor.Components.Helpers; public static class EngineObjectHelper { + private static readonly ILogger s_logger = Log.CreateLogger(typeof(EngineObjectHelper)); + public static IObservable?> SubscribeExpressionChange(this IProperty property) { return Observable.FromEvent?>( @@ -69,33 +76,119 @@ public static IObservable SubscribeEngineResource( .Select(t => t.resource); } + /// + /// Observes as a versioned resource whose creation, update, and disposal all run on + /// the render dispatcher, so a subscriber never races the renderer for the same resource. + /// + /// + /// Disposing the subscription cancels work that has not started yet, so a resource is never created for a + /// subscription that was already gone. + /// public static IObservable<(TResource Resource, int Version)> SubscribeEngineVersionedResource( this T obj, IObservable time, Func createResource) where T : EngineObject where TResource : EngineObject.Resource { - var renderContext = new CompositionContext(TimeSpan.Zero); - TResource? resource = null; - return Observable.FromEventPattern( - h => obj.Edited += h, - h => obj.Edited -= h) - .Select(_ => Unit.Default) - .Publish(Unit.Default).RefCount() - .CombineLatest(time) - .Select(t => + return Observable.Create<(TResource Resource, int Version)>(observer => { - renderContext.Time = t.Second; - if (resource == null) + var renderContext = new CompositionContext(TimeSpan.Zero); + var cts = new CancellationTokenSource(); + CancellationToken token = cts.Token; + TResource? resource = null; + + IDisposable trigger = Observable.FromEventPattern( + h => obj.Edited += h, + h => obj.Edited -= h) + .Select(_ => Unit.Default) + .Publish(Unit.Default).RefCount() + .CombineLatest(time) + .Subscribe(onNext: t => + { + if (token.IsCancellationRequested) + return; + + RenderThread.Dispatcher.Dispatch( + () => + { + if (token.IsCancellationRequested) + return; + + try + { + renderContext.Time = t.Second; + if (resource is null) + { + resource = createResource(obj, renderContext); + } + else + { + bool updateOnly = false; + resource.Update(obj, renderContext, ref updateOnly); + } + + observer.OnNext((resource, resource.Version)); + } + catch (Exception ex) + { + // An escaping exception unwinds the shared render-thread loop, which + // installs no unhandled-exception handler. + cts.Cancel(); + try + { + resource?.Dispose(); + } + catch (Exception disposeFailure) + { + ex.Data["EngineVersionedResourceDisposeFailure"] = disposeFailure; + } + + resource = null; + observer.OnError(ex); + } + }, + DispatchPriority.Low); + }, + // Without an explicit handler Rx throws the trigger's failure on the source thread, + // leaving this subscription uninformed and still holding its resource. + onError: ex => + { + cts.Cancel(); + ReleaseOnRenderThread(disposeTokenSource: false); + observer.OnError(ex); + }); + + return Disposable.Create(() => { - resource = createResource(obj, renderContext); - } - else + cts.Cancel(); + trigger.Dispose(); + ReleaseOnRenderThread(disposeTokenSource: true); + }); + + void ReleaseOnRenderThread(bool disposeTokenSource) { - bool updateOnly = false; - resource.Update(obj, renderContext, ref updateOnly); - } + RenderThread.Dispatcher.Dispatch( + () => + { + // The render loop installs no unhandled-exception handler, so a throwing + // Dispose here would take the render thread down with it. + try + { + resource?.Dispose(); + } + catch (Exception disposeFailure) + { + s_logger.LogWarning( + disposeFailure, + "Releasing the versioned resource for '{Object}' failed.", + obj); + } - return (resource, resource.Version); + resource = null; + if (disposeTokenSource) + cts.Dispose(); + }, + DispatchPriority.Low); + } }) .DistinctUntilChanged(t => t.Version); } diff --git a/src/Beutl.Editor.Components/PathEditorTab/ViewModels/PathEditorViewModel.cs b/src/Beutl.Editor.Components/PathEditorTab/ViewModels/PathEditorViewModel.cs index eccee84ae8..ee74385e69 100644 --- a/src/Beutl.Editor.Components/PathEditorTab/ViewModels/PathEditorViewModel.cs +++ b/src/Beutl.Editor.Components/PathEditorTab/ViewModels/PathEditorViewModel.cs @@ -120,7 +120,7 @@ private Matrix CalculateMatrix(Drawable.Resource drawable) matrix *= Graphics.Matrix.CreateTranslation(thickness, thickness); } - Matrix mat = drawable.GetOriginal().GetTransformMatrix(frameSize, size, drawable); + Matrix mat = drawable.GetOriginal()!.GetTransformMatrix(frameSize, size, drawable); matrix *= mat; } @@ -172,15 +172,15 @@ public void StartEdit(Shape shape, IGeometryEditorContext context, Avalonia.Poin var shapeResource = shape.ToResource(new CompositionContext(_clock.CurrentTime.Value)); Avalonia.Matrix matrix = CalculateMatrix(shapeResource).ToAvaMatrix(); if (matrix.TryInvert(out Avalonia.Matrix inverted) - && shapeResource is GeometryShape.Resource { Data: not null } geometryShapeResource - && context.Value.Value is PathGeometry geometry) + && shapeResource is GeometryShape.Resource { Data: PathGeometry.Resource pathData } geometryShapeResource + && context.Value.Value is PathGeometry) { point = inverted.Transform(point); - PathFigure.Resource? figure = geometry.HitTestFigure( - point.ToBtlPoint(), geometryShapeResource.Pen, geometryShapeResource.Data); + PathFigure.Resource? figure = pathData.HitTestFigure( + point.ToBtlPoint(), geometryShapeResource.Pen); if (figure != null) { - var figContext = context.FindPathFigureContext(figure.GetOriginal()); + var figContext = context.FindPathFigureContext(figure.GetOriginal()!); if (figContext != null) { StartEdit(figContext); diff --git a/src/Beutl.Editor.Components/PathEditorTab/Views/PathEditorTabView.axaml.cs b/src/Beutl.Editor.Components/PathEditorTab/Views/PathEditorTabView.axaml.cs index 086fee468c..a606872265 100644 --- a/src/Beutl.Editor.Components/PathEditorTab/Views/PathEditorTabView.axaml.cs +++ b/src/Beutl.Editor.Components/PathEditorTab/Views/PathEditorTabView.axaml.cs @@ -157,7 +157,7 @@ private void UpdateBackgroundGeometry() { using (var context = new GeometryContext { FillType = geometry.FillType }) { - geometry.GetOriginal().ApplyTo(context, geometry); + geometry.ApplyTo(context); string s = context.NativeObject.ToSvgPathData(); var newGeometry = Avalonia.Media.PathGeometry.Parse(s); diff --git a/src/Beutl.Editor/Models/ExportRendererFactory.cs b/src/Beutl.Editor/Models/ExportRendererFactory.cs new file mode 100644 index 0000000000..f22113a7c3 --- /dev/null +++ b/src/Beutl.Editor/Models/ExportRendererFactory.cs @@ -0,0 +1,57 @@ +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.ProjectSystem; + +namespace Beutl.Models; + +/// +/// Creates the used by delivery-grade output paths (video export, still +/// export, frame save). +/// +public static class ExportRendererFactory +{ + /// + /// Creates a renderer configured for final output: so an + /// intermediate render-target allocation failure fails the export instead of silently dropping the + /// affected content, , original media (the default + /// PreferProxy setting would otherwise encode from preview proxies), an unshared resource graph so + /// live preview resources are untouched, and no render caching. + /// + /// The scene to render. + /// Output scale in device px per logical unit. + public static SceneRenderer Create(Scene scene, float renderScale = 1f) + { + ArgumentNullException.ThrowIfNull(scene); + + var renderer = new SceneRenderer( + scene, + RenderIntent.Delivery, + renderScale, + disableResourceShare: true, + maxWorkingScale: WorkingScaleCeiling.Export(), + forceOriginalSource: true); + try + { + renderer.CacheOptions = RenderCacheOptions.Disabled; + } + catch + { + DisposePreservingPrimaryFailure(renderer); + throw; + } + + return renderer; + } + + private static void DisposePreservingPrimaryFailure(IDisposable? value) + { + try + { + value?.Dispose(); + } + catch + { + // Cleanup must not replace the failure that triggered it. + } + } +} diff --git a/src/Beutl.Editor/Models/FrameProviderImpl.cs b/src/Beutl.Editor/Models/FrameProviderImpl.cs index 61887714c4..df4fe23c6e 100644 --- a/src/Beutl.Editor/Models/FrameProviderImpl.cs +++ b/src/Beutl.Editor/Models/FrameProviderImpl.cs @@ -20,14 +20,26 @@ public sealed class FrameProviderImpl : IFrameProvider, IDisposable private readonly Channel<(long Frame, Bitmap Bitmap)> _channel; private readonly CancellationTokenSource _cts = new(); private readonly Task _producerTask; + private readonly RetainedRenderTargetCheckpoint _retentionCheckpoint; private bool _disposed; public FrameProviderImpl(Scene scene, Rational rate, SceneRenderer renderer, Subject progress) + : this(scene, rate, renderer, progress, RetainedRenderTargetCheckpoint.DefaultReleaseInterval) + { + } + + internal FrameProviderImpl( + Scene scene, + Rational rate, + SceneRenderer renderer, + Subject progress, + int retainedRenderTargetReleaseInterval) { _scene = scene; _rate = rate; _renderer = renderer; _progress = progress; + _retentionCheckpoint = new RetainedRenderTargetCheckpoint(retainedRenderTargetReleaseInterval); int bufferSize = Preferences.Default.Get("Output.FrameBufferSize", 100); _channel = Channel.CreateBounded<(long Frame, Bitmap Bitmap)>( @@ -68,6 +80,19 @@ private Bitmap RenderCore(TimeSpan time) "SupersampleDownscaler failed to normalize the supersampled render to the output resolution."); } + if (_retentionCheckpoint.Advance()) + { + try + { + _renderer.ReleaseRetainedRenderTargets(); + } + catch + { + normalized.Dispose(); + throw; + } + } + return normalized; } @@ -164,3 +189,19 @@ public void Dispose() _cts.Dispose(); } } + +internal sealed class RetainedRenderTargetCheckpoint +{ + internal const int DefaultReleaseInterval = 30; + private readonly int _releaseInterval; + private int _renderedFrameCount; + + public RetainedRenderTargetCheckpoint(int releaseInterval = DefaultReleaseInterval) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(releaseInterval); + _releaseInterval = releaseInterval; + } + + public bool Advance() + => ++_renderedFrameCount % _releaseInterval == 0; +} diff --git a/src/Beutl.Editor/Models/SaveFrameScale.cs b/src/Beutl.Editor/Models/SaveFrameScale.cs index 007e0ca114..eae4c10cde 100644 --- a/src/Beutl.Editor/Models/SaveFrameScale.cs +++ b/src/Beutl.Editor/Models/SaveFrameScale.cs @@ -23,7 +23,7 @@ public static (long Width, long Height) GetRenderSize(PixelSize frameSize, float /// Whether the scaled surface fits the per-axis buffer limit on both axes. public static bool FitsBufferLimit( - PixelSize frameSize, float scale, int maxDimension = RenderNodeContext.MaxBufferDimension) + PixelSize frameSize, float scale, int maxDimension = RenderScaleUtilities.MaxBufferDimension) { (long width, long height) = GetRenderSize(frameSize, scale); return width <= maxDimension && height <= maxDimension; diff --git a/src/Beutl.Editor/Services/ObjectTemplatePreviewRenderer.cs b/src/Beutl.Editor/Services/ObjectTemplatePreviewRenderer.cs index 941a1a9a0e..db23f38c4b 100644 --- a/src/Beutl.Editor/Services/ObjectTemplatePreviewRenderer.cs +++ b/src/Beutl.Editor/Services/ObjectTemplatePreviewRenderer.cs @@ -192,13 +192,21 @@ private static PixelSize ResolveFrameSize(Element element) using var root = new DrawableRenderNode(resource); using (var context = new GraphicsContext2D(root, availableSize, scale)) { - resource.GetOriginal().Render(context, resource); + resource.GetOriginal()!.Render(context, resource); } - root.PrepareForProcess(canvas); - new RenderNodeProcessor( - root, useRenderCache: false, outputScale: scale, maxWorkingScale: scale * 2f) - .Render(canvas); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = scale, + MaxWorkingScale = scale * 2f, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); } } @@ -214,30 +222,39 @@ private static Rect MeasureBounds(IReadOnlyList resources, Si using var root = new DrawableRenderNode(resource); using (var context = new GraphicsContext2D(root, availableSize)) { - resource.GetOriginal().Render(context, resource); + resource.GetOriginal()!.Render(context, resource); } - var processor = new RenderNodeProcessor(root, useRenderCache: false); - RenderNodeOperation[] operations = processor.PullToRoot(); try { - foreach (RenderNodeOperation op in operations) - { - bounds = bounds.Union(op.Bounds); - } + bounds = bounds.Union(MeasureOutputBounds(root, targetDomain: null)); } - finally + catch (RenderTargetDomainRequiredException) { - foreach (RenderNodeOperation op in operations) - { - op.Dispose(); - } + // A TargetDomain also clips the measured extent, so it serves only as a fallback owner + // for graphs whose Full target access cannot resolve without one. + bounds = bounds.Union(MeasureOutputBounds(root, new Rect(default, availableSize))); } } return bounds; } + private static Rect MeasureOutputBounds(DrawableRenderNode root, Rect? targetDomain) + { + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + return renderer.Measure().OutputBounds; + } + private static Size AvailableSize => new(PreviewWidth, PreviewHeight); // Assigning the live object to a fresh shape would tear it out of the edited scene's hierarchy, diff --git a/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs b/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs index 8d18009150..cd2b6020e9 100644 --- a/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs +++ b/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs @@ -66,7 +66,10 @@ private static void EmitFields(StringBuilder sb, string innerIndent, ClassInfo i string fieldName = EmitHelpers.ToFieldName(property.Name); string resourceType = EmitHelpers.GetResourceTypeName(property.ValueType); - sb.Append(innerIndent).AppendLine($"private {resourceType} {fieldName} = default!;"); + string fieldType = resourceType.EndsWith("?", StringComparison.Ordinal) + ? resourceType + : resourceType + "?"; + sb.Append(innerIndent).AppendLine($"private {fieldType} {fieldName};"); sb.AppendLine(); } @@ -112,9 +115,22 @@ private static void EmitProperties(StringBuilder sb, string innerIndent, ClassIn string fieldName = EmitHelpers.ToFieldName(property.Name); string resourceType = EmitHelpers.GetResourceTypeName(property.ValueType); + bool isNullable = property.ValueType.NullableAnnotation == NullableAnnotation.Annotated; sb.Append(innerIndent).AppendLine($"public {resourceType} {property.Name}"); sb.Append(innerIndent).AppendLine("{"); - sb.Append(innerIndent).AppendLine($" get => {fieldName};"); + if (isNullable) + { + sb.Append(innerIndent).AppendLine($" get => {fieldName};"); + } + else + { + sb.Append(innerIndent) + .Append(" get => ") + .Append(fieldName) + .Append(" ?? throw new global::System.InvalidOperationException(\"") + .Append(property.Name) + .AppendLine(" did not contain an owned resource.\");"); + } sb.Append(innerIndent).AppendLine($" set => {fieldName} = value;"); sb.Append(innerIndent).AppendLine("}"); sb.AppendLine(); @@ -149,9 +165,9 @@ private static void EmitProperties(StringBuilder sb, string innerIndent, ClassIn private static void EmitGetOriginal(StringBuilder sb, string innerIndent, string currentTypeDisplay) { - sb.Append(innerIndent).AppendLine($"public new {currentTypeDisplay} GetOriginal()"); + sb.Append(innerIndent).AppendLine($"public new {currentTypeDisplay}? GetOriginal()"); sb.Append(innerIndent).AppendLine("{"); - sb.Append(innerIndent).AppendLine($" return ({currentTypeDisplay})base.GetOriginal();"); + sb.Append(innerIndent).AppendLine($" return ({currentTypeDisplay}?)base.GetOriginal();"); sb.Append(innerIndent).AppendLine("}"); } @@ -163,7 +179,7 @@ private static void EmitBindNodePortValues(StringBuilder sb, string innerIndent, sb.Append(innerIndent).AppendLine("public override void BindNodePortValues()"); sb.Append(innerIndent).AppendLine("{"); sb.Append(innerIndent).AppendLine(" base.BindNodePortValues();"); - sb.Append(innerIndent).AppendLine(" var node = GetOriginal();"); + sb.Append(innerIndent).AppendLine(" var node = GetOriginal()!;"); for (int i = 0; i < info.NodePortProperties.Length; i++) { diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index a80c0700fe..3d6e0b0f48 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -845,7 +845,7 @@ public void InvalidateCache() /// protected void ComposeSound(Sound.Resource resource, TimeRange timeRange) { - var sound = resource.GetOriginal(); + Sound sound = resource.GetOriginal()!; // Get or create cache entry if (!_audioCache.TryGetValue(sound, out var entry)) { diff --git a/src/Beutl.Engine/Audio/SoundGroup.cs b/src/Beutl.Engine/Audio/SoundGroup.cs index e609100fff..1055a85b1b 100644 --- a/src/Beutl.Engine/Audio/SoundGroup.cs +++ b/src/Beutl.Engine/Audio/SoundGroup.cs @@ -33,7 +33,7 @@ public override void Compose(AudioContext context, Sound.Resource resource) // そのまま通す foreach (var child in r.Children) { - var original = child.GetOriginal(); + Sound original = child.GetOriginal()!; if (original.TimeRange.Start < TimeRange.Start) { var internalContext = new AudioContext(context.SampleRate, context.ChannelCount); @@ -80,7 +80,7 @@ public override void Compose(AudioContext context, Sound.Resource resource) foreach (var child in r.Children) { - var original = child.GetOriginal(); + Sound original = child.GetOriginal()!; var internalContext = new AudioContext(context.SampleRate, context.ChannelCount); original.Compose(internalContext, child); foreach (AudioNode node in internalContext.Nodes) diff --git a/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs b/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs index 9c8ab60fcc..5362d12ab9 100644 --- a/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs +++ b/src/Beutl.Engine/Audio/SourceSound.Thumbnails.cs @@ -117,7 +117,7 @@ public async IAsyncEnumerable GetWaveformChunksAsync( [resource], TimeRange, default, - new CompositionEligibility([resource.GetOriginal()])); + new CompositionEligibility([resource.GetOriginal()!])); for (int chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) { diff --git a/src/Beutl.Engine/Composition/CompositionContext.cs b/src/Beutl.Engine/Composition/CompositionContext.cs index c675f743da..fcb4cb22f2 100644 --- a/src/Beutl.Engine/Composition/CompositionContext.cs +++ b/src/Beutl.Engine/Composition/CompositionContext.cs @@ -1,4 +1,5 @@ using Beutl.Engine; +using Beutl.Graphics; using Beutl.Media.Proxy; namespace Beutl.Composition; @@ -48,6 +49,15 @@ public class CompositionContext(TimeSpan time) public ProxyPreset PreferredProxyPreset { get; set; } = ProxyPreset.Quarter; + /// + /// Gets or sets the finite logical composition domain available to auxiliary render-node evaluation. + /// + /// + /// Scene composition supplies its frame rectangle. A null value means the caller has no finite target domain; + /// self-bounded render graphs remain valid, while root TargetRegion.Full access requires a value. + /// + public Rect? TargetDomain { get; set; } + public virtual T Get(IProperty property) { if (property == null) diff --git a/src/Beutl.Engine/Engine/EngineObject.cs b/src/Beutl.Engine/Engine/EngineObject.cs index dcb3e337d3..5a2eb54f63 100644 --- a/src/Beutl.Engine/Engine/EngineObject.cs +++ b/src/Beutl.Engine/Engine/EngineObject.cs @@ -379,7 +379,7 @@ public class Resource : IDisposable Dispose(false); } - private EngineObject _original = null!; + private EngineObject? _original; public int Version { get; set; } @@ -387,7 +387,13 @@ public class Resource : IDisposable public bool IsDisposed { get; private set; } - public EngineObject GetOriginal() => _original; + /// The object this resource was built from, or when there is none. + /// + /// A resource constructed directly instead of through - a + /// detached resource - never receives a backing object. Engine code that only needs an + /// equality-stable key uses EngineResourceIdentity instead, which handles that case. + /// + public EngineObject? GetOriginal() => _original; public virtual void Update(EngineObject obj, CompositionContext context, ref bool updateOnly) { diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioSpectrogramDrawable.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioSpectrogramDrawable.cs index e8e7facb30..2b538e8696 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioSpectrogramDrawable.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioSpectrogramDrawable.cs @@ -101,7 +101,7 @@ protected override void RenderForeground(ImmediateCanvas canvas, Rect bounds) // 高強度 (normalized=1) 相当の Fill を 1 度構築し、各セルは Alpha のみ書き換える。 _paint ??= new SKPaint(); - new BrushConstructor(bounds, Fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, Fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; SKColor baseColor = _paint.Color; byte baseAlpha = baseColor.Alpha; diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs index 6722878a79..c32e3935a6 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerDrawable.cs @@ -154,7 +154,7 @@ private void EnsureSamplesComposed(TimeSpan targetStart, TimeSpan targetDuration if (!needsRecompose) return; var targetRange = new TimeRange(targetStart, targetDuration); - Sound sound = _source.GetOriginal(); + Sound sound = _source.GetOriginal()!; if (!ReferenceEquals(_frameObjectsSource, _source)) { diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs index e282203b05..16758d13b3 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs @@ -1,5 +1,6 @@ using Beutl.Engine; using Beutl.Graphics.Rendering; +using Beutl.Media; namespace Beutl.Graphics.AudioVisualizers; @@ -19,21 +20,34 @@ public bool Update(AudioVisualizerDrawable.Resource resource) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - if (!Visualizer.HasValue) return []; - AudioVisualizerDrawable.Resource resource = Visualizer.Value.Resource; + if (Visualizer is not { } snapshot) + return; - var bounds = new Rect(0, 0, Math.Max(1f, resource.Width), Math.Max(1f, resource.Height)); + AudioVisualizerDrawable.Resource resource = snapshot.Resource; - return - [ - RenderNodeOperation.CreateLambda(bounds, canvas => resource.RenderToCanvas(canvas, bounds)) - ]; + var bounds = new Rect(0, 0, Math.Max(1f, resource.Width), Math.Max(1f, resource.Height)); + RenderResource resourceToken = context.Borrow(resource); + Brush.Resource? fill = resource.Fill; + context.Publish(context.PaintedSource( + state: new VisualizerPainterState(resource, bounds), + draw: static (canvas, _, _, state) => state.Resource.RenderToCanvas(canvas, state.Bounds), + fill: fill, + pen: null, + outputBounds: bounds, + hitTest: RenderHitTestContract.None, + scale: RenderScaleContract.Vector, + supportsDirectDstOut: false, + resources: [resourceToken])); } protected override void OnDispose(bool disposing) { Visualizer = null; } + + private readonly record struct VisualizerPainterState( + AudioVisualizerDrawable.Resource Resource, + Rect Bounds); } diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/BarSpectrumShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/BarSpectrumShape.cs index 0e9789bb74..3f18847fd1 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/BarSpectrumShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/BarSpectrumShape.cs @@ -58,7 +58,7 @@ internal override void Render( } _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; _path ??= new SKPath(); diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/DotsWaveformShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/DotsWaveformShape.cs index 94a551ef5f..2beeba283f 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/DotsWaveformShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/DotsWaveformShape.cs @@ -52,7 +52,7 @@ internal override void Render( bool minmax = Mode == DotsWaveformMode.MinMax; _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; _paint.IsAntialias = true; _path ??= new SKPath(); diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledAreaSpectrumShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledAreaSpectrumShape.cs index f7b863a1da..225055dd54 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledAreaSpectrumShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledAreaSpectrumShape.cs @@ -41,7 +41,7 @@ internal override void Render( float cornerRadius = smoothness * slotWidth * 0.5f; _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; if (_lastCornerRadius != cornerRadius) { diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledEnvelopeWaveformShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledEnvelopeWaveformShape.cs index c1c6932d26..53fe209cc6 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledEnvelopeWaveformShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledEnvelopeWaveformShape.cs @@ -50,7 +50,7 @@ internal override void Render( bool symmetric = Symmetric; _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; if (_lastCornerRadius != cornerRadius) { diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledMirrorWaveformShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledMirrorWaveformShape.cs index 0af3739dc2..f6136b6e29 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/FilledMirrorWaveformShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/FilledMirrorWaveformShape.cs @@ -54,7 +54,7 @@ internal override void Render( if (round) { _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; _path ??= new SKPath(); _path.Reset(); diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/LineSpectrumShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/LineSpectrumShape.cs index 78b461ba07..fec5df1ffa 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/LineSpectrumShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/LineSpectrumShape.cs @@ -46,7 +46,7 @@ internal override void Render( float cornerRadius = smoothness * slotWidth * 0.5f; _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Stroke; _paint.StrokeCap = SKStrokeCap.Round; _paint.StrokeJoin = SKStrokeJoin.Round; diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/LineWaveformShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/LineWaveformShape.cs index 254c3e3294..68aab905cb 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/LineWaveformShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/LineWaveformShape.cs @@ -54,7 +54,7 @@ internal override void Render( bool mirrored = Mirrored; _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Stroke; _paint.StrokeCap = SKStrokeCap.Round; _paint.StrokeJoin = SKStrokeJoin.Round; diff --git a/src/Beutl.Engine/Graphics/AudioVisualizers/MinMaxBarWaveformShape.cs b/src/Beutl.Engine/Graphics/AudioVisualizers/MinMaxBarWaveformShape.cs index 04b769045f..b13e893614 100644 --- a/src/Beutl.Engine/Graphics/AudioVisualizers/MinMaxBarWaveformShape.cs +++ b/src/Beutl.Engine/Graphics/AudioVisualizers/MinMaxBarWaveformShape.cs @@ -54,7 +54,7 @@ internal override void Render( if (round) { _paint ??= new SKPaint(); - new BrushConstructor(bounds, fill, BlendMode.SrcOver, canvas.Density, canvas.MaxWorkingScale).ConfigurePaint(_paint); + canvas.CreateBrushConstructor(bounds, fill, BlendMode.SrcOver).ConfigurePaint(_paint); _paint.Style = SKPaintStyle.Fill; _path ??= new SKPath(); _path.Reset(); diff --git a/src/Beutl.Engine/Graphics/Backend/Composite/CompositeContext.cs b/src/Beutl.Engine/Graphics/Backend/Composite/CompositeContext.cs index 79659f3493..5bbb8c10a4 100644 --- a/src/Beutl.Engine/Graphics/Backend/Composite/CompositeContext.cs +++ b/src/Beutl.Engine/Graphics/Backend/Composite/CompositeContext.cs @@ -41,7 +41,9 @@ public ITexture2D CreateTexture2D(int width, int height, TextureFormat format) { if (Metal != null && !format.IsDepthFormat()) { - return new MetalVulkanTexture2D(Metal, Vulkan, width, height, format); + var texture = new MetalVulkanTexture2D(Metal, Vulkan, width, height, format); + VulkanContext.RecordTextureAllocation(format); + return texture; } return Vulkan.CreateTexture2D(width, height, format); @@ -74,14 +76,17 @@ public IShaderCompiler CreateShaderCompiler() public IRenderPass3D CreateRenderPass3D( IReadOnlyList colorFormats, - TextureFormat depthFormat = TextureFormat.Depth32Float, + TextureFormat? depthFormat, AttachmentLoadOp colorLoadOp = AttachmentLoadOp.Clear, AttachmentLoadOp depthLoadOp = AttachmentLoadOp.Clear) { return Vulkan.CreateRenderPass3D(colorFormats, depthFormat, colorLoadOp, depthLoadOp); } - public IFramebuffer3D CreateFramebuffer3D(IRenderPass3D renderPass, IReadOnlyList colorTextures, ITexture2D depthTexture) + public IFramebuffer3D CreateFramebuffer3D( + IRenderPass3D renderPass, + IReadOnlyList colorTextures, + ITexture2D? depthTexture) { return Vulkan.CreateFramebuffer3D(renderPass, colorTextures, depthTexture); } diff --git a/src/Beutl.Engine/Graphics/Backend/GraphicsContextFactory.cs b/src/Beutl.Engine/Graphics/Backend/GraphicsContextFactory.cs index 06c398101e..44c6155282 100644 --- a/src/Beutl.Engine/Graphics/Backend/GraphicsContextFactory.cs +++ b/src/Beutl.Engine/Graphics/Backend/GraphicsContextFactory.cs @@ -9,6 +9,8 @@ namespace Beutl.Graphics.Backend; public class GraphicsContextFactory { + internal const string VulkanValidationEnvironmentVariable = "BEUTL_VULKAN_VALIDATION"; + internal const string VulkanValidationAppContextSwitch = "Beutl.Graphics.Vulkan.EnableValidation"; private static readonly ILogger s_logger = Log.CreateLogger(typeof(GraphicsContextFactory)); private static bool s_failedToInitialize; private static VulkanInstance? s_vulkanInstance; @@ -75,10 +77,23 @@ private static void EnsureVulkanInstance() { VulkanSetup.Setup(); var vk = Vk.GetApi(); - s_vulkanInstance = new VulkanInstance(vk, enableValidation: false); + s_vulkanInstance = new VulkanInstance(vk, IsVulkanValidationEnabled()); } } + internal static bool IsVulkanValidationEnabled() + { + if (AppContext.TryGetSwitch(VulkanValidationAppContextSwitch, out bool enabled) && enabled) + return true; + + string? value = Environment.GetEnvironmentVariable(VulkanValidationEnvironmentVariable); + return value is not null + && (value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase)); + } + public static IGraphicsContext CreateContext() { EnsureVulkanInstance(); @@ -163,6 +178,7 @@ public static void Shutdown() { RenderThread.Dispatcher.Invoke(() => { + GpuResourceReclaimQueue.FlushAndDrain(); SharedContext?.Dispose(); SharedContext = null; diff --git a/src/Beutl.Engine/Graphics/Backend/IFramebuffer3D.cs b/src/Beutl.Engine/Graphics/Backend/IFramebuffer3D.cs index a542227f3f..0b0ffbf4bc 100644 --- a/src/Beutl.Engine/Graphics/Backend/IFramebuffer3D.cs +++ b/src/Beutl.Engine/Graphics/Backend/IFramebuffer3D.cs @@ -4,7 +4,7 @@ namespace Beutl.Graphics.Backend; /// -/// Interface for 3D framebuffer with MRT (Multiple Render Targets) support. +/// Interface for a framebuffer with MRT (Multiple Render Targets) and optional depth support. /// public interface IFramebuffer3D : IDisposable { @@ -24,9 +24,9 @@ public interface IFramebuffer3D : IDisposable IReadOnlyList ColorTextures { get; } /// - /// Gets the depth texture attachment. + /// Gets the depth texture attachment, or for a color-only framebuffer. /// - ITexture2D DepthTexture { get; } + ITexture2D? DepthTexture { get; } /// /// Prepares all textures for sampling. diff --git a/src/Beutl.Engine/Graphics/Backend/IGraphicsContext.cs b/src/Beutl.Engine/Graphics/Backend/IGraphicsContext.cs index 2bcf1613b5..79d1e8f79d 100644 --- a/src/Beutl.Engine/Graphics/Backend/IGraphicsContext.cs +++ b/src/Beutl.Engine/Graphics/Backend/IGraphicsContext.cs @@ -59,15 +59,15 @@ public interface IGraphicsContext : IDisposable IShaderCompiler CreateShaderCompiler(); /// - /// Creates a new 3D render pass with multiple color attachments and specified load operations. + /// Creates a new render pass with multiple color attachments and an optional depth attachment. /// /// Formats for each color attachment. - /// Format for the depth attachment. + /// Format for the depth attachment, or for a color-only pass. /// The load operation for color attachments. - /// The load operation for the depth attachment. + /// The load operation for the depth attachment; ignored when is null. IRenderPass3D CreateRenderPass3D( IReadOnlyList colorFormats, - TextureFormat depthFormat = TextureFormat.Depth32Float, + TextureFormat? depthFormat, AttachmentLoadOp colorLoadOp = AttachmentLoadOp.Clear, AttachmentLoadOp depthLoadOp = AttachmentLoadOp.Clear); @@ -76,8 +76,11 @@ IRenderPass3D CreateRenderPass3D( /// /// The render pass to use with this framebuffer. /// The color attachment textures. - /// The depth attachment texture. - IFramebuffer3D CreateFramebuffer3D(IRenderPass3D renderPass, IReadOnlyList colorTextures, ITexture2D depthTexture); + /// The depth attachment texture, or for a color-only framebuffer. + IFramebuffer3D CreateFramebuffer3D( + IRenderPass3D renderPass, + IReadOnlyList colorTextures, + ITexture2D? depthTexture); /// /// Creates a new 3D pipeline. @@ -87,7 +90,11 @@ IRenderPass3D CreateRenderPass3D( /// The compiled fragment shader SPIR-V bytecode. /// The descriptor bindings for the pipeline. /// The vertex input description. Use VertexInputDescription.Empty for fullscreen passes. - /// Pipeline options (depth test, cull mode, etc.). If null, uses PipelineOptions.Default. + /// + /// Pipeline options, including specialization constants and fixed-function state. If null, uses + /// . Specialization constants are captured during this call and form + /// part of the created pipeline's identity. + /// /// A new pipeline instance. IPipeline3D CreatePipeline3D( IRenderPass3D renderPass, diff --git a/src/Beutl.Engine/Graphics/Backend/IRenderPass3D.cs b/src/Beutl.Engine/Graphics/Backend/IRenderPass3D.cs index daaecf9f46..9d2e78716b 100644 --- a/src/Beutl.Engine/Graphics/Backend/IRenderPass3D.cs +++ b/src/Beutl.Engine/Graphics/Backend/IRenderPass3D.cs @@ -13,7 +13,7 @@ public interface IRenderPass3D : IDisposable /// /// The framebuffer to render to. /// Clear colors for each color attachment. - /// The depth value to clear the depth buffer with. + /// The depth value to clear the depth buffer with; ignored by color-only passes. void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, float clearDepth = 1.0f); /// @@ -61,6 +61,10 @@ public interface IRenderPass3D : IDisposable /// /// The type of push constants data. /// The push constants data. - /// The shader stages that will access the push constants. - void SetPushConstants(T data, ShaderStage stageFlags = ShaderStage.Vertex | ShaderStage.Fragment) where T : unmanaged; + /// + /// Which shader stages the update names is the bound pipeline layout's to decide, not the caller's: an + /// update must name every stage of every declared range it overlaps, so a caller naming only the stage + /// it happens to read from would be describing something the layout does not offer. + /// + void SetPushConstants(T data) where T : unmanaged; } diff --git a/src/Beutl.Engine/Graphics/Backend/ITexture2D.cs b/src/Beutl.Engine/Graphics/Backend/ITexture2D.cs index 8f32cf87aa..353c02770b 100644 --- a/src/Beutl.Engine/Graphics/Backend/ITexture2D.cs +++ b/src/Beutl.Engine/Graphics/Backend/ITexture2D.cs @@ -60,4 +60,55 @@ public interface ITexture2D : IDisposable /// Prepares the texture for sampling (transitions to shader read-only layout). /// void PrepareForSampling(); + + /// + /// Gets whether the associated Skia surface owns pending work that the caller must flush + /// before returning the texture to backend interop. + /// + bool RequiresSkiaFlushForBackendInterop { get; } + + /// + /// Prepares the texture to be rendered through the Skia surface returned by + /// by submitting preceding backend access and establishing + /// the visibility and ordering required by Skia. + /// + void PrepareForSkiaRendering(); + + /// + /// Prepares the texture to be sampled by Skia by submitting preceding backend access and + /// establishing the visibility and ordering required by the associated Skia surface. + /// + /// + /// Whether the method must wait for backend work to complete before returning. A value of + /// permits asynchronous submission, but an implementation may still + /// wait when its backend cannot express the hand-off with GPU synchronization primitives. + /// + void PrepareForSkiaSampling(bool requireCompletion); +} + +/// +/// Internal content-state contract for textures whose backend can record an ordered transparent clear. +/// +internal interface ITransparentClearableTexture +{ + /// + /// Gets whether the most recently recorded write defines the whole texture as transparent. + /// + bool HasTransparentContents { get; } + + /// + /// Records a transparent clear unless the current recorded content is already known transparent. + /// + void ClearToTransparent(); + + /// + /// Records that something other than has just defined the whole + /// texture as transparent. + /// + /// + /// A surface cleared through Skia leaves the image transparent too, but the backend cannot see that + /// write. Without this the texture keeps reporting unknown contents and the next caller that wants a + /// blank target clears an already-blank image. + /// + void MarkContentsTransparent(); } diff --git a/src/Beutl.Engine/Graphics/Backend/Metal/MetalVulkanTexture2D.cs b/src/Beutl.Engine/Graphics/Backend/Metal/MetalVulkanTexture2D.cs index 4fe3a8d6b6..1d3fd9c8ed 100644 --- a/src/Beutl.Engine/Graphics/Backend/Metal/MetalVulkanTexture2D.cs +++ b/src/Beutl.Engine/Graphics/Backend/Metal/MetalVulkanTexture2D.cs @@ -70,13 +70,37 @@ public override SKSurface CreateSkiaSurface() var textureInfo = new GRMtlTextureInfo(_metalTexture); var backendTexture = new GRBackendTexture(_width, _height, false, textureInfo); - return SKSurface.Create( + SKSurface surface = SKSurface.Create( _metalContext.SkiaContext, backendTexture, GRSurfaceOrigin.TopLeft, 1, _format.ToSkiaColorType(), SKColorSpace.CreateSrgbLinear()); + return surface; + } + + public override void PrepareForSkiaRendering() + { + bool requiresWait = RequiresVulkanToSkiaHandoff + || _currentLayout != ImageLayout.ColorAttachmentOptimal; + base.PrepareForSkiaRendering(); + if (requiresWait) + { + // MoltenVK and Skia's Metal queue do not share Beutl's Vulkan submission semaphore. + _context.FlushCommands(waitForCompletion: true); + } + } + + public override void PrepareForSkiaSampling(bool requireCompletion) + { + bool requiresWait = RequiresVulkanToSkiaHandoff; + base.PrepareForSkiaSampling(requireCompletion: false); + if (requiresWait) + { + // CPU completion is the cross-API hand-off until an exported Metal event is available. + _context.FlushCommands(waitForCompletion: true); + } } private IntPtr ExportMetalTexture() diff --git a/src/Beutl.Engine/Graphics/Backend/PipelineOptions.cs b/src/Beutl.Engine/Graphics/Backend/PipelineOptions.cs index 19ba71de38..144eaf5b63 100644 --- a/src/Beutl.Engine/Graphics/Backend/PipelineOptions.cs +++ b/src/Beutl.Engine/Graphics/Backend/PipelineOptions.cs @@ -1,10 +1,22 @@ -namespace Beutl.Graphics.Backend; +using System.Collections.Immutable; + +namespace Beutl.Graphics.Backend; /// /// Options for creating a graphics pipeline. /// public struct PipelineOptions { + /// + /// Gets or sets the immutable specialization constants applied when the pipeline is created. + /// A default or empty array applies no specialization. + /// + /// + /// Specialization constants are part of pipeline identity. Pipeline caches must compare their stage, + /// constant ID, scalar size, and value rather than the array instance or insertion order. + /// + public ImmutableArray SpecializationConstants { get; set; } + /// /// Gets or sets whether depth testing is enabled. Default is true. /// @@ -65,6 +77,7 @@ public struct PipelineOptions /// public static PipelineOptions Default => new() { + SpecializationConstants = [], DepthTestEnabled = true, DepthWriteEnabled = true, CullMode = CullMode.Back, @@ -83,6 +96,7 @@ public struct PipelineOptions /// public static PipelineOptions Fullscreen => new() { + SpecializationConstants = [], DepthTestEnabled = false, DepthWriteEnabled = false, CullMode = CullMode.None, @@ -102,6 +116,7 @@ public struct PipelineOptions /// public static PipelineOptions Transparent => new() { + SpecializationConstants = [], DepthTestEnabled = true, DepthWriteEnabled = false, CullMode = CullMode.Back, diff --git a/src/Beutl.Engine/Graphics/Backend/SpecializationConstant.cs b/src/Beutl.Engine/Graphics/Backend/SpecializationConstant.cs new file mode 100644 index 0000000000..da136a5e3b --- /dev/null +++ b/src/Beutl.Engine/Graphics/Backend/SpecializationConstant.cs @@ -0,0 +1,123 @@ +namespace Beutl.Graphics.Backend; + +/// +/// Describes an immutable scalar value used to specialize one or more shader stages when a pipeline is created. +/// +/// +/// The constant ID must match a SPIR-V specialization constant declared by every stage in . +/// Specialization values are part of pipeline identity and cannot be changed after pipeline creation. +/// +public readonly record struct SpecializationConstant +{ + private readonly ulong _valueBits; + private readonly byte _sizeInBytes; + private readonly bool _isFloatingPoint; + + private SpecializationConstant( + uint constantId, + ShaderStage stages, + ulong valueBits, + byte sizeInBytes, + bool isFloatingPoint = false) + { + ConstantId = constantId; + Stages = stages; + _valueBits = valueBits; + _sizeInBytes = sizeInBytes; + _isFloatingPoint = isFloatingPoint; + } + + /// + /// Gets the SPIR-V specialization constant ID. + /// + public uint ConstantId { get; } + + /// + /// Gets the shader stages specialized with this value. + /// + public ShaderStage Stages { get; } + + /// + /// Gets the size of the scalar value in bytes. + /// + public int SizeInBytes => _sizeInBytes; + + /// + /// Creates a 32-bit Boolean specialization constant. + /// + public static SpecializationConstant Create(uint constantId, bool value, ShaderStage stages) + => new(constantId, stages, value ? 1u : 0u, sizeof(uint)); + + /// + /// Creates a signed 32-bit integer specialization constant. + /// + public static SpecializationConstant Create(uint constantId, int value, ShaderStage stages) + => new(constantId, stages, unchecked((uint)value), sizeof(int)); + + /// + /// Creates an unsigned 32-bit integer specialization constant. + /// + public static SpecializationConstant Create(uint constantId, uint value, ShaderStage stages) + => new(constantId, stages, value, sizeof(uint)); + + /// + /// Creates a 32-bit floating-point specialization constant. + /// + public static SpecializationConstant Create(uint constantId, float value, ShaderStage stages) + => new(constantId, stages, BitConverter.SingleToUInt32Bits(value), sizeof(float), isFloatingPoint: true); + + /// + /// Creates a signed 64-bit integer specialization constant. + /// + public static SpecializationConstant Create(uint constantId, long value, ShaderStage stages) + => new(constantId, stages, unchecked((ulong)value), sizeof(long)); + + /// + /// Creates an unsigned 64-bit integer specialization constant. + /// + public static SpecializationConstant Create(uint constantId, ulong value, ShaderStage stages) + => new(constantId, stages, value, sizeof(ulong)); + + /// + /// Creates a 64-bit floating-point specialization constant. + /// + public static SpecializationConstant Create(uint constantId, double value, ShaderStage stages) + => new( + constantId, + stages, + unchecked((ulong)BitConverter.DoubleToInt64Bits(value)), + sizeof(double), + isFloatingPoint: true); + + /// Whether specializing with this value needs the device's 64-bit integer shader feature. + internal bool RequiresShaderInt64 => _sizeInBytes == sizeof(ulong) && !_isFloatingPoint; + + /// Whether specializing with this value needs the device's 64-bit float shader feature. + internal bool RequiresShaderFloat64 => _sizeInBytes == sizeof(double) && _isFloatingPoint; + + /// + /// Copies the immutable scalar value to the start of in its native binary + /// representation. Exactly bytes are written. + /// + /// The destination buffer, which must be at least bytes. + public void CopyValueTo(Span destination) + { + if (destination.Length < _sizeInBytes) + { + throw new ArgumentException("The destination is too small for the specialization value.", nameof(destination)); + } + + if (_sizeInBytes == sizeof(uint)) + { + BitConverter.TryWriteBytes(destination, unchecked((uint)_valueBits)); + } + else if (_sizeInBytes == sizeof(ulong)) + { + BitConverter.TryWriteBytes(destination, _valueBits); + } + else + { + throw new InvalidOperationException("The specialization constant has an invalid scalar size."); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanContextResource.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanContextResource.cs new file mode 100644 index 0000000000..f850cd5450 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanContextResource.cs @@ -0,0 +1,13 @@ +namespace Beutl.Graphics.Backend.Vulkan; + +/// A backend object whose Vulkan handles are only valid on the context that created them. +/// +/// Vulkan handles carry no device provenance, so submitting one to a different device is undefined behaviour +/// the driver is not required to diagnose. Every backend entry point that accepts a caller-supplied resource +/// resolves it through , which uses this to reject a +/// resource that belongs to another context before its handle reaches a native call. +/// +internal interface IVulkanContextResource +{ + VulkanContext OwnerContext { get; } +} diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanRenderPassSuspension.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanRenderPassSuspension.cs new file mode 100644 index 0000000000..2649b3456b --- /dev/null +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/IVulkanRenderPassSuspension.cs @@ -0,0 +1,20 @@ +namespace Beutl.Graphics.Backend.Vulkan; + +/// +/// A render pass instance that can be ended and begun again so work Vulkan forbids inside it can be +/// recorded where it was asked for. +/// +/// +/// Without this, a transfer or barrier requested mid-pass has to take its own batch, and that batch submits +/// ahead of the pass still being recorded - so a draw already recorded in the pass runs after work requested +/// later than it. Splitting the pass keeps the whole sequence on one command buffer in recording order. +/// +internal interface IVulkanRenderPassSuspension +{ + /// Ends the open pass instance, if one is open. + /// Whether an instance was open and has been ended, so it must be resumed. + bool TrySuspend(); + + /// Begins the instance again, loading what the suspended half stored. + void Resume(); +} diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanBuffer.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanBuffer.cs index e6efd06f65..07a999ec1a 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanBuffer.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanBuffer.cs @@ -8,7 +8,7 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of . /// -internal sealed unsafe class VulkanBuffer : IBuffer +internal sealed unsafe class VulkanBuffer : IBuffer, IVulkanContextResource { private readonly VulkanContext _context; private readonly Buffer _buffer; @@ -18,6 +18,8 @@ internal sealed unsafe class VulkanBuffer : IBuffer private readonly MemoryProperty _memoryProperties; private bool _disposed; + public VulkanContext OwnerContext => _context; + public VulkanBuffer( VulkanContext context, ulong size, @@ -136,17 +138,22 @@ public void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; - - if (_buffer.Handle != 0) + Buffer buffer = _buffer; + DeviceMemory memory = _memory; + _context.DeferRelease(() => { - vk.DestroyBuffer(device, _buffer, null); - } + var vk = _context.Vk; + var device = _context.Device; - if (_memory.Handle != 0) - { - vk.FreeMemory(device, _memory, null); - } + if (buffer.Handle != 0) + { + vk.DestroyBuffer(device, buffer, null); + } + + if (memory.Handle != 0) + { + vk.FreeMemory(device, memory, null); + } + }); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanCommandPool.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanCommandPool.cs index 00d076c977..eb42d00600 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanCommandPool.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanCommandPool.cs @@ -7,14 +7,21 @@ namespace Beutl.Graphics.Backend.Vulkan; internal sealed unsafe class VulkanCommandPool : IDisposable { + private static readonly AsyncLocal s_observer = new(); private readonly Vk _vk; private readonly Device _device; private readonly Queue _graphicsQueue; private readonly uint _graphicsQueueFamilyIndex; private readonly CommandPool _commandPool; - private readonly Fence _immediateFence; + private readonly List _inFlightSubmissions = []; + private readonly List _recordingReleases = []; + private CommandBuffer _recordingCommandBuffer; private Semaphore _submissionSemaphore; + private bool _isRecording; + private int _renderPassScopeDepth; + private IVulkanRenderPassSuspension? _activeRenderPassOwner; private bool _hasPendingSemaphoreSignal; + private bool _isCompletingSubmissions; private bool _disposed; public VulkanCommandPool(Vk vk, Device device, Queue graphicsQueue, uint graphicsQueueFamilyIndex) @@ -25,15 +32,15 @@ public VulkanCommandPool(Vk vk, Device device, Queue graphicsQueue, uint graphic _graphicsQueueFamilyIndex = graphicsQueueFamilyIndex; _commandPool = CreateCommandPool(); - _immediateFence = CreateFence(); - _submissionSemaphore = CreateSemaphore(); } - public CommandPool CommandPool => _commandPool; - - public Fence ImmediateFence => _immediateFence; - - public Semaphore SubmissionSemaphore => _submissionSemaphore; + internal static IDisposable Observe(Action observer) + { + ArgumentNullException.ThrowIfNull(observer); + var scope = new ObservationScope(observer, s_observer.Value); + s_observer.Value = scope; + return scope; + } private CommandPool CreateCommandPool() { @@ -83,62 +90,208 @@ private Semaphore CreateSemaphore() return semaphore; } - public void SubmitImmediateCommands(Action record) + public void RecordCommands(Action record) { - CommandBufferAllocateInfo allocInfo = new() + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(record); + if (_renderPassScopeDepth > 0) { - SType = StructureType.CommandBufferAllocateInfo, - CommandPool = _commandPool, - Level = CommandBufferLevel.Primary, - CommandBufferCount = 1 - }; + RecordAroundTheRenderPassInstance(record); + return; + } - CommandBuffer commandBuffer; - _vk.AllocateCommandBuffers(_device, &allocInfo, &commandBuffer); + record(GetRecordingCommandBuffer()); + } - CommandBufferBeginInfo beginInfo = new() + /// + /// Records onto the claimed batch outside the render pass instance, ending + /// that instance first and beginning it again after when one is open. + /// + /// + /// Vulkan forbids a transfer or a barrier inside a render pass instance, and this is the arrangement + /// that obeys that without reordering anything: the whole sequence stays on one command buffer in the + /// order it was recorded, so a draw already recorded in this pass still runs before work requested + /// after it. Nothing is suspended when the batch is claimed but the instance is not open yet, or when + /// an outer split already suspended it - in both cases the work simply joins the batch in place. + /// + private void RecordAroundTheRenderPassInstance(Action record) + { + IVulkanRenderPassSuspension owner = _activeRenderPassOwner + ?? throw new InvalidOperationException( + "A claimed render-pass scope must have an owner that can suspend its instance."); + if (!owner.TrySuspend()) { - SType = StructureType.CommandBufferBeginInfo, - Flags = CommandBufferUsageFlags.OneTimeSubmitBit - }; + record(GetRecordingCommandBuffer()); + return; + } + + try + { + record(GetRecordingCommandBuffer()); + } + finally + { + owner.Resume(); + } + } - _vk.BeginCommandBuffer(commandBuffer, &beginInfo); - record(commandBuffer); - _vk.EndCommandBuffer(commandBuffer); + /// Rejects a caller that is about to record a render pass while another one owns the batch. + /// + /// Separate from so a pass can reject a double begin before it + /// records anything, while still claiming the batch only at the command that opens the pass: claiming + /// it earlier would send the pass's own preparation barriers through the suspend path, and there is + /// nothing to suspend yet. + /// + /// Another render pass already owns the batch. + public void ThrowIfRenderPassActive() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_activeRenderPassOwner is not null) + { + throw new InvalidOperationException( + "A render pass instance is already recording on this context's command buffer. Vulkan does " + + "not allow one render pass inside another, so the active pass must end before the next " + + "begins."); + } + } - SubmitInfo submitInfo = new() + /// + /// Claims the recording batch for one render pass instance, during which transfers and barriers cannot + /// join it. + /// + /// The render pass claiming the batch. + /// + /// Every pass on this context records into one shared command buffer, and Vulkan forbids a render pass + /// instance inside another on the same buffer. Ownership is exclusive rather than counted so a second + /// pass is rejected here instead of reaching vkCmdBeginRenderPass, where it would invalidate the + /// buffer the first pass is still recording into. + /// + /// Another render pass already owns the batch. + public void BeginRenderPassScope(IVulkanRenderPassSuspension owner) + { + ArgumentNullException.ThrowIfNull(owner); + ThrowIfRenderPassActive(); + _activeRenderPassOwner = owner; + _renderPassScopeDepth++; + } + + /// Releases the recording batch claimed by . + /// does not own the batch. + public void EndRenderPassScope(IVulkanRenderPassSuspension owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (!ReferenceEquals(_activeRenderPassOwner, owner)) { - SType = StructureType.SubmitInfo, + throw new InvalidOperationException( + "Only the render pass that claimed this context's command buffer can release it."); + } + + _activeRenderPassOwner = null; + if (_renderPassScopeDepth > 0) + _renderPassScopeDepth--; + } + + /// + /// Records, submits, and waits for an isolated one-shot command buffer without consuming the + /// open recording batch or retiring its deferred releases. + /// + /// + /// This is reserved for Vulkan callbacks whose caller can use the affected resource as soon as + /// the callback returns. Queue order places the isolated submission after previously submitted + /// work and before the still-open recording batch when that batch is eventually submitted. + /// + public void SubmitIsolatedCommands(Action record) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(record); + + var allocInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = _commandPool, + Level = CommandBufferLevel.Primary, CommandBufferCount = 1, - PCommandBuffers = &commandBuffer }; - fixed (Semaphore* submissionSemaphore = &_submissionSemaphore) - fixed (Fence* immediateFence = &_immediateFence) + CommandBuffer commandBuffer = default; + Fence fence = default; + bool commandBufferAllocated = false; + try { - PipelineStageFlags waitDstStageMask = PipelineStageFlags.AllCommandsBit; - if (_hasPendingSemaphoreSignal) + Result result = _vk.AllocateCommandBuffers(_device, &allocInfo, &commandBuffer); + if (result != Result.Success) { - submitInfo.WaitSemaphoreCount = 1; - submitInfo.PWaitSemaphores = submissionSemaphore; - submitInfo.PWaitDstStageMask = &waitDstStageMask; + throw new InvalidOperationException( + $"Failed to allocate an isolated command buffer: {result}"); + } + commandBufferAllocated = true; + + var beginInfo = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + result = _vk.BeginCommandBuffer(commandBuffer, &beginInfo); + if (result != Result.Success) + { + throw new InvalidOperationException( + $"Failed to begin an isolated command buffer: {result}"); } - submitInfo.SignalSemaphoreCount = 1; - submitInfo.PSignalSemaphores = submissionSemaphore; + record(commandBuffer); - _vk.ResetFences(_device, 1, immediateFence); - _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, _immediateFence); - _vk.WaitForFences(_device, 1, immediateFence, Vk.True, ulong.MaxValue); + result = _vk.EndCommandBuffer(commandBuffer); + if (result != Result.Success) + { + throw new InvalidOperationException( + $"Failed to end an isolated command buffer: {result}"); + } - _hasPendingSemaphoreSignal = true; - _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + fence = CreateFence(); + var submitInfo = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + }; + result = _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, fence); + if (result != Result.Success) + { + throw new InvalidOperationException( + $"Failed to submit an isolated command buffer: {result}"); + } + RecordEvent(VulkanCommandPoolEvent.Submission); + + result = _vk.WaitForFences(_device, 1, &fence, Vk.True, ulong.MaxValue); + if (result != Result.Success) + { + throw new InvalidOperationException( + $"Failed to wait for an isolated command buffer: {result}"); + } + RecordEvent(VulkanCommandPoolEvent.FenceWait); + } + finally + { + if (fence.Handle != 0) + { + _vk.DestroyFence(_device, fence, null); + } + if (commandBufferAllocated) + { + _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + } } } - public CommandBuffer AllocateCommandBuffer() + public CommandBuffer GetRecordingCommandBuffer() { - CommandBufferAllocateInfo allocInfo = new() + ObjectDisposedException.ThrowIf(_disposed, this); + CollectCompletedSubmissions(); + + if (_isRecording) + return _recordingCommandBuffer; + + var allocInfo = new CommandBufferAllocateInfo { SType = StructureType.CommandBufferAllocateInfo, CommandPool = _commandPool, @@ -152,38 +305,278 @@ public CommandBuffer AllocateCommandBuffer() { throw new InvalidOperationException($"Failed to allocate command buffer: {result}"); } - return commandBuffer; + _recordingCommandBuffer = commandBuffer; + + var beginInfo = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit + }; + + result = _vk.BeginCommandBuffer(_recordingCommandBuffer, &beginInfo); + if (result != Result.Success) + { + _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + _recordingCommandBuffer = default; + throw new InvalidOperationException($"Failed to begin command buffer: {result}"); + } + + _isRecording = true; + return _recordingCommandBuffer; + } + + public void Flush(bool waitForCompletion) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // A render pass instance owns the recording batch until it ends, so submitting it here would + // end and free the command buffer the pass is still recording into - the same buffer a suspended + // instance resumes on. Everything recorded during the scope is on that unfinished batch, which a + // synchronous caller cannot be owed yet, so waiting on the in-flight submissions is the whole of + // what it can be owed. + if (_renderPassScopeDepth == 0) + { + SubmitRecordingCommandBuffer(); + } + + if (waitForCompletion) + { + WaitForInFlightSubmissions(); + } + else + { + CollectCompletedSubmissions(); + } } - public void SubmitCommandBuffer(CommandBuffer commandBuffer) + public void DeferRelease(Action release) { - SubmitInfo submitInfo = new() + ArgumentNullException.ThrowIfNull(release); + + if (_disposed) { - SType = StructureType.SubmitInfo, - CommandBufferCount = 1, - PCommandBuffers = &commandBuffer - }; + release(); + return; + } - fixed (Semaphore* submissionSemaphore = &_submissionSemaphore) - fixed (Fence* immediateFence = &_immediateFence) + CollectCompletedSubmissions(); + if (_isRecording) { + _recordingReleases.Add(release); + } + else if (_inFlightSubmissions.Count > 0) + { + _inFlightSubmissions[^1].Releases.Add(release); + } + else + { + release(); + } + } + + private void SubmitRecordingCommandBuffer() + { + if (!_isRecording) + return; + + CommandBuffer commandBuffer = _recordingCommandBuffer; + Action[] releases = [.. _recordingReleases]; + _recordingCommandBuffer = default; + _recordingReleases.Clear(); + _isRecording = false; + + Fence fence = default; + Semaphore signalSemaphore = default; + Semaphore waitSemaphore = _submissionSemaphore; + InFlightSubmission? submission = null; + try + { + Result result = _vk.EndCommandBuffer(commandBuffer); + if (result != Result.Success) + { + throw new InvalidOperationException($"Failed to end command buffer: {result}"); + } + + fence = CreateFence(); + signalSemaphore = CreateSemaphore(); PipelineStageFlags waitDstStageMask = PipelineStageFlags.AllCommandsBit; + var submitInfo = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + SignalSemaphoreCount = 1, + PSignalSemaphores = &signalSemaphore + }; + if (_hasPendingSemaphoreSignal) { submitInfo.WaitSemaphoreCount = 1; - submitInfo.PWaitSemaphores = submissionSemaphore; + submitInfo.PWaitSemaphores = &waitSemaphore; submitInfo.PWaitDstStageMask = &waitDstStageMask; } - submitInfo.SignalSemaphoreCount = 1; - submitInfo.PSignalSemaphores = submissionSemaphore; - - _vk.ResetFences(_device, 1, immediateFence); - _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, _immediateFence); - _vk.WaitForFences(_device, 1, immediateFence, Vk.True, ulong.MaxValue); + submission = new InFlightSubmission(commandBuffer, fence); + if (_hasPendingSemaphoreSignal) + { + submission.WaitSemaphores.Add(waitSemaphore); + } + submission.Releases.AddRange(releases); + _inFlightSubmissions.EnsureCapacity(_inFlightSubmissions.Count + 1); - _hasPendingSemaphoreSignal = true; + result = _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, fence); + if (result != Result.Success) + { + throw new InvalidOperationException($"Failed to submit command buffer: {result}"); + } + } + catch (Exception submitException) + { + if (signalSemaphore.Handle != 0) + { + _vk.DestroySemaphore(_device, signalSemaphore, null); + } + if (fence.Handle != 0) + { + _vk.DestroyFence(_device, fence, null); + } _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + + try + { + RetireUnsubmittedReleases(releases); + } + catch (Exception releaseException) + { + throw new AggregateException( + "Vulkan submission and deferred-resource retirement both failed.", + submitException, + releaseException); + } + + throw; + } + + _inFlightSubmissions.Add(submission!); + _submissionSemaphore = signalSemaphore; + _hasPendingSemaphoreSignal = true; + RecordEvent(VulkanCommandPoolEvent.Submission); + } + + private void RetireUnsubmittedReleases(Action[] releases) + { + // A resource referenced by the failed recording may also be referenced by an older + // submission. Keep its release behind that submission instead of freeing it immediately. + if (_inFlightSubmissions.Count > 0) + { + _inFlightSubmissions[^1].Releases.AddRange(releases); + } + else + { + InvokeReleases(releases); + } + } + + private void WaitForInFlightSubmissions() + { + if (_inFlightSubmissions.Count == 0) + return; + + var fences = new Fence[_inFlightSubmissions.Count]; + for (int i = 0; i < fences.Length; i++) + { + fences[i] = _inFlightSubmissions[i].Fence; + } + + fixed (Fence* pFences = fences) + { + Result result = _vk.WaitForFences(_device, (uint)fences.Length, pFences, Vk.True, ulong.MaxValue); + if (result != Result.Success) + { + throw new InvalidOperationException($"Failed to wait for Vulkan submissions: {result}"); + } + } + + RecordEvent(VulkanCommandPoolEvent.FenceWait); + while (_inFlightSubmissions.Count > 0) + { + CompleteSubmission(0); + } + ResetSubmissionSemaphore(); + } + + private void CollectCompletedSubmissions() + { + if (_isCompletingSubmissions) + return; + + while (_inFlightSubmissions.Count > 0 + && _vk.GetFenceStatus(_device, _inFlightSubmissions[0].Fence) == Result.Success) + { + CompleteSubmission(0); + } + + if (_inFlightSubmissions.Count == 0) + { + ResetSubmissionSemaphore(); + } + } + + private void CompleteSubmission(int index) + { + InFlightSubmission submission = _inFlightSubmissions[index]; + _inFlightSubmissions.RemoveAt(index); + Action[] releases = [.. submission.Releases]; + submission.Releases.Clear(); + + CommandBuffer commandBuffer = submission.CommandBuffer; + _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + _vk.DestroyFence(_device, submission.Fence, null); + foreach (Semaphore semaphore in submission.WaitSemaphores) + { + _vk.DestroySemaphore(_device, semaphore, null); + } + + bool wasCompletingSubmissions = _isCompletingSubmissions; + _isCompletingSubmissions = true; + try + { + InvokeReleases(releases); + } + finally + { + _isCompletingSubmissions = wasCompletingSubmissions; + } + } + + private static void InvokeReleases(IEnumerable releases) + { + List? exceptions = null; + foreach (Action release in releases) + { + try + { + release(); + } + catch (Exception exception) + { + (exceptions ??= []).Add(exception); + } + } + + if (exceptions is not null) + { + throw new AggregateException("One or more deferred Vulkan resource releases failed.", exceptions); + } + } + + private void ResetSubmissionSemaphore() + { + if (_hasPendingSemaphoreSignal) + { + _vk.DestroySemaphore(_device, _submissionSemaphore, null); + _submissionSemaphore = default; + _hasPendingSemaphoreSignal = false; } } @@ -194,7 +587,7 @@ public void TransitionImageLayout(Image image, ImageLayout oldLayout, ImageLayou public void TransitionImageLayout(Image image, ImageLayout oldLayout, ImageLayout newLayout, ImageAspectFlags aspectMask) { - SubmitImmediateCommands(commandBuffer => + RecordCommands(commandBuffer => { ImageMemoryBarrier barrier = new() { @@ -232,7 +625,7 @@ public void TransitionImageLayout( uint baseArrayLayer, uint layerCount) { - SubmitImmediateCommands(commandBuffer => + RecordCommands(commandBuffer => { ImageMemoryBarrier barrier = new() { @@ -270,105 +663,166 @@ private static void GetPipelineStages( out AccessFlags srcAccess, out AccessFlags dstAccess) { - srcStage = PipelineStageFlags.TopOfPipeBit; - dstStage = PipelineStageFlags.BottomOfPipeBit; - srcAccess = 0; - dstAccess = 0; - - if (oldLayout == ImageLayout.Undefined && newLayout == ImageLayout.ColorAttachmentOptimal) - { - srcStage = PipelineStageFlags.TopOfPipeBit; - dstStage = PipelineStageFlags.ColorAttachmentOutputBit; - srcAccess = 0; - dstAccess = AccessFlags.ColorAttachmentWriteBit; - } - else if (oldLayout == ImageLayout.ColorAttachmentOptimal && newLayout == ImageLayout.ShaderReadOnlyOptimal) - { - srcStage = PipelineStageFlags.ColorAttachmentOutputBit; - dstStage = PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.ComputeShaderBit; - srcAccess = AccessFlags.ColorAttachmentWriteBit; - dstAccess = AccessFlags.ShaderReadBit; - } - else if (oldLayout == ImageLayout.ShaderReadOnlyOptimal && newLayout == ImageLayout.ColorAttachmentOptimal) - { - srcStage = PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.ComputeShaderBit; - dstStage = PipelineStageFlags.ColorAttachmentOutputBit; - srcAccess = AccessFlags.ShaderReadBit; - dstAccess = AccessFlags.ColorAttachmentWriteBit; - } - else if (oldLayout == ImageLayout.ColorAttachmentOptimal && newLayout == ImageLayout.TransferSrcOptimal) - { - srcStage = PipelineStageFlags.ColorAttachmentOutputBit; - dstStage = PipelineStageFlags.TransferBit; - srcAccess = AccessFlags.ColorAttachmentWriteBit; - dstAccess = AccessFlags.TransferReadBit; - } - else if (oldLayout == ImageLayout.TransferSrcOptimal && newLayout == ImageLayout.ColorAttachmentOptimal) - { - srcStage = PipelineStageFlags.TransferBit; - dstStage = PipelineStageFlags.ColorAttachmentOutputBit; - srcAccess = AccessFlags.TransferReadBit; - dstAccess = AccessFlags.ColorAttachmentWriteBit; - } - // Depth image transitions - else if (oldLayout == ImageLayout.Undefined && newLayout == ImageLayout.DepthStencilAttachmentOptimal) + (srcStage, srcAccess) = oldLayout switch { - srcStage = PipelineStageFlags.TopOfPipeBit; - dstStage = PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit; - srcAccess = 0; - dstAccess = AccessFlags.DepthStencilAttachmentReadBit | AccessFlags.DepthStencilAttachmentWriteBit; - } - else if (oldLayout == ImageLayout.DepthStencilAttachmentOptimal && newLayout == ImageLayout.ShaderReadOnlyOptimal) + ImageLayout.Undefined => (PipelineStageFlags.TopOfPipeBit, (AccessFlags)0), + ImageLayout.ColorAttachmentOptimal => ( + PipelineStageFlags.ColorAttachmentOutputBit, + AccessFlags.ColorAttachmentReadBit | AccessFlags.ColorAttachmentWriteBit), + ImageLayout.DepthStencilAttachmentOptimal => ( + PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit, + AccessFlags.DepthStencilAttachmentReadBit | AccessFlags.DepthStencilAttachmentWriteBit), + ImageLayout.ShaderReadOnlyOptimal => ( + PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.ComputeShaderBit, + AccessFlags.ShaderReadBit), + ImageLayout.TransferSrcOptimal => (PipelineStageFlags.TransferBit, AccessFlags.TransferReadBit), + ImageLayout.TransferDstOptimal => (PipelineStageFlags.TransferBit, AccessFlags.TransferWriteBit), + _ => (PipelineStageFlags.AllCommandsBit, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit), + }; + + (dstStage, dstAccess) = newLayout switch { - srcStage = PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit; - dstStage = PipelineStageFlags.FragmentShaderBit; - srcAccess = AccessFlags.DepthStencilAttachmentWriteBit; - dstAccess = AccessFlags.ShaderReadBit; - } - else if (oldLayout == ImageLayout.ShaderReadOnlyOptimal && newLayout == ImageLayout.DepthStencilAttachmentOptimal) + ImageLayout.ColorAttachmentOptimal => ( + PipelineStageFlags.ColorAttachmentOutputBit, + AccessFlags.ColorAttachmentReadBit | AccessFlags.ColorAttachmentWriteBit), + ImageLayout.DepthStencilAttachmentOptimal => ( + PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit, + AccessFlags.DepthStencilAttachmentReadBit | AccessFlags.DepthStencilAttachmentWriteBit), + ImageLayout.ShaderReadOnlyOptimal => ( + PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.ComputeShaderBit, + AccessFlags.ShaderReadBit), + ImageLayout.TransferSrcOptimal => (PipelineStageFlags.TransferBit, AccessFlags.TransferReadBit), + ImageLayout.TransferDstOptimal => (PipelineStageFlags.TransferBit, AccessFlags.TransferWriteBit), + _ => (PipelineStageFlags.AllCommandsBit, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit), + }; + } + + public void Dispose() + { + if (_disposed) + return; + + try { - srcStage = PipelineStageFlags.FragmentShaderBit; - dstStage = PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit; - srcAccess = AccessFlags.ShaderReadBit; - dstAccess = AccessFlags.DepthStencilAttachmentReadBit | AccessFlags.DepthStencilAttachmentWriteBit; + Flush(waitForCompletion: true); } - // Transfer transitions for texture upload - else if (oldLayout == ImageLayout.Undefined && newLayout == ImageLayout.TransferDstOptimal) + catch (Exception flushException) { - srcStage = PipelineStageFlags.TopOfPipeBit; - dstStage = PipelineStageFlags.TransferBit; - srcAccess = 0; - dstAccess = AccessFlags.TransferWriteBit; + try + { + // Submission failure leaves the recording detached, but older submissions may + // still own resources retired from it. Complete those before destroying the pool. + WaitForInFlightSubmissions(); + } + catch (Exception cleanupException) + { + throw new AggregateException( + "Vulkan command-pool flush and cleanup both failed.", + flushException, + cleanupException); + } + + throw; } - else if (oldLayout == ImageLayout.TransferDstOptimal && newLayout == ImageLayout.ShaderReadOnlyOptimal) + finally { - srcStage = PipelineStageFlags.TransferBit; - dstStage = PipelineStageFlags.FragmentShaderBit; - srcAccess = AccessFlags.TransferWriteBit; - dstAccess = AccessFlags.ShaderReadBit; + _disposed = true; + try + { + // A batch left recording means a render pass never ended, so the flush above withheld it. + // The pool is going away, so reclaim the buffer and retire the releases a submission + // would have run rather than dropping them. + ReclaimUnsubmittedRecording(); + } + finally + { + try + { + if (_inFlightSubmissions.Count == 0) + { + ResetSubmissionSemaphore(); + } + } + finally + { + if (_commandPool.Handle != 0) + { + _vk.DestroyCommandPool(_device, _commandPool, null); + } + } + } } } - public void Dispose() + private void ReclaimUnsubmittedRecording() { - if (_disposed) + if (!_isRecording) return; - _disposed = true; + CommandBuffer commandBuffer = _recordingCommandBuffer; + Action[] releases = [.. _recordingReleases]; + _recordingCommandBuffer = default; + _recordingReleases.Clear(); + _isRecording = false; + _renderPassScopeDepth = 0; + _activeRenderPassOwner = null; - if (_immediateFence.Handle != 0) - { - _vk.DestroyFence(_device, _immediateFence, null); - } + _vk.FreeCommandBuffers(_device, _commandPool, 1, &commandBuffer); + InvokeReleases(releases); + } - if (_commandPool.Handle != 0) + private static void RecordEvent(VulkanCommandPoolEvent eventType) + { + for (ObservationScope? scope = s_observer.Value; scope is not null; scope = scope.Parent) { - _vk.DestroyCommandPool(_device, _commandPool, null); + try + { + scope.Observer(eventType); + } + catch + { + // Diagnostics must never affect rendering or cleanup. + } } + } + + private sealed class InFlightSubmission(CommandBuffer commandBuffer, Fence fence) + { + public CommandBuffer CommandBuffer = commandBuffer; + + public Fence Fence { get; } = fence; + + public List WaitSemaphores { get; } = []; + + public List Releases { get; } = []; + } + + private sealed class ObservationScope( + Action observer, + ObservationScope? parent) : IDisposable + { + private bool _disposed; + + public Action Observer { get; } = observer; - if (_submissionSemaphore.Handle != 0) + public ObservationScope? Parent { get; } = parent; + + public void Dispose() { - _vk.DestroySemaphore(_device, _submissionSemaphore, null); + if (_disposed) + return; + + _disposed = true; + if (ReferenceEquals(s_observer.Value, this)) + { + s_observer.Value = Parent; + } } } } + +internal enum VulkanCommandPoolEvent : byte +{ + Submission, + FenceWait, +} diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanContext.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanContext.cs index 0886488ee5..2228745450 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanContext.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanContext.cs @@ -1,4 +1,5 @@ -using System.Runtime.InteropServices; +using System.Collections.Immutable; +using System.Runtime.InteropServices; using System.Text.Json; using Beutl.Graphics3D; using Beutl.Logging; @@ -10,12 +11,30 @@ namespace Beutl.Graphics.Backend.Vulkan; using Image = Silk.NET.Vulkan.Image; -internal sealed class VulkanContext : IGraphicsContext +internal sealed unsafe class VulkanContext : IGraphicsContext { private static readonly ILogger s_logger = Log.CreateLogger(); + private static readonly AsyncLocal s_textureAllocationObserver = new(); private readonly VulkanInstance _vulkanInstance; private readonly VulkanDevice _vulkanDevice; private readonly VulkanCommandPool _vulkanCommandPool; + private readonly object _skiaImagesLock = new(); + private readonly Dictionary _skiaImages = []; + private readonly VkCreateImageDelegate _createImage; + private readonly VkDestroyImageDelegate _destroyImage; + private readonly VkBindImageMemoryDelegate _bindImageMemory; + private readonly VkBindImageMemory2Delegate? _bindImageMemory2; + private readonly VkBindImageMemory2Delegate? _bindImageMemory2Khr; + private readonly VkCreateImageDelegate _createImageProxyDelegate; + private readonly VkDestroyImageDelegate _destroyImageProxyDelegate; + private readonly VkBindImageMemoryDelegate _bindImageMemoryProxyDelegate; + private readonly VkBindImageMemory2Delegate? _bindImageMemory2ProxyDelegate; + private readonly VkBindImageMemory2Delegate? _bindImageMemory2KhrProxyDelegate; + private readonly IntPtr _createImageProxy; + private readonly IntPtr _destroyImageProxy; + private readonly IntPtr _bindImageMemoryProxy; + private readonly IntPtr _bindImageMemory2Proxy; + private readonly IntPtr _bindImageMemory2KhrProxy; private GRContext? _skiaContext; private GRVkBackendContext? _skiaBackendContext; private bool _disposed; @@ -29,6 +48,31 @@ public VulkanContext(VulkanInstance vulkanInstance, VulkanPhysicalDeviceInfo phy _vulkanDevice.Device, _vulkanDevice.GraphicsQueue, _vulkanDevice.GraphicsQueueFamilyIndex); + _createImage = GetDeviceDelegate("vkCreateImage"); + _destroyImage = GetDeviceDelegate("vkDestroyImage"); + _bindImageMemory = GetDeviceDelegate("vkBindImageMemory"); + // Skia's allocator picks whichever bind entry point the device exposes, so the core 1.1 and KHR + // forms have to carry the same initialization contract as the 1.0 one. Either may be absent. + _bindImageMemory2 = TryGetDeviceDelegate("vkBindImageMemory2"); + _bindImageMemory2Khr = TryGetDeviceDelegate("vkBindImageMemory2KHR"); + // Ganesh creates its filter layers and scratch images through these callbacks. Vulkan + // leaves a newly bound image undefined, and SwiftShader can expose bytes from a previously + // freed allocation, so make initialization part of image binding instead of relying on + // every Skia caller to happen to overwrite the complete allocation. + _createImageProxy = Marshal.GetFunctionPointerForDelegate(_createImageProxyDelegate = CreateSkiaImage); + _destroyImageProxy = Marshal.GetFunctionPointerForDelegate(_destroyImageProxyDelegate = DestroySkiaImage); + _bindImageMemoryProxy = Marshal.GetFunctionPointerForDelegate(_bindImageMemoryProxyDelegate = BindSkiaImageMemory); + if (_bindImageMemory2 is not null) + { + _bindImageMemory2Proxy = Marshal.GetFunctionPointerForDelegate( + _bindImageMemory2ProxyDelegate = BindSkiaImageMemory2); + } + + if (_bindImageMemory2Khr is not null) + { + _bindImageMemory2KhrProxy = Marshal.GetFunctionPointerForDelegate( + _bindImageMemory2KhrProxyDelegate = BindSkiaImageMemory2Khr); + } if (!physicalDevice.IsMoltenVK) { @@ -65,8 +109,23 @@ private void InitializeSkiaVulkanContext() } } - private IntPtr GetVulkanProcAddress(string name, IntPtr instance, IntPtr device) + /// + /// Resolves a Vulkan entry point for Skia, substituting this context's proxies for the image + /// create/destroy/bind calls it intercepts. + /// + internal IntPtr GetVulkanProcAddress(string name, IntPtr instance, IntPtr device) { + if (name == "vkCreateImage") + return _createImageProxy; + if (name == "vkDestroyImage") + return _destroyImageProxy; + if (name == "vkBindImageMemory") + return _bindImageMemoryProxy; + if (name == "vkBindImageMemory2" && _bindImageMemory2Proxy != IntPtr.Zero) + return _bindImageMemory2Proxy; + if (name == "vkBindImageMemory2KHR" && _bindImageMemory2KhrProxy != IntPtr.Zero) + return _bindImageMemory2KhrProxy; + var vk = _vulkanInstance.Vk; if (device != IntPtr.Zero) @@ -88,6 +147,207 @@ private IntPtr GetVulkanProcAddress(string name, IntPtr instance, IntPtr device) return vk.GetInstanceProcAddr(_vulkanInstance.Instance, name); } + private T GetDeviceDelegate(string name) + where T : Delegate + => TryGetDeviceDelegate(name) + ?? throw new InvalidOperationException($"Vulkan device function '{name}' is unavailable."); + + private T? TryGetDeviceDelegate(string name) + where T : Delegate + { + IntPtr address = _vulkanInstance.Vk.GetDeviceProcAddr(_vulkanDevice.Device, name); + return address == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(address); + } + + private unsafe Result CreateSkiaImage( + Device device, + ImageCreateInfo* createInfo, + AllocationCallbacks* allocator, + Image* image) + { + ImageCreateInfo initializedInfo = PrepareSkiaImageCreateInfo(*createInfo); + + Result result = _createImage(device, &initializedInfo, allocator, image); + if (result == Result.Success) + { + lock (_skiaImagesLock) + _skiaImages[image->Handle] = initializedInfo; + } + return result; + } + + private unsafe void DestroySkiaImage( + Device device, + Image image, + AllocationCallbacks* allocator) + { + lock (_skiaImagesLock) + _skiaImages.Remove(image.Handle); + _destroyImage(device, image, allocator); + } + + private unsafe Result BindSkiaImageMemory( + Device device, + Image image, + DeviceMemory memory, + ulong memoryOffset) + { + Result result = _bindImageMemory(device, image, memory, memoryOffset); + ImageCreateInfo createInfo; + lock (_skiaImagesLock) + _skiaImages.TryGetValue(image.Handle, out createInfo); + if (result == Result.Success && RequiresTransparentInitialization(createInfo)) + { + try + { + ClearSkiaImage(image, createInfo); + } + catch (Exception ex) + { + // Never let a managed exception cross the unmanaged Vulkan callback boundary. + // Rejecting the bind makes Skia discard the allocation instead of observing + // an image whose contents were never defined. + s_logger.LogError(ex, "Failed to initialize a Skia Vulkan image."); + return Result.ErrorInitializationFailed; + } + } + return result; + } + + private unsafe Result BindSkiaImageMemory2( + Device device, + uint bindInfoCount, + BindImageMemoryInfo* bindInfos) + => BindSkiaImageMemoryBatch(_bindImageMemory2!, device, bindInfoCount, bindInfos); + + private unsafe Result BindSkiaImageMemory2Khr( + Device device, + uint bindInfoCount, + BindImageMemoryInfo* bindInfos) + => BindSkiaImageMemoryBatch(_bindImageMemory2Khr!, device, bindInfoCount, bindInfos); + + // vkBindImageMemory2 binds the whole batch or none of it, so initialization follows a successful call + // and covers every image in the batch that the single-bind path would have cleared. + private unsafe Result BindSkiaImageMemoryBatch( + VkBindImageMemory2Delegate bind, + Device device, + uint bindInfoCount, + BindImageMemoryInfo* bindInfos) + { + Result result = bind(device, bindInfoCount, bindInfos); + if (result != Result.Success || bindInfos is null) + return result; + + for (uint index = 0; index < bindInfoCount; index++) + { + Image image = bindInfos[index].Image; + ImageCreateInfo createInfo; + lock (_skiaImagesLock) + _skiaImages.TryGetValue(image.Handle, out createInfo); + if (!RequiresTransparentInitialization(createInfo)) + continue; + + try + { + ClearSkiaImage(image, createInfo); + } + catch (Exception ex) + { + // Never let a managed exception cross the unmanaged Vulkan callback boundary. The bind + // already succeeded here, so the batch cannot be undone; reporting the failure makes Skia + // discard the allocation rather than draw from memory whose contents were never defined. + s_logger.LogError(ex, "Failed to initialize a Skia Vulkan image."); + return Result.ErrorInitializationFailed; + } + } + + return result; + } + + internal static ImageCreateInfo PrepareSkiaImageCreateInfo(ImageCreateInfo createInfo) + { + if ((createInfo.Usage & ImageUsageFlags.ColorAttachmentBit) != 0) + createInfo.Usage |= ImageUsageFlags.TransferDstBit; + return createInfo; + } + + internal static bool RequiresTransparentInitialization(ImageCreateInfo createInfo) + => createInfo.InitialLayout == ImageLayout.Undefined + && (createInfo.Usage & ImageUsageFlags.ColorAttachmentBit) != 0; + + internal static ImageSubresourceRange CreateInitializationRange(ImageCreateInfo createInfo) + => new() + { + AspectMask = ImageAspectFlags.ColorBit, + BaseMipLevel = 0, + LevelCount = createInfo.MipLevels, + BaseArrayLayer = 0, + LayerCount = createInfo.ArrayLayers, + }; + + private unsafe void ClearSkiaImage(Image image, ImageCreateInfo createInfo) + { + _vulkanCommandPool.SubmitIsolatedCommands(commandBuffer => + { + ImageSubresourceRange range = CreateInitializationRange(createInfo); + var barrier = new ImageMemoryBarrier + { + SType = StructureType.ImageMemoryBarrier, + OldLayout = ImageLayout.Undefined, + NewLayout = ImageLayout.TransferDstOptimal, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = range, + SrcAccessMask = 0, + DstAccessMask = AccessFlags.TransferWriteBit, + }; + Vk.CmdPipelineBarrier( + commandBuffer, + PipelineStageFlags.TopOfPipeBit, + PipelineStageFlags.TransferBit, + 0, + 0, null, + 0, null, + 1, &barrier); + + var transparent = new ClearColorValue(0, 0, 0, 0); + Vk.CmdClearColorImage( + commandBuffer, + image, + ImageLayout.TransferDstOptimal, + &transparent, + 1, + &range); + }); + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private unsafe delegate Result VkCreateImageDelegate( + Device device, + ImageCreateInfo* createInfo, + AllocationCallbacks* allocator, + Image* image); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private unsafe delegate void VkDestroyImageDelegate( + Device device, + Image image, + AllocationCallbacks* allocator); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private unsafe delegate Result VkBindImageMemoryDelegate( + Device device, + Image image, + DeviceMemory memory, + ulong memoryOffset); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private unsafe delegate Result VkBindImageMemory2Delegate( + Device device, + uint bindInfoCount, + BindImageMemoryInfo* bindInfos); + public GraphicsBackend Backend => GraphicsBackend.Vulkan; public GRContext SkiaContext => _skiaContext ?? throw new InvalidOperationException( @@ -101,6 +361,15 @@ private IntPtr GetVulkanProcAddress(string name, IntPtr instance, IntPtr device) public Device Device => _vulkanDevice.Device; + /// + public bool SupportsShaderInt64 => _vulkanDevice.SupportsShaderInt64; + + /// + public bool SupportsShaderFloat64 => _vulkanDevice.SupportsShaderFloat64; + + /// + public bool SupportsImageCubeArray => _vulkanDevice.SupportsImageCubeArray; + public Queue GraphicsQueue => _vulkanDevice.GraphicsQueue; public uint GraphicsQueueFamilyIndex => _vulkanDevice.GraphicsQueueFamilyIndex; @@ -110,6 +379,31 @@ private IntPtr GetVulkanProcAddress(string name, IntPtr instance, IntPtr device) public bool Supports3DRendering => true; + internal static IDisposable ObserveTextureAllocations(Action observer) + { + ArgumentNullException.ThrowIfNull(observer); + var scope = new TextureAllocationObservationScope(observer, s_textureAllocationObserver.Value); + s_textureAllocationObserver.Value = scope; + return scope; + } + + internal static void RecordTextureAllocation(TextureFormat format) + { + for (TextureAllocationObservationScope? scope = s_textureAllocationObserver.Value; + scope is not null; + scope = scope.Parent) + { + try + { + scope.Observer(format); + } + catch + { + // Diagnostics must never affect texture allocation. + } + } + } + public ITexture2D CreateTexture2D(int width, int height, TextureFormat format) { ImageUsageFlags usage; @@ -123,7 +417,9 @@ public ITexture2D CreateTexture2D(int width, int height, TextureFormat format) usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.SampledBit | ImageUsageFlags.TransferSrcBit | ImageUsageFlags.TransferDstBit; } - return new VulkanTexture2D(this, width, height, format, usage); + var texture = new VulkanTexture2D(this, width, height, format, usage); + RecordTextureAllocation(format); + return texture; } public ITextureCube CreateTextureCube(int size, TextureFormat format) @@ -160,22 +456,75 @@ public IShaderCompiler CreateShaderCompiler() return new VulkanShaderCompiler(); } + /// + /// Resolves a caller-supplied backend resource to its concrete type after confirming this context created + /// it. + /// + /// + /// A Vulkan handle names nothing outside the device that produced it, and mixing two contexts' framebuffers, + /// pipelines, descriptors, or copy operands is undefined behaviour the driver need not report. Rejecting the + /// resource here turns that into an argument error before any handle reaches a native call. + /// + /// is . + /// + /// is not a , or belongs to another context. + /// + internal TResource RequireOwned(object? resource, string parameterName) + where TResource : class, IVulkanContextResource + { + ArgumentNullException.ThrowIfNull(resource, parameterName); + if (resource is not TResource owned) + { + throw new ArgumentException( + $"'{resource.GetType().Name}' is not a {typeof(TResource).Name} created by the Vulkan backend.", + parameterName); + } + + if (!ReferenceEquals(owned.OwnerContext, this)) + { + throw new ArgumentException( + $"The {typeof(TResource).Name} was created by a different Vulkan context; its handles are only " + + "valid on the device that created it.", + parameterName); + } + + return owned; + } + public IRenderPass3D CreateRenderPass3D( IReadOnlyList colorFormats, - TextureFormat depthFormat = TextureFormat.Depth32Float, + TextureFormat? depthFormat, AttachmentLoadOp colorLoadOp = AttachmentLoadOp.Clear, AttachmentLoadOp depthLoadOp = AttachmentLoadOp.Clear) { + if (colorFormats.Any(static format => format.IsDepthFormat())) + { + throw new ArgumentException("Color attachments cannot use a depth format.", nameof(colorFormats)); + } + + if (depthFormat is TextureFormat actualDepthFormat && !actualDepthFormat.IsDepthFormat()) + { + throw new ArgumentException("The depth attachment must use a depth format.", nameof(depthFormat)); + } + var vulkanColorFormats = colorFormats.Select(f => f.ToVulkanFormat()).ToList(); - return new VulkanRenderPass3D(this, vulkanColorFormats, depthFormat.ToVulkanFormat(), colorLoadOp, depthLoadOp); + Format? vulkanDepthFormat = depthFormat?.ToVulkanFormat(); + return new VulkanRenderPass3D(this, vulkanColorFormats, vulkanDepthFormat, colorLoadOp, depthLoadOp); } - public IFramebuffer3D CreateFramebuffer3D(IRenderPass3D renderPass, IReadOnlyList colorTextures, ITexture2D depthTexture) + public IFramebuffer3D CreateFramebuffer3D( + IRenderPass3D renderPass, + IReadOnlyList colorTextures, + ITexture2D? depthTexture) { - var vulkanRenderPass = (VulkanRenderPass3D)renderPass; - var vulkanColorTextures = colorTextures.Cast().ToList(); - var vulkanDepthTexture = (VulkanTexture2D)depthTexture; - return new VulkanFramebuffer3D(this, vulkanRenderPass.Handle, vulkanColorTextures, vulkanDepthTexture); + var vulkanRenderPass = RequireOwned(renderPass, nameof(renderPass)); + List vulkanColorTextures = colorTextures + .Select(texture => RequireOwned(texture, nameof(colorTextures))) + .ToList(); + VulkanTexture2D? vulkanDepthTexture = depthTexture is null + ? null + : RequireOwned(depthTexture, nameof(depthTexture)); + return new VulkanFramebuffer3D(this, vulkanRenderPass, vulkanColorTextures, vulkanDepthTexture); } public IPipeline3D CreatePipeline3D( @@ -186,12 +535,23 @@ public IPipeline3D CreatePipeline3D( VertexInputDescription vertexInput, PipelineOptions? options = null) { - var vulkanRenderPass = (VulkanRenderPass3D)renderPass; + var vulkanRenderPass = RequireOwned(renderPass, nameof(renderPass)); var vulkanBindings = descriptorBindings .Select(VulkanFlagConverter.ToVulkan) .ToArray(); var vulkanVertexInput = VulkanFlagConverter.ToVulkan(vertexInput); var pipelineOptions = options ?? PipelineOptions.Default; + ImmutableArray specializationConstants = + ValidateSpecializationConstants(pipelineOptions.SpecializationConstants, nameof(options)); + ValidateSpecializationConstantPrecision(specializationConstants, nameof(options)); + + if (!vulkanRenderPass.HasDepthAttachment + && (pipelineOptions.DepthTestEnabled || pipelineOptions.DepthWriteEnabled)) + { + throw new ArgumentException( + "A pipeline without a depth attachment cannot enable depth testing or depth writes.", + nameof(options)); + } return new VulkanPipeline3D( this, @@ -200,7 +560,9 @@ public IPipeline3D CreatePipeline3D( fragmentShaderSpirv, vulkanVertexInput, vulkanBindings, + specializationConstants, vulkanRenderPass.ColorAttachmentCount, + vulkanRenderPass.HasDepthAttachment, pipelineOptions.DepthTestEnabled, pipelineOptions.DepthWriteEnabled, VulkanFlagConverter.ToVulkan(pipelineOptions.CullMode), @@ -214,9 +576,107 @@ public IPipeline3D CreatePipeline3D( VulkanFlagConverter.ToVulkan(pipelineOptions.AlphaBlendOp)); } + internal static ImmutableArray ValidateSpecializationConstants( + ImmutableArray constants, + string parameterName) + { + if (constants.IsDefaultOrEmpty) + return []; + + const ShaderStage supportedStages = ShaderStage.Vertex | ShaderStage.Fragment; + var occupiedIds = new HashSet<(ShaderStage Stage, uint ConstantId)>(); + + foreach (SpecializationConstant constant in constants) + { + if (constant.SizeInBytes is not (sizeof(uint) or sizeof(ulong))) + { + throw new ArgumentException( + $"Specialization constant {constant.ConstantId} has an invalid scalar size.", + parameterName); + } + + if (constant.Stages == ShaderStage.None + || (constant.Stages & ~supportedStages) != ShaderStage.None) + { + throw new ArgumentException( + $"Specialization constant {constant.ConstantId} must target only vertex or fragment stages.", + parameterName); + } + + foreach (ShaderStage stage in new[] { ShaderStage.Vertex, ShaderStage.Fragment }) + { + if ((constant.Stages & stage) != stage) + continue; + + if (!occupiedIds.Add((stage, constant.ConstantId))) + { + throw new ArgumentException( + $"Specialization constant {constant.ConstantId} is specified more than once for the {stage} stage.", + parameterName); + } + } + } + + return constants; + } + + /// + /// Rejects a 64-bit specialization constant this device cannot specialize with. + /// + /// + /// Reported here rather than left to vkCreateGraphicsPipelines, whose failure names neither the + /// constant nor the missing feature. + /// + private void ValidateSpecializationConstantPrecision( + ImmutableArray constants, + string parameterName) + { + foreach (SpecializationConstant constant in constants) + { + if (constant.RequiresShaderInt64 && !SupportsShaderInt64) + { + throw new ArgumentException( + $"Specialization constant {constant.ConstantId} is a 64-bit integer, which this Vulkan " + + "device does not support (shaderInt64).", + parameterName); + } + + if (constant.RequiresShaderFloat64 && !SupportsShaderFloat64) + { + throw new ArgumentException( + $"Specialization constant {constant.ConstantId} is a 64-bit float, which this Vulkan " + + "device does not support (shaderFloat64).", + parameterName); + } + } + } + + private sealed class TextureAllocationObservationScope( + Action observer, + TextureAllocationObservationScope? parent) : IDisposable + { + private bool _disposed; + + public Action Observer { get; } = observer; + + public TextureAllocationObservationScope? Parent { get; } = parent; + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + if (ReferenceEquals(s_textureAllocationObserver.Value, this)) + { + s_textureAllocationObserver.Value = Parent; + } + } + } + public IDescriptorSet CreateDescriptorSet(IPipeline3D pipeline, DescriptorPoolSize[] poolSizes) { - var vulkanPipeline = (VulkanPipeline3D)pipeline; + var vulkanPipeline = RequireOwned(pipeline, nameof(pipeline)); var vulkanPoolSizes = poolSizes .Select(VulkanFlagConverter.ToVulkan) .ToArray(); @@ -234,10 +694,10 @@ public ISampler CreateSampler( public unsafe void CopyBuffer(IBuffer source, IBuffer destination, ulong size) { - var vulkanSource = (VulkanBuffer)source; - var vulkanDest = (VulkanBuffer)destination; + var vulkanSource = RequireOwned(source, nameof(source)); + var vulkanDest = RequireOwned(destination, nameof(destination)); - SubmitImmediateCommands(cmd => + RecordCommands(cmd => { var copyRegion = new BufferCopy { Size = size }; Vk.CmdCopyBuffer(cmd, vulkanSource.Handle, vulkanDest.Handle, 1, ©Region); @@ -247,45 +707,17 @@ public unsafe void CopyBuffer(IBuffer source, IBuffer destination, ulong size) public unsafe void CopyTexture(ITexture2D source, ITexture2D destination) { - var vulkanSource = (VulkanTexture2D)source; - var vulkanDest = (VulkanTexture2D)destination; + var vulkanSource = RequireOwned(source, nameof(source)); + var vulkanDest = RequireOwned(destination, nameof(destination)); // Transition source to transfer source layout vulkanSource.TransitionTo(ImageLayout.TransferSrcOptimal); - // Transition destination to transfer destination - SubmitImmediateCommands(cmd => - { - // Transition destination to transfer destination - var barrier = new ImageMemoryBarrier - { - SType = StructureType.ImageMemoryBarrier, - OldLayout = ImageLayout.Undefined, - NewLayout = ImageLayout.TransferDstOptimal, - SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = vulkanDest.ImageHandle, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = ImageAspectFlags.ColorBit, - BaseMipLevel = 0, - LevelCount = 1, - BaseArrayLayer = 0, - LayerCount = 1 - }, - SrcAccessMask = 0, - DstAccessMask = AccessFlags.TransferWriteBit - }; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TopOfPipeBit, - PipelineStageFlags.TransferBit, - 0, - 0, null, - 0, null, - 1, &barrier); + // Track both layouts through the deferred recording batch. + vulkanDest.TransitionTo(ImageLayout.TransferDstOptimal); + RecordCommands(cmd => + { // Use blit for format conversion (RGBA8 -> BGRA8) var blitRegion = new ImageBlit { @@ -319,22 +751,11 @@ public unsafe void CopyTexture(ITexture2D source, ITexture2D destination) 1, &blitRegion, Filter.Nearest); + }); - // Transition destination back to color attachment optimal - barrier.OldLayout = ImageLayout.TransferDstOptimal; - barrier.NewLayout = ImageLayout.ColorAttachmentOptimal; - barrier.SrcAccessMask = AccessFlags.TransferWriteBit; - barrier.DstAccessMask = AccessFlags.ColorAttachmentReadBit | AccessFlags.ColorAttachmentWriteBit; + vulkanDest.MarkContentsUnknown(); - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TransferBit, - PipelineStageFlags.ColorAttachmentOutputBit, - 0, - 0, null, - 0, null, - 1, &barrier); - }); + vulkanDest.TransitionTo(ImageLayout.ColorAttachmentOptimal); // Transition source back to shader read optimal vulkanSource.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); @@ -345,44 +766,15 @@ public unsafe void CopyTextureToCubeFace(ITexture2D source, ITextureCube destina if (faceIndex < 0 || faceIndex >= 6) throw new ArgumentOutOfRangeException(nameof(faceIndex), "Face index must be 0-5"); - var vulkanSource = (VulkanTexture2D)source; - var vulkanDest = (VulkanTextureCube)destination; + var vulkanSource = RequireOwned(source, nameof(source)); + var vulkanDest = RequireOwned(destination, nameof(destination)); // Transition source to transfer source layout vulkanSource.TransitionTo(ImageLayout.TransferSrcOptimal); + vulkanDest.TransitionFaceToTransferDestination(faceIndex); - SubmitImmediateCommands(cmd => + RecordCommands(cmd => { - // Transition cube face to transfer destination - var barrier = new ImageMemoryBarrier - { - SType = StructureType.ImageMemoryBarrier, - OldLayout = ImageLayout.Undefined, - NewLayout = ImageLayout.TransferDstOptimal, - SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = vulkanDest.ImageHandle, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = ImageAspectFlags.DepthBit, - BaseMipLevel = 0, - LevelCount = 1, - BaseArrayLayer = (uint)faceIndex, - LayerCount = 1 - }, - SrcAccessMask = 0, - DstAccessMask = AccessFlags.TransferWriteBit - }; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TopOfPipeBit, - PipelineStageFlags.TransferBit, - 0, - 0, null, - 0, null, - 1, &barrier); - // Copy from 2D texture to cube face var copyRegion = new ImageCopy { @@ -413,23 +805,10 @@ public unsafe void CopyTextureToCubeFace(ITexture2D source, ITextureCube destina ImageLayout.TransferDstOptimal, 1, ©Region); - - // Transition cube face to shader read optimal - barrier.OldLayout = ImageLayout.TransferDstOptimal; - barrier.NewLayout = ImageLayout.ShaderReadOnlyOptimal; - barrier.SrcAccessMask = AccessFlags.TransferWriteBit; - barrier.DstAccessMask = AccessFlags.ShaderReadBit; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TransferBit, - PipelineStageFlags.FragmentShaderBit, - 0, - 0, null, - 0, null, - 1, &barrier); }); + vulkanDest.TransitionFaceToSampled(faceIndex); + // Transition source back to shader read optimal vulkanSource.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); } @@ -439,8 +818,8 @@ public unsafe void CopyTextureToArrayLayer(ITexture2D source, ITextureArray dest if (layerIndex < 0 || layerIndex >= (int)destination.ArraySize) throw new ArgumentOutOfRangeException(nameof(layerIndex), $"Layer index must be 0-{destination.ArraySize - 1}"); - var vulkanSource = (VulkanTexture2D)source; - var vulkanDest = (VulkanTextureArray)destination; + var vulkanSource = RequireOwned(source, nameof(source)); + var vulkanDest = RequireOwned(destination, nameof(destination)); // Determine aspect mask based on format var aspectMask = source.Format.IsDepthFormat() @@ -449,39 +828,10 @@ public unsafe void CopyTextureToArrayLayer(ITexture2D source, ITextureArray dest // Transition source to transfer source layout vulkanSource.TransitionTo(ImageLayout.TransferSrcOptimal); + vulkanDest.TransitionLayerToTransferDestination((uint)layerIndex); - SubmitImmediateCommands(cmd => + RecordCommands(cmd => { - // Transition array layer to transfer destination - var barrier = new ImageMemoryBarrier - { - SType = StructureType.ImageMemoryBarrier, - OldLayout = ImageLayout.Undefined, - NewLayout = ImageLayout.TransferDstOptimal, - SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = vulkanDest.ImageHandle, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = aspectMask, - BaseMipLevel = 0, - LevelCount = 1, - BaseArrayLayer = (uint)layerIndex, - LayerCount = 1 - }, - SrcAccessMask = 0, - DstAccessMask = AccessFlags.TransferWriteBit - }; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TopOfPipeBit, - PipelineStageFlags.TransferBit, - 0, - 0, null, - 0, null, - 1, &barrier); - // Copy from 2D texture to array layer var copyRegion = new ImageCopy { @@ -512,23 +862,10 @@ public unsafe void CopyTextureToArrayLayer(ITexture2D source, ITextureArray dest ImageLayout.TransferDstOptimal, 1, ©Region); - - // Transition array layer to shader read optimal - barrier.OldLayout = ImageLayout.TransferDstOptimal; - barrier.NewLayout = ImageLayout.ShaderReadOnlyOptimal; - barrier.SrcAccessMask = AccessFlags.TransferWriteBit; - barrier.DstAccessMask = AccessFlags.ShaderReadBit; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TransferBit, - PipelineStageFlags.FragmentShaderBit, - 0, - 0, null, - 0, null, - 1, &barrier); }); + vulkanDest.TransitionLayerToSampled((uint)layerIndex); + // Transition source back to shader read optimal vulkanSource.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); } @@ -540,8 +877,8 @@ public unsafe void CopyTextureToCubeArrayFace(ITexture2D source, ITextureCubeArr if (faceIndex < 0 || faceIndex >= 6) throw new ArgumentOutOfRangeException(nameof(faceIndex), "Face index must be 0-5"); - var vulkanSource = (VulkanTexture2D)source; - var vulkanDest = (VulkanTextureCubeArray)destination; + var vulkanSource = RequireOwned(source, nameof(source)); + var vulkanDest = RequireOwned(destination, nameof(destination)); // Determine aspect mask based on format var aspectMask = source.Format.IsDepthFormat() @@ -553,39 +890,10 @@ public unsafe void CopyTextureToCubeArrayFace(ITexture2D source, ITextureCubeArr // Transition source to transfer source layout vulkanSource.TransitionTo(ImageLayout.TransferSrcOptimal); + vulkanDest.TransitionFaceToTransferDestination((uint)arrayIndex, faceIndex); - SubmitImmediateCommands(cmd => + RecordCommands(cmd => { - // Transition cube array face to transfer destination - var barrier = new ImageMemoryBarrier - { - SType = StructureType.ImageMemoryBarrier, - OldLayout = ImageLayout.Undefined, - NewLayout = ImageLayout.TransferDstOptimal, - SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, - DstQueueFamilyIndex = Vk.QueueFamilyIgnored, - Image = vulkanDest.ImageHandle, - SubresourceRange = new ImageSubresourceRange - { - AspectMask = aspectMask, - BaseMipLevel = 0, - LevelCount = 1, - BaseArrayLayer = layerIndex, - LayerCount = 1 - }, - SrcAccessMask = 0, - DstAccessMask = AccessFlags.TransferWriteBit - }; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TopOfPipeBit, - PipelineStageFlags.TransferBit, - 0, - 0, null, - 0, null, - 1, &barrier); - // Copy from 2D texture to cube array face var copyRegion = new ImageCopy { @@ -616,35 +924,28 @@ public unsafe void CopyTextureToCubeArrayFace(ITexture2D source, ITextureCubeArr ImageLayout.TransferDstOptimal, 1, ©Region); - - // Transition cube array face to shader read optimal - barrier.OldLayout = ImageLayout.TransferDstOptimal; - barrier.NewLayout = ImageLayout.ShaderReadOnlyOptimal; - barrier.SrcAccessMask = AccessFlags.TransferWriteBit; - barrier.DstAccessMask = AccessFlags.ShaderReadBit; - - Vk.CmdPipelineBarrier( - cmd, - PipelineStageFlags.TransferBit, - PipelineStageFlags.FragmentShaderBit, - 0, - 0, null, - 0, null, - 1, &barrier); }); + vulkanDest.TransitionFaceToSampled((uint)arrayIndex, faceIndex); + // Transition source back to shader read optimal vulkanSource.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); } public void WaitIdle() { + _vulkanCommandPool.Flush(waitForCompletion: true); _vulkanDevice.WaitIdle(); } - public void SubmitImmediateCommands(Action record) + public void RecordCommands(Action record) + { + _vulkanCommandPool.RecordCommands(record); + } + + internal void SubmitIsolatedCommands(Action record) { - _vulkanCommandPool.SubmitImmediateCommands(record); + _vulkanCommandPool.SubmitIsolatedCommands(record); } public void TransitionImageLayout(Image image, ImageLayout oldLayout, ImageLayout newLayout) @@ -668,14 +969,37 @@ public void TransitionImageLayout( _vulkanCommandPool.TransitionImageLayout(image, oldLayout, newLayout, aspectMask, baseArrayLayer, layerCount); } - public CommandBuffer AllocateCommandBuffer() + public CommandBuffer GetRecordingCommandBuffer() + { + return _vulkanCommandPool.GetRecordingCommandBuffer(); + } + + public void FlushCommands(bool waitForCompletion) + { + _vulkanCommandPool.Flush(waitForCompletion); + } + + /// + public void ThrowIfRenderPassActive() + { + _vulkanCommandPool.ThrowIfRenderPassActive(); + } + + /// + public void BeginRenderPassScope(IVulkanRenderPassSuspension owner) { - return _vulkanCommandPool.AllocateCommandBuffer(); + _vulkanCommandPool.BeginRenderPassScope(owner); } - public void SubmitCommandBuffer(CommandBuffer commandBuffer) + /// + public void EndRenderPassScope(IVulkanRenderPassSuspension owner) { - _vulkanCommandPool.SubmitCommandBuffer(commandBuffer); + _vulkanCommandPool.EndRenderPassScope(owner); + } + + public void DeferRelease(Action release) + { + _vulkanCommandPool.DeferRelease(release); } @@ -706,14 +1030,31 @@ public void Dispose() _disposed = true; - _vulkanDevice.WaitIdle(); - - _skiaContext?.Dispose(); - _skiaContext = null; - _skiaBackendContext?.Dispose(); - _skiaBackendContext = null; - - _vulkanCommandPool.Dispose(); - _vulkanDevice.Dispose(); + try + { + _vulkanCommandPool.Flush(waitForCompletion: true); + _vulkanDevice.WaitIdle(); + } + finally + { + try + { + _skiaContext?.Dispose(); + _skiaContext = null; + _skiaBackendContext?.Dispose(); + _skiaBackendContext = null; + } + finally + { + try + { + _vulkanCommandPool.Dispose(); + } + finally + { + _vulkanDevice.Dispose(); + } + } + } } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDescriptorSet.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDescriptorSet.cs index 200bf2fbec..9456f10c0f 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDescriptorSet.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDescriptorSet.cs @@ -6,7 +6,7 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of . /// -internal sealed unsafe class VulkanDescriptorSet : IDescriptorSet +internal sealed unsafe class VulkanDescriptorSet : IDescriptorSet, IVulkanContextResource { private readonly VulkanContext _context; private readonly DescriptorPool _descriptorPool; @@ -14,6 +14,8 @@ internal sealed unsafe class VulkanDescriptorSet : IDescriptorSet private readonly DescriptorSetLayout _layout; private bool _disposed; + public VulkanContext OwnerContext => _context; + public VulkanDescriptorSet(VulkanContext context, DescriptorSetLayout layout, Silk.NET.Vulkan.DescriptorPoolSize[] poolSizes) { _context = context; @@ -144,6 +146,8 @@ public void Dispose() _disposed = true; // Descriptor sets are automatically freed when the pool is destroyed - _context.Vk.DestroyDescriptorPool(_context.Device, _descriptorPool, null); + DescriptorPool descriptorPool = _descriptorPool; + _context.DeferRelease(() => + _context.Vk.DestroyDescriptorPool(_context.Device, descriptorPool, null)); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDevice.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDevice.cs index 86c262a1bc..daff69772d 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDevice.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanDevice.cs @@ -15,6 +15,7 @@ internal sealed unsafe class VulkanDevice : IDisposable private readonly Queue _graphicsQueue; private readonly uint _graphicsQueueFamilyIndex; private readonly string[] _enabledExtensions; + private readonly PhysicalDeviceFeatures _enabledFeatures; private bool _disposed; public VulkanDevice(Vk vk, Instance instance, PhysicalDevice physicalDevice) @@ -25,7 +26,7 @@ public VulkanDevice(Vk vk, Instance instance, PhysicalDevice physicalDevice) _graphicsQueueFamilyIndex = FindGraphicsQueueFamily(); _enabledExtensions = GetRequiredDeviceExtensions(); - _device = CreateDevice(_enabledExtensions); + _device = CreateDevice(_enabledExtensions, out _enabledFeatures); _vk.GetDeviceQueue(_device, _graphicsQueueFamilyIndex, 0, out _graphicsQueue); @@ -49,6 +50,15 @@ public VulkanDevice(Vk vk, Instance instance, PhysicalDevice physicalDevice) public string[] EnabledExtensions => _enabledExtensions; + /// Whether the logical device enabled 64-bit integer arithmetic in shaders. + public bool SupportsShaderInt64 => _enabledFeatures.ShaderInt64; + + /// Whether the logical device enabled 64-bit floating-point arithmetic in shaders. + public bool SupportsShaderFloat64 => _enabledFeatures.ShaderFloat64; + + /// Whether the logical device enabled cube-array image views and sampling. + public bool SupportsImageCubeArray => _enabledFeatures.ImageCubeArray; + private uint FindGraphicsQueueFamily() { @@ -108,7 +118,7 @@ private string[] GetRequiredDeviceExtensions() return extensions.ToArray(); } - private Device CreateDevice(string[] extensions) + private Device CreateDevice(string[] extensions, out PhysicalDeviceFeatures enabledFeatures) { float queuePriority = 1.0f; var queueCreateInfo = new DeviceQueueCreateInfo @@ -119,7 +129,22 @@ private Device CreateDevice(string[] extensions) PQueuePriorities = &queuePriority }; - var features = new PhysicalDeviceFeatures(); + // A feature the engine's own code uses has to be requested here: advertising it on the physical + // device is not enough, and using it without requesting it is undefined behaviour the driver need + // not report. Requesting only what this device already reports costs nothing. + // - shaderInt64/shaderFloat64: a 64-bit specialization constant, or any shader declaring a 64-bit + // scalar, fails pipeline creation without them. + // - imageCubeArray: point-light shadows sample every cube at once, which needs a + // VK_IMAGE_VIEW_TYPE_CUBE_ARRAY view and the SampledCubeArray SPIR-V capability. + PhysicalDeviceFeatures available; + _vk.GetPhysicalDeviceFeatures(_physicalDevice, &available); + var features = new PhysicalDeviceFeatures + { + ShaderInt64 = available.ShaderInt64, + ShaderFloat64 = available.ShaderFloat64, + ImageCubeArray = available.ImageCubeArray, + }; + enabledFeatures = features; var createInfo = new DeviceCreateInfo { diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanFramebuffer3D.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanFramebuffer3D.cs index 00f89e33d9..50bc7d7ac7 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanFramebuffer3D.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanFramebuffer3D.cs @@ -7,26 +7,29 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of with MRT support. /// -internal sealed unsafe class VulkanFramebuffer3D : IFramebuffer3D +internal sealed unsafe class VulkanFramebuffer3D : IFramebuffer3D, IVulkanContextResource { private readonly VulkanContext _context; + private readonly VulkanRenderPass3D _renderPass; private readonly Framebuffer _framebuffer; private readonly List _colorTextures; - private readonly VulkanTexture2D _depthTexture; + private readonly VulkanTexture2D? _depthTexture; private readonly bool _ownsColorTextures; private readonly bool _ownsDepthTexture; private readonly int _width; private readonly int _height; private bool _disposed; + public VulkanContext OwnerContext => _context; + /// - /// Creates a framebuffer with the specified color and depth textures. + /// Creates a framebuffer with the specified color textures and optional depth texture. /// public VulkanFramebuffer3D( VulkanContext context, - RenderPass renderPass, + VulkanRenderPass3D renderPass, IReadOnlyList colorTextures, - VulkanTexture2D depthTexture, + VulkanTexture2D? depthTexture, bool ownsColorTextures = false, bool ownsDepthTexture = false) { @@ -35,7 +38,39 @@ public VulkanFramebuffer3D( throw new ArgumentException("At least one color texture is required", nameof(colorTextures)); } + if (colorTextures.Count != renderPass.ColorAttachmentCount) + { + throw new ArgumentException( + "The framebuffer color attachment count must match the render pass.", + nameof(colorTextures)); + } + + if (renderPass.HasDepthAttachment != (depthTexture is not null)) + { + throw new ArgumentException( + "The framebuffer depth attachment must match the render pass.", + nameof(depthTexture)); + } + + for (int i = 0; i < colorTextures.Count; i++) + { + if (colorTextures[i].Format.ToVulkanFormat() != renderPass.ColorFormats[i]) + { + throw new ArgumentException( + $"Color attachment {i} format must match the render pass.", + nameof(colorTextures)); + } + } + + if (depthTexture is not null && depthTexture.Format.ToVulkanFormat() != renderPass.DepthFormat) + { + throw new ArgumentException( + "The depth attachment format must match the render pass.", + nameof(depthTexture)); + } + _context = context; + _renderPass = renderPass; _colorTextures = new List(colorTextures); _depthTexture = depthTexture; _ownsColorTextures = ownsColorTextures; @@ -43,23 +78,36 @@ public VulkanFramebuffer3D( _width = colorTextures[0].Width; _height = colorTextures[0].Height; + foreach (VulkanTexture2D colorTexture in colorTextures) + { + ValidateDimensions(colorTexture, _width, _height, nameof(colorTextures)); + } + + if (depthTexture is not null) + { + ValidateDimensions(depthTexture, _width, _height, nameof(depthTexture)); + } + var vk = context.Vk; var device = context.Device; // Create framebuffer with all attachments - int attachmentCount = colorTextures.Count + 1; // colors + depth + int attachmentCount = colorTextures.Count + (depthTexture is not null ? 1 : 0); var attachments = stackalloc ImageView[attachmentCount]; for (int i = 0; i < colorTextures.Count; i++) { attachments[i] = colorTextures[i].ImageViewHandle; } - attachments[colorTextures.Count] = depthTexture.ImageViewHandle; + if (depthTexture is not null) + { + attachments[colorTextures.Count] = depthTexture.ImageViewHandle; + } var framebufferInfo = new FramebufferCreateInfo { SType = StructureType.FramebufferCreateInfo, - RenderPass = renderPass, + RenderPass = renderPass.Handle, AttachmentCount = (uint)attachmentCount, PAttachments = attachments, Width = (uint)_width, @@ -82,26 +130,34 @@ public VulkanFramebuffer3D( public IReadOnlyList ColorTextures => _colorTextures; - public ITexture2D DepthTexture => _depthTexture; + public ITexture2D? DepthTexture => _depthTexture; public Framebuffer Handle => _framebuffer; + public bool IsCompatibleWith(VulkanRenderPass3D renderPass) => ReferenceEquals(_renderPass, renderPass); + public void PrepareForSampling() { foreach (var texture in _colorTextures) { texture.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); } - _depthTexture.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); + _depthTexture?.TransitionTo(ImageLayout.ShaderReadOnlyOptimal); } + /// + /// A pass writes its attachments, so whatever the backend recorded about their contents stops being + /// true here. Leaving a transparent record standing would let the next caller that wants a blank target + /// skip its clear and get the pass's output instead. + /// public void PrepareForRendering() { foreach (var texture in _colorTextures) { texture.TransitionTo(ImageLayout.ColorAttachmentOptimal); + texture.MarkContentsUnknown(); } - _depthTexture.TransitionTo(ImageLayout.DepthStencilAttachmentOptimal); + _depthTexture?.TransitionTo(ImageLayout.DepthStencilAttachmentOptimal); } public void Dispose() @@ -109,7 +165,9 @@ public void Dispose() if (_disposed) return; _disposed = true; - _context.Vk.DestroyFramebuffer(_context.Device, _framebuffer, null); + Framebuffer framebuffer = _framebuffer; + _context.DeferRelease(() => + _context.Vk.DestroyFramebuffer(_context.Device, framebuffer, null)); if (_ownsColorTextures) { @@ -119,9 +177,19 @@ public void Dispose() } } - if (_ownsDepthTexture) + if (_ownsDepthTexture && _depthTexture is not null) { _depthTexture.Dispose(); } } + + private static void ValidateDimensions(VulkanTexture2D texture, int width, int height, string paramName) + { + if (texture.Width != width || texture.Height != height) + { + throw new ArgumentException( + "All framebuffer attachments must have identical dimensions.", + paramName); + } + } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanInstance.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanInstance.cs index 3c5055e0f7..cd95e0f0e4 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanInstance.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanInstance.cs @@ -65,9 +65,23 @@ public VulkanInstance(Vk vk, bool enableValidation) _enabledExtensions = GetRequiredInstanceExtensions(); _instance = CreateInstance(_enabledExtensions); - if (_enableValidation && _vk.TryGetInstanceExtension(_instance, out _debugUtils)) + if (_enableValidation) { - _debugMessenger = CreateDebugMessenger(); + try + { + if (!_vk.TryGetInstanceExtension(_instance, out _debugUtils)) + { + throw new InvalidOperationException( + $"Vulkan validation was requested, but {ExtDebugUtils.ExtensionName} could not be loaded."); + } + + _debugMessenger = CreateDebugMessenger(); + } + catch + { + _vk.DestroyInstance(_instance, null); + throw; + } } } @@ -221,12 +235,17 @@ private Instance CreateInstance(string[] extensions) validationLayers = new[] { "VK_LAYER_KHRONOS_validation" }; if (!CheckValidationLayerSupport(validationLayers)) { - s_logger.LogWarning("Validation layers requested but not available, continuing without them."); - validationLayers = Array.Empty(); + throw new InvalidOperationException( + "Vulkan validation was requested, but VK_LAYER_KHRONOS_validation is not available."); } } var availableExtensions = EnumerateInstanceExtensions(); + if (_enableValidation && !availableExtensions.Contains(ExtDebugUtils.ExtensionName)) + { + throw new InvalidOperationException( + $"Vulkan validation was requested, but {ExtDebugUtils.ExtensionName} is not available."); + } var filteredExtensions = extensions.Where(e => availableExtensions.Contains(e)).ToArray(); var appNamePtr = Marshal.StringToHGlobalAnsi("Beutl"); @@ -379,7 +398,7 @@ private DebugUtilsMessengerEXT CreateDebugMessenger() var result = _debugUtils!.CreateDebugUtilsMessenger(_instance, &createInfo, null, &messenger); if (result != Result.Success) { - s_logger.LogError("Failed to create debug messenger: {Result}", result); + throw new InvalidOperationException($"Failed to create Vulkan debug messenger: {result}"); } return messenger; @@ -392,6 +411,20 @@ private static uint DebugCallback( void* userData) { var message = Marshal.PtrToStringAnsi((IntPtr)callbackData->PMessage); + if ((severity & DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt) != 0 + && (type & DebugUtilsMessageTypeFlagsEXT.ValidationBitExt) != 0) + { + try + { + VulkanValidationErrorLog.Shared.Record(message); + } + catch + { + // Never let a managed exception cross the unmanaged Vulkan callback boundary. Losing the + // record is better than tearing down the driver's reporting thread. + } + } + switch (severity) { #pragma warning disable CA2254, CA1873 diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanPipeline3D.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanPipeline3D.cs index 7923fab0e6..2d258284cd 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanPipeline3D.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanPipeline3D.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Numerics; using System.Runtime.InteropServices; using Silk.NET.Vulkan; @@ -8,9 +9,10 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of . /// -internal sealed unsafe class VulkanPipeline3D : IPipeline3D +internal sealed unsafe class VulkanPipeline3D : IPipeline3D, IVulkanContextResource { private readonly VulkanContext _context; + private readonly RenderPass _compatibleRenderPass; private readonly Pipeline _pipeline; private readonly PipelineLayout _pipelineLayout; private readonly DescriptorSetLayout _descriptorSetLayout; @@ -18,6 +20,8 @@ internal sealed unsafe class VulkanPipeline3D : IPipeline3D private readonly ShaderModule _fragmentShader; private bool _disposed; + public VulkanContext OwnerContext => _context; + public VulkanPipeline3D( VulkanContext context, RenderPass renderPass, @@ -25,7 +29,9 @@ public VulkanPipeline3D( byte[] fragmentShaderSpirv, VulkanVertexInputDescription vertexInputDescription, DescriptorSetLayoutBinding[] descriptorBindings, - int colorAttachmentCount = 1, + ImmutableArray specializationConstants, + int colorAttachmentCount, + bool hasDepthAttachment, bool depthTestEnabled = true, bool depthWriteEnabled = true, CullModeFlags cullMode = CullModeFlags.BackBit, @@ -39,6 +45,7 @@ public VulkanPipeline3D( Silk.NET.Vulkan.BlendOp alphaBlendOp = Silk.NET.Vulkan.BlendOp.Add) { _context = context; + _compatibleRenderPass = renderPass; var vk = context.Vk; var device = context.Device; @@ -66,13 +73,14 @@ public VulkanPipeline3D( _descriptorSetLayout = descriptorLayout; } - // Create pipeline layout with push constants support - // Use 128 bytes which is the minimum guaranteed by Vulkan + // One range covering both stages over the 128 bytes Vulkan guarantees. Because the range spans both + // stages, every vkCmdPushConstants against this layout must name both: the spec requires the update + // to cover all stages of every range it overlaps. var pushConstantRange = new PushConstantRange { - StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + StageFlags = PushConstantStages, Offset = 0, - Size = 128 + Size = MaxPushConstantsSize }; var layouts = stackalloc DescriptorSetLayout[] { _descriptorSetLayout }; @@ -98,43 +106,100 @@ public VulkanPipeline3D( // Create graphics pipeline _pipeline = CreateGraphicsPipeline( vk, device, renderPass, vertexInputDescription, colorAttachmentCount, - depthTestEnabled, depthWriteEnabled, cullMode, frontFace, + hasDepthAttachment, depthTestEnabled, depthWriteEnabled, cullMode, frontFace, blendEnabled, srcColorBlendFactor, dstColorBlendFactor, - srcAlphaBlendFactor, dstAlphaBlendFactor, colorBlendOp, alphaBlendOp); + srcAlphaBlendFactor, dstAlphaBlendFactor, colorBlendOp, alphaBlendOp, + specializationConstants); } + /// The stages the pipeline layout's push-constant range covers. + /// + /// Every vkCmdPushConstants against this layout must pass exactly these: the spec requires an + /// update to name all stages of every range it overlaps, and this layout declares one range spanning + /// them. Reading it from here rather than from the caller is what keeps the two in step. + /// + public const ShaderStageFlags PushConstantStages = + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit; + + /// The size of the pipeline layout's push-constant range, in bytes. + /// 128 is the minimum every Vulkan implementation guarantees. + public const uint MaxPushConstantsSize = 128; + public Pipeline Handle => _pipeline; public PipelineLayout PipelineLayoutHandle => _pipelineLayout; public DescriptorSetLayout DescriptorSetLayoutHandle => _descriptorSetLayout; + /// Whether this pipeline was created for . + /// + /// The owning context is compared before the handle: two contexts allocate handles independently, so an + /// equal handle value from a foreign device says nothing about compatibility. + /// + public bool IsCompatibleWith(VulkanRenderPass3D renderPass) + { + ArgumentNullException.ThrowIfNull(renderPass); + return ReferenceEquals(_context, renderPass.OwnerContext) + && _compatibleRenderPass.Handle == renderPass.Handle.Handle; + } + private Pipeline CreateGraphicsPipeline( Vk vk, Device device, RenderPass renderPass, VulkanVertexInputDescription vertexInput, - int colorAttachmentCount, bool depthTestEnabled, bool depthWriteEnabled, + int colorAttachmentCount, bool hasDepthAttachment, bool depthTestEnabled, bool depthWriteEnabled, CullModeFlags cullMode, Silk.NET.Vulkan.FrontFace frontFace, bool blendEnabled, Silk.NET.Vulkan.BlendFactor srcColorBlendFactor, Silk.NET.Vulkan.BlendFactor dstColorBlendFactor, Silk.NET.Vulkan.BlendFactor srcAlphaBlendFactor, Silk.NET.Vulkan.BlendFactor dstAlphaBlendFactor, Silk.NET.Vulkan.BlendOp colorBlendOp, - Silk.NET.Vulkan.BlendOp alphaBlendOp) + Silk.NET.Vulkan.BlendOp alphaBlendOp, + ImmutableArray specializationConstants) { + VulkanSpecializationData vertexSpecialization = CreateSpecializationData( + specializationConstants, + ShaderStage.Vertex); + VulkanSpecializationData fragmentSpecialization = CreateSpecializationData( + specializationConstants, + ShaderStage.Fragment); var mainBytes = System.Text.Encoding.UTF8.GetBytes("main\0"); fixed (byte* mainPtr = mainBytes) + fixed (SpecializationMapEntry* vertexEntriesPtr = vertexSpecialization.MapEntries) + fixed (byte* vertexDataPtr = vertexSpecialization.Data) + fixed (SpecializationMapEntry* fragmentEntriesPtr = fragmentSpecialization.MapEntries) + fixed (byte* fragmentDataPtr = fragmentSpecialization.Data) { + var vertexSpecializationInfo = new SpecializationInfo + { + MapEntryCount = (uint)vertexSpecialization.MapEntries.Length, + PMapEntries = vertexEntriesPtr, + DataSize = (nuint)vertexSpecialization.Data.Length, + PData = vertexDataPtr, + }; + var fragmentSpecializationInfo = new SpecializationInfo + { + MapEntryCount = (uint)fragmentSpecialization.MapEntries.Length, + PMapEntries = fragmentEntriesPtr, + DataSize = (nuint)fragmentSpecialization.Data.Length, + PData = fragmentDataPtr, + }; var shaderStages = stackalloc PipelineShaderStageCreateInfo[2]; shaderStages[0] = new PipelineShaderStageCreateInfo { SType = StructureType.PipelineShaderStageCreateInfo, Stage = ShaderStageFlags.VertexBit, Module = _vertexShader, - PName = mainPtr + PName = mainPtr, + PSpecializationInfo = vertexSpecialization.MapEntries.Length == 0 + ? null + : &vertexSpecializationInfo, }; shaderStages[1] = new PipelineShaderStageCreateInfo { SType = StructureType.PipelineShaderStageCreateInfo, Stage = ShaderStageFlags.FragmentBit, Module = _fragmentShader, - PName = mainPtr + PName = mainPtr, + PSpecializationInfo = fragmentSpecialization.MapEntries.Length == 0 + ? null + : &fragmentSpecializationInfo, }; // Vertex input state @@ -237,7 +302,7 @@ private Pipeline CreateGraphicsPipeline( PViewportState = &viewportState, PRasterizationState = &rasterizer, PMultisampleState = &multisampling, - PDepthStencilState = &depthStencil, + PDepthStencilState = hasDepthAttachment ? &depthStencil : null, PColorBlendState = &colorBlending, PDynamicState = &dynamicState, Layout = _pipelineLayout, @@ -256,6 +321,38 @@ private Pipeline CreateGraphicsPipeline( } } + private static VulkanSpecializationData CreateSpecializationData( + ImmutableArray constants, + ShaderStage stage) + { + SpecializationConstant[] stageConstants = constants + .Where(constant => (constant.Stages & stage) == stage) + .OrderBy(constant => constant.ConstantId) + .ToArray(); + var entries = new SpecializationMapEntry[stageConstants.Length]; + var data = new byte[stageConstants.Sum(constant => constant.SizeInBytes)]; + int offset = 0; + + for (int i = 0; i < stageConstants.Length; i++) + { + SpecializationConstant constant = stageConstants[i]; + entries[i] = new SpecializationMapEntry + { + ConstantID = constant.ConstantId, + Offset = (uint)offset, + Size = (nuint)constant.SizeInBytes, + }; + constant.CopyValueTo(data.AsSpan(offset, constant.SizeInBytes)); + offset += constant.SizeInBytes; + } + + return new VulkanSpecializationData(entries, data); + } + + private sealed record VulkanSpecializationData( + SpecializationMapEntry[] MapEntries, + byte[] Data); + private static ShaderModule CreateShaderModule(Vk vk, Device device, byte[] spirv) { fixed (byte* codePtr = spirv) @@ -296,19 +393,30 @@ public void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; + Pipeline pipeline = _pipeline; + PipelineLayout pipelineLayout = _pipelineLayout; + DescriptorSetLayout descriptorSetLayout = _descriptorSetLayout; + ShaderModule vertexShader = _vertexShader; + ShaderModule fragmentShader = _fragmentShader; + _context.DeferRelease(() => + { + var vk = _context.Vk; + var device = _context.Device; - if (_pipeline.Handle != 0) - vk.DestroyPipeline(device, _pipeline, null); + if (pipeline.Handle != 0) + vk.DestroyPipeline(device, pipeline, null); - if (_pipelineLayout.Handle != 0) - vk.DestroyPipelineLayout(device, _pipelineLayout, null); + if (pipelineLayout.Handle != 0) + vk.DestroyPipelineLayout(device, pipelineLayout, null); - if (_descriptorSetLayout.Handle != 0) - vk.DestroyDescriptorSetLayout(device, _descriptorSetLayout, null); + if (descriptorSetLayout.Handle != 0) + vk.DestroyDescriptorSetLayout(device, descriptorSetLayout, null); - CleanupShaderModules(vk, device); + if (vertexShader.Handle != 0) + vk.DestroyShaderModule(device, vertexShader, null); + if (fragmentShader.Handle != 0) + vk.DestroyShaderModule(device, fragmentShader, null); + }); } } @@ -320,4 +428,3 @@ internal struct VulkanVertexInputDescription public VertexInputBindingDescription[] Bindings; public VertexInputAttributeDescription[] Attributes; } - diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanRenderPass3D.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanRenderPass3D.cs index 10b9410d66..247995f81d 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanRenderPass3D.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanRenderPass3D.cs @@ -8,28 +8,35 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of with MRT support. /// -internal sealed unsafe class VulkanRenderPass3D : IRenderPass3D +internal sealed unsafe class VulkanRenderPass3D : IRenderPass3D, IVulkanContextResource, IVulkanRenderPassSuspension { private readonly VulkanContext _context; private readonly RenderPass _renderPass; + private readonly Format[] _colorFormats; + private readonly Format? _depthFormat; private readonly int _colorAttachmentCount; private CommandBuffer _currentCommandBuffer; private VulkanPipeline3D? _currentPipeline; + private VulkanFramebuffer3D? _currentFramebuffer; + private RenderPass _resumeRenderPass; private bool _inRenderPass; + private bool _suspended; private bool _disposed; + public VulkanContext OwnerContext => _context; + /// - /// Creates a render pass with the specified color and depth formats. + /// Creates a render pass with the specified color formats and optional depth format. /// /// The Vulkan context. /// Formats for each color attachment. - /// Format for the depth attachment. + /// Format for the depth attachment, or null for a color-only pass. /// The load operation for color attachments. /// The load operation for the depth attachment. public VulkanRenderPass3D( VulkanContext context, IReadOnlyList colorFormats, - Format depthFormat = Format.D32Sfloat, + Format? depthFormat, AttachmentLoadOp colorLoadOp = AttachmentLoadOp.Clear, AttachmentLoadOp depthLoadOp = AttachmentLoadOp.Clear) { @@ -39,20 +46,20 @@ public VulkanRenderPass3D( } _context = context; + _colorFormats = [.. colorFormats]; + _depthFormat = depthFormat; _colorAttachmentCount = colorFormats.Count; var vk = context.Vk; var device = context.Device; - int totalAttachments = colorFormats.Count + 1; // colors + depth + int totalAttachments = colorFormats.Count + (depthFormat.HasValue ? 1 : 0); // Create attachment descriptions var attachments = stackalloc AttachmentDescription[totalAttachments]; var colorAttachmentRefs = stackalloc AttachmentReference[colorFormats.Count]; var vulkanColorLoadOp = ToVulkanLoadOp(colorLoadOp); - var vulkanDepthLoadOp = ToVulkanLoadOp(depthLoadOp); - for (int i = 0; i < colorFormats.Count; i++) { attachments[i] = new AttachmentDescription @@ -74,41 +81,52 @@ public VulkanRenderPass3D( }; } - // Depth attachment (last) - attachments[colorFormats.Count] = new AttachmentDescription - { - Format = depthFormat, - Samples = SampleCountFlags.Count1Bit, - LoadOp = vulkanDepthLoadOp, - StoreOp = AttachmentStoreOp.Store, // Store depth for shadow mapping - StencilLoadOp = Silk.NET.Vulkan.AttachmentLoadOp.DontCare, - StencilStoreOp = AttachmentStoreOp.DontCare, - InitialLayout = ImageLayout.DepthStencilAttachmentOptimal, - FinalLayout = ImageLayout.DepthStencilAttachmentOptimal - }; - - var depthAttachmentRef = new AttachmentReference + var depthAttachmentRef = default(AttachmentReference); + if (depthFormat is Format actualDepthFormat) { - Attachment = (uint)colorFormats.Count, - Layout = ImageLayout.DepthStencilAttachmentOptimal - }; + attachments[colorFormats.Count] = new AttachmentDescription + { + Format = actualDepthFormat, + Samples = SampleCountFlags.Count1Bit, + LoadOp = ToVulkanLoadOp(depthLoadOp), + StoreOp = AttachmentStoreOp.Store, + StencilLoadOp = Silk.NET.Vulkan.AttachmentLoadOp.DontCare, + StencilStoreOp = AttachmentStoreOp.DontCare, + InitialLayout = ImageLayout.DepthStencilAttachmentOptimal, + FinalLayout = ImageLayout.DepthStencilAttachmentOptimal + }; + + depthAttachmentRef = new AttachmentReference + { + Attachment = (uint)colorFormats.Count, + Layout = ImageLayout.DepthStencilAttachmentOptimal + }; + } var subpass = new SubpassDescription { PipelineBindPoint = PipelineBindPoint.Graphics, ColorAttachmentCount = (uint)colorFormats.Count, PColorAttachments = colorAttachmentRefs, - PDepthStencilAttachment = &depthAttachmentRef + PDepthStencilAttachment = depthFormat.HasValue ? &depthAttachmentRef : null }; + PipelineStageFlags attachmentStages = PipelineStageFlags.ColorAttachmentOutputBit; + AccessFlags attachmentWrites = AccessFlags.ColorAttachmentWriteBit; + if (depthFormat.HasValue) + { + attachmentStages |= PipelineStageFlags.EarlyFragmentTestsBit; + attachmentWrites |= AccessFlags.DepthStencilAttachmentWriteBit; + } + var dependency = new SubpassDependency { SrcSubpass = Vk.SubpassExternal, DstSubpass = 0, - SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.EarlyFragmentTestsBit, + SrcStageMask = attachmentStages, SrcAccessMask = 0, - DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.EarlyFragmentTestsBit, - DstAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit + DstStageMask = attachmentStages, + DstAccessMask = attachmentWrites }; var renderPassInfo = new RenderPassCreateInfo @@ -131,10 +149,141 @@ public VulkanRenderPass3D( _renderPass = renderPass; } + /// + /// The pass instance this one resumes as after being suspended: the same attachments, loading what the + /// suspended half stored instead of clearing over it. + /// + /// + /// Render pass compatibility ignores load and store ops, so a pipeline built for this pass stays valid + /// across the split and nothing has to be rebuilt to cross it. + /// + private RenderPass GetOrCreateResumeRenderPass() + { + if (_resumeRenderPass.Handle != 0) + return _resumeRenderPass; + + _resumeRenderPass = CreateRenderPass( + _context, + _colorFormats, + _depthFormat, + Silk.NET.Vulkan.AttachmentLoadOp.Load, + Silk.NET.Vulkan.AttachmentLoadOp.Load); + return _resumeRenderPass; + } + + private static RenderPass CreateRenderPass( + VulkanContext context, + IReadOnlyList colorFormats, + Format? depthFormat, + Silk.NET.Vulkan.AttachmentLoadOp colorLoadOp, + Silk.NET.Vulkan.AttachmentLoadOp depthLoadOp) + { + var vk = context.Vk; + var device = context.Device; + int totalAttachments = colorFormats.Count + (depthFormat.HasValue ? 1 : 0); + var attachments = stackalloc AttachmentDescription[totalAttachments]; + var colorAttachmentRefs = stackalloc AttachmentReference[colorFormats.Count]; + + for (int i = 0; i < colorFormats.Count; i++) + { + attachments[i] = new AttachmentDescription + { + Format = colorFormats[i], + Samples = SampleCountFlags.Count1Bit, + LoadOp = colorLoadOp, + StoreOp = AttachmentStoreOp.Store, + StencilLoadOp = Silk.NET.Vulkan.AttachmentLoadOp.DontCare, + StencilStoreOp = AttachmentStoreOp.DontCare, + InitialLayout = ImageLayout.ColorAttachmentOptimal, + FinalLayout = ImageLayout.ColorAttachmentOptimal + }; + + colorAttachmentRefs[i] = new AttachmentReference + { + Attachment = (uint)i, + Layout = ImageLayout.ColorAttachmentOptimal + }; + } + + var depthAttachmentRef = default(AttachmentReference); + if (depthFormat is Format actualDepthFormat) + { + attachments[colorFormats.Count] = new AttachmentDescription + { + Format = actualDepthFormat, + Samples = SampleCountFlags.Count1Bit, + LoadOp = depthLoadOp, + StoreOp = AttachmentStoreOp.Store, + StencilLoadOp = Silk.NET.Vulkan.AttachmentLoadOp.DontCare, + StencilStoreOp = AttachmentStoreOp.DontCare, + InitialLayout = ImageLayout.DepthStencilAttachmentOptimal, + FinalLayout = ImageLayout.DepthStencilAttachmentOptimal + }; + + depthAttachmentRef = new AttachmentReference + { + Attachment = (uint)colorFormats.Count, + Layout = ImageLayout.DepthStencilAttachmentOptimal + }; + } + + var subpass = new SubpassDescription + { + PipelineBindPoint = PipelineBindPoint.Graphics, + ColorAttachmentCount = (uint)colorFormats.Count, + PColorAttachments = colorAttachmentRefs, + PDepthStencilAttachment = depthFormat.HasValue ? &depthAttachmentRef : null + }; + + PipelineStageFlags attachmentStages = PipelineStageFlags.ColorAttachmentOutputBit; + AccessFlags attachmentWrites = AccessFlags.ColorAttachmentWriteBit; + if (depthFormat.HasValue) + { + attachmentStages |= PipelineStageFlags.EarlyFragmentTestsBit; + attachmentWrites |= AccessFlags.DepthStencilAttachmentWriteBit; + } + + var dependency = new SubpassDependency + { + SrcSubpass = Vk.SubpassExternal, + DstSubpass = 0, + SrcStageMask = attachmentStages, + SrcAccessMask = 0, + DstStageMask = attachmentStages, + DstAccessMask = attachmentWrites + }; + + var renderPassInfo = new RenderPassCreateInfo + { + SType = StructureType.RenderPassCreateInfo, + AttachmentCount = (uint)totalAttachments, + PAttachments = attachments, + SubpassCount = 1, + PSubpasses = &subpass, + DependencyCount = 1, + PDependencies = &dependency + }; + + RenderPass created; + Result result = vk.CreateRenderPass(device, &renderPassInfo, null, &created); + if (result != Result.Success) + { + throw new InvalidOperationException($"Failed to create render pass: {result}"); + } + + return created; + } + public RenderPass Handle => _renderPass; public int ColorAttachmentCount => _colorAttachmentCount; + public IReadOnlyList ColorFormats => _colorFormats; + + public Format? DepthFormat => _depthFormat; + + public bool HasDepthAttachment => _depthFormat.HasValue; + public void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, float clearDepth = 1.0f) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -144,24 +293,26 @@ public void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, f throw new InvalidOperationException("Render pass already begun"); } - var vulkanFramebuffer = (VulkanFramebuffer3D)framebuffer; - - // Allocate command buffer - _currentCommandBuffer = _context.AllocateCommandBuffer(); - - var beginInfo = new CommandBufferBeginInfo + var vulkanFramebuffer = _context.RequireOwned(framebuffer, nameof(framebuffer)); + if (!vulkanFramebuffer.IsCompatibleWith(this)) { - SType = StructureType.CommandBufferBeginInfo, - Flags = CommandBufferUsageFlags.OneTimeSubmitBit - }; + throw new ArgumentException("The framebuffer was created for a different render pass.", nameof(framebuffer)); + } - _context.Vk.BeginCommandBuffer(_currentCommandBuffer, &beginInfo); + // Rejected before the first barrier, but the batch is not claimed until the command that opens the + // pass: a claimed scope sends every barrier through the suspend path, and the preparation below + // runs before there is an instance to suspend. + _context.ThrowIfRenderPassActive(); // Prepare textures for rendering vulkanFramebuffer.PrepareForRendering(); + // Barriers, copies, and consecutive render passes share one command buffer until an + // external consumer requires submission. + _currentCommandBuffer = _context.GetRecordingCommandBuffer(); + // Create clear values for all attachments - int totalClearValues = _colorAttachmentCount + 1; + int totalClearValues = _colorAttachmentCount + (HasDepthAttachment ? 1 : 0); var clearValues = stackalloc ClearValue[totalClearValues]; for (int i = 0; i < _colorAttachmentCount; i++) @@ -176,7 +327,10 @@ public void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, f clearValues[i].Color = new ClearColorValue(0, 0, 0, 0); } } - clearValues[_colorAttachmentCount].DepthStencil = new ClearDepthStencilValue(clearDepth, 0); + if (HasDepthAttachment) + { + clearValues[_colorAttachmentCount].DepthStencil = new ClearDepthStencilValue(clearDepth, 0); + } var renderPassBeginInfo = new RenderPassBeginInfo { @@ -186,15 +340,22 @@ public void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, f RenderArea = new Rect2D { Offset = new Offset2D(0, 0), - Extent = new Extent2D((uint)framebuffer.Width, (uint)framebuffer.Height) + Extent = new Extent2D((uint)vulkanFramebuffer.Width, (uint)vulkanFramebuffer.Height) }, ClearValueCount = (uint)totalClearValues, PClearValues = clearValues }; + _context.BeginRenderPassScope(this); _context.Vk.CmdBeginRenderPass(_currentCommandBuffer, &renderPassBeginInfo, SubpassContents.Inline); + SetFullFramebufferViewport(vulkanFramebuffer); + + _currentFramebuffer = vulkanFramebuffer; + _inRenderPass = true; + } - // Set viewport and scissor + private void SetFullFramebufferViewport(VulkanFramebuffer3D framebuffer) + { var viewport = new Viewport { X = 0, @@ -212,8 +373,57 @@ public void Begin(IFramebuffer3D framebuffer, ReadOnlySpan clearColors, f Extent = new Extent2D((uint)framebuffer.Width, (uint)framebuffer.Height) }; _context.Vk.CmdSetScissor(_currentCommandBuffer, 0, 1, &scissor); + } - _inRenderPass = true; + /// + /// Vulkan forbids a transfer or a barrier inside a render pass instance, and appending one to the batch + /// the pass is still recording is not an option either. Ending the instance, recording the work, and + /// beginning it again keeps everything on one command buffer in the order it was recorded, which is what + /// a draw already recorded in this pass needs: it must not end up running after work requested later. + /// + bool IVulkanRenderPassSuspension.TrySuspend() + { + if (!_inRenderPass || _suspended || _currentFramebuffer is null) + return false; + + _context.Vk.CmdEndRenderPass(_currentCommandBuffer); + _suspended = true; + return true; + } + + void IVulkanRenderPassSuspension.Resume() + { + VulkanFramebuffer3D framebuffer = _currentFramebuffer + ?? throw new InvalidOperationException("A suspended render pass lost the framebuffer it was recording into."); + + var renderPassBeginInfo = new RenderPassBeginInfo + { + SType = StructureType.RenderPassBeginInfo, + RenderPass = GetOrCreateResumeRenderPass(), + Framebuffer = framebuffer.Handle, + RenderArea = new Rect2D + { + Offset = new Offset2D(0, 0), + Extent = new Extent2D((uint)framebuffer.Width, (uint)framebuffer.Height) + }, + ClearValueCount = 0, + PClearValues = null + }; + + _context.Vk.CmdBeginRenderPass(_currentCommandBuffer, &renderPassBeginInfo, SubpassContents.Inline); + SetFullFramebufferViewport(framebuffer); + _suspended = false; + + // Bindings survive the split - a command buffer keeps its state across a pass boundary, and the two + // pass objects are compatible - but rebinding says so to a reader and to the validation layer + // without depending on that. + if (_currentPipeline is { } pipeline) + { + _context.Vk.CmdBindPipeline( + _currentCommandBuffer, + PipelineBindPoint.Graphics, + pipeline.Handle); + } } public void End() @@ -226,12 +436,11 @@ public void End() } _context.Vk.CmdEndRenderPass(_currentCommandBuffer); - _context.Vk.EndCommandBuffer(_currentCommandBuffer); - - // Submit command buffer - _context.SubmitCommandBuffer(_currentCommandBuffer); + _context.EndRenderPassScope(this); _inRenderPass = false; + _suspended = false; + _currentFramebuffer = null; _currentPipeline = null; } @@ -251,7 +460,12 @@ public void BindPipeline(IPipeline3D pipeline) throw new InvalidOperationException("Render pass not begun"); } - var vulkanPipeline = (VulkanPipeline3D)pipeline; + var vulkanPipeline = _context.RequireOwned(pipeline, nameof(pipeline)); + if (!vulkanPipeline.IsCompatibleWith(this)) + { + throw new ArgumentException("The pipeline was created for a different render pass.", nameof(pipeline)); + } + _currentPipeline = vulkanPipeline; _context.Vk.CmdBindPipeline(_currentCommandBuffer, PipelineBindPoint.Graphics, vulkanPipeline.Handle); } @@ -263,7 +477,7 @@ public void BindVertexBuffer(IBuffer buffer) throw new InvalidOperationException("Render pass not begun"); } - var vulkanBuffer = (VulkanBuffer)buffer; + var vulkanBuffer = _context.RequireOwned(buffer, nameof(buffer)); var bufferHandle = vulkanBuffer.Handle; ulong offset = 0; _context.Vk.CmdBindVertexBuffers(_currentCommandBuffer, 0, 1, &bufferHandle, &offset); @@ -276,7 +490,7 @@ public void BindIndexBuffer(IBuffer buffer) throw new InvalidOperationException("Render pass not begun"); } - var vulkanBuffer = (VulkanBuffer)buffer; + var vulkanBuffer = _context.RequireOwned(buffer, nameof(buffer)); _context.Vk.CmdBindIndexBuffer(_currentCommandBuffer, vulkanBuffer.Handle, 0, IndexType.Uint32); } @@ -287,8 +501,8 @@ public void BindDescriptorSet(IPipeline3D pipeline, IDescriptorSet descriptorSet throw new InvalidOperationException("Render pass not begun"); } - var vulkanPipeline = (VulkanPipeline3D)pipeline; - var vulkanDescriptorSet = (VulkanDescriptorSet)descriptorSet; + var vulkanPipeline = _context.RequireOwned(pipeline, nameof(pipeline)); + var vulkanDescriptorSet = _context.RequireOwned(descriptorSet, nameof(descriptorSet)); var set = vulkanDescriptorSet.Handle; _context.Vk.CmdBindDescriptorSets( _currentCommandBuffer, @@ -321,7 +535,7 @@ public void Draw(uint vertexCount, uint instanceCount = 1, uint firstVertex = 0, _context.Vk.CmdDraw(_currentCommandBuffer, vertexCount, instanceCount, firstVertex, firstInstance); } - public void SetPushConstants(T data, ShaderStage stageFlags = ShaderStage.Vertex | ShaderStage.Fragment) where T : unmanaged + public void SetPushConstants(T data) where T : unmanaged { if (!_inRenderPass) { @@ -334,21 +548,18 @@ public void SetPushConstants(T data, ShaderStage stageFlags = ShaderStage.Ver } var size = (uint)sizeof(T); - if (size > 128) + if (size > VulkanPipeline3D.MaxPushConstantsSize) { - throw new ArgumentException($"Push constants size {size} exceeds maximum of 128 bytes"); + throw new ArgumentException( + $"Push constants size {size} exceeds maximum of {VulkanPipeline3D.MaxPushConstantsSize} bytes"); } - ShaderStageFlags vulkanStageFlags = 0; - if ((stageFlags & ShaderStage.Vertex) != 0) - vulkanStageFlags |= ShaderStageFlags.VertexBit; - if ((stageFlags & ShaderStage.Fragment) != 0) - vulkanStageFlags |= ShaderStageFlags.FragmentBit; - + // The bound layout's range is what decides these, not the caller: an update has to name every stage + // of every range it overlaps, so naming fewer is undefined behaviour the driver need not report. _context.Vk.CmdPushConstants( _currentCommandBuffer, _currentPipeline.PipelineLayoutHandle, - vulkanStageFlags, + VulkanPipeline3D.PushConstantStages, 0, size, &data); @@ -359,7 +570,14 @@ public void Dispose() if (_disposed) return; _disposed = true; - _context.Vk.DestroyRenderPass(_context.Device, _renderPass, null); + RenderPass renderPass = _renderPass; + RenderPass resumeRenderPass = _resumeRenderPass; + _context.DeferRelease(() => + { + _context.Vk.DestroyRenderPass(_context.Device, renderPass, null); + if (resumeRenderPass.Handle != 0) + _context.Vk.DestroyRenderPass(_context.Device, resumeRenderPass, null); + }); } private static Silk.NET.Vulkan.AttachmentLoadOp ToVulkanLoadOp(AttachmentLoadOp loadOp) diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSampler.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSampler.cs index 9792a483b7..b0dbe67374 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSampler.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSampler.cs @@ -88,6 +88,7 @@ public void Dispose() if (_disposed) return; _disposed = true; - _context.Vk.DestroySampler(_context.Device, _sampler, null); + Silk.NET.Vulkan.Sampler sampler = _sampler; + _context.DeferRelease(() => _context.Vk.DestroySampler(_context.Device, sampler, null)); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSwapchainRenderer.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSwapchainRenderer.cs index 9d46c3db36..1a6235698d 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSwapchainRenderer.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanSwapchainRenderer.cs @@ -55,6 +55,7 @@ internal sealed unsafe class VulkanSwapchainRenderer : IDisposable private int _sourceWidth; private int _sourceHeight; private Format _sourceFormat; + private ImageLayout _sourceImageLayout = ImageLayout.Undefined; // Staging buffer private Silk.NET.Vulkan.Buffer _stagingBuffer; @@ -214,13 +215,16 @@ private void ExecuteRender(Ref bitmapRef, RenderParams renderParams, int if (bitmap.IsDisposed) return; - // Upload bitmap to GPU - UploadBitmap(bitmap); - - // Acquire next swapchain image + // The previous frame fence covers its upload and present draw because both use this queue. + // Wait before rewriting the persistent staging buffer or replacing the source image. var fence = _inFlightFence; _vk.WaitForFences(_device, 1, &fence, Vk.True, ulong.MaxValue); + // Upload bitmap to GPU. The following render submission is ordered after it on the same + // queue, so the upload needs no per-operation CPU fence wait. + UploadBitmap(bitmap); + + // Acquire next swapchain image var acquireResult = _swapchain.AcquireNextImage(_imageAvailableSemaphore, out uint imageIndex); if (acquireResult == Result.ErrorOutOfDateKhr) { @@ -274,7 +278,8 @@ private void UploadBitmap(Bitmap bitmap) BeginCommandBuffer(cmdBuf); // Transition to transfer dst - TransitionImageLayout(cmdBuf, _sourceImage, ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + TransitionImageLayout(cmdBuf, _sourceImage, _sourceImageLayout, ImageLayout.TransferDstOptimal); + _sourceImageLayout = ImageLayout.TransferDstOptimal; var region = new BufferImageCopy { @@ -296,8 +301,9 @@ private void UploadBitmap(Bitmap bitmap) // Transition to shader read TransitionImageLayout(cmdBuf, _sourceImage, ImageLayout.TransferDstOptimal, ImageLayout.ShaderReadOnlyOptimal); + _sourceImageLayout = ImageLayout.ShaderReadOnlyOptimal; - EndAndSubmitCommandBuffer(cmdBuf); + EndAndSubmitUploadCommands(cmdBuf); // Update descriptor set _pipeline!.UpdateDescriptorSet(_descriptorSet, _sourceImageView); @@ -630,6 +636,7 @@ private void CreateSourceImage(int width, int height, Format format) ImageView view; _vk.CreateImageView(_device, &viewInfo, null, &view); _sourceImageView = view; + _sourceImageLayout = ImageLayout.Undefined; // Allocate descriptor set _descriptorSet = _pipeline!.AllocateDescriptorSet(); @@ -663,6 +670,7 @@ private void DestroySourceImage() _sourceWidth = 0; _sourceHeight = 0; + _sourceImageLayout = ImageLayout.Undefined; } private void EnsureStagingBuffer(ulong requiredSize) @@ -763,7 +771,7 @@ private void BeginCommandBuffer(CommandBuffer cmdBuf) _vk.BeginCommandBuffer(cmdBuf, &beginInfo); } - private void EndAndSubmitCommandBuffer(CommandBuffer cmdBuf) + private void EndAndSubmitUploadCommands(CommandBuffer cmdBuf) { _vk.EndCommandBuffer(cmdBuf); @@ -774,11 +782,11 @@ private void EndAndSubmitCommandBuffer(CommandBuffer cmdBuf) PCommandBuffers = &cmdBuf }; - var fence = _inFlightFence; - _vk.WaitForFences(_device, 1, &fence, Vk.True, ulong.MaxValue); - _vk.ResetFences(_device, 1, &fence); - _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, _inFlightFence); - _vk.WaitForFences(_device, 1, &fence, Vk.True, ulong.MaxValue); + Result result = _vk.QueueSubmit(_graphicsQueue, 1, &submitInfo, default); + if (result != Result.Success) + { + throw new InvalidOperationException($"Failed to submit HDR source upload: {result}"); + } } private void TransitionImageLayout(CommandBuffer cmdBuf, Image image, ImageLayout oldLayout, ImageLayout newLayout) @@ -800,6 +808,13 @@ private void TransitionImageLayout(CommandBuffer cmdBuf, Image image, ImageLayou srcAccess = AccessFlags.TransferWriteBit; dstAccess = AccessFlags.ShaderReadBit; } + else if (oldLayout == ImageLayout.ShaderReadOnlyOptimal && newLayout == ImageLayout.TransferDstOptimal) + { + srcStage = PipelineStageFlags.FragmentShaderBit; + dstStage = PipelineStageFlags.TransferBit; + srcAccess = AccessFlags.ShaderReadBit; + dstAccess = AccessFlags.TransferWriteBit; + } else { srcStage = PipelineStageFlags.AllCommandsBit; diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTexture2D.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTexture2D.cs index 39f3a632ee..1adc895de5 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTexture2D.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTexture2D.cs @@ -8,7 +8,7 @@ namespace Beutl.Graphics.Backend.Vulkan; /// /// Vulkan implementation of . /// -internal unsafe class VulkanTexture2D : ITexture2D +internal unsafe class VulkanTexture2D : ITexture2D, ITransparentClearableTexture, IVulkanContextResource { protected readonly VulkanContext _context; protected readonly Silk.NET.Vulkan.Image _image; @@ -18,7 +18,11 @@ internal unsafe class VulkanTexture2D : ITexture2D protected readonly int _height; protected readonly TextureFormat _format; protected readonly ulong _allocationSize; + + public VulkanContext OwnerContext => _context; protected ImageLayout _currentLayout = ImageLayout.Undefined; + private TextureAccessDomain _accessDomain; + private bool _hasTransparentContents; protected bool _disposed; public VulkanTexture2D( @@ -166,7 +170,7 @@ public void Upload(ReadOnlySpan data) TransitionTo(ImageLayout.TransferDstOptimal); // Copy buffer to image - _context.SubmitImmediateCommands(cmd => + _context.RecordCommands(cmd => { var region = new BufferImageCopy { @@ -191,6 +195,7 @@ public void Upload(ReadOnlySpan data) // Transition to shader read TransitionTo(ImageLayout.ShaderReadOnlyOptimal); + _hasTransparentContents = false; } public byte[] DownloadPixels() @@ -222,7 +227,7 @@ public byte[] DownloadPixels() MemoryProperty.HostVisible | MemoryProperty.HostCoherent); // Copy image to buffer - _context.SubmitImmediateCommands(cmd => + _context.RecordCommands(cmd => { var region = new BufferImageCopy { @@ -245,14 +250,15 @@ public byte[] DownloadPixels() cmd, _image, ImageLayout.TransferSrcOptimal, stagingBuffer.Handle, 1, ®ion); }); + // Restore the sampled layout in the same batch, then wait before mapping the staging buffer. + TransitionTo(ImageLayout.ShaderReadOnlyOptimal); + _context.FlushCommands(waitForCompletion: true); + // Read data from staging buffer var srcPtr = stagingBuffer.Map(); Marshal.Copy(srcPtr, pixelData, 0, (int)bufferSize); stagingBuffer.Unmap(); - // Transition back to shader read - TransitionTo(ImageLayout.ShaderReadOnlyOptimal); - return pixelData; } @@ -264,6 +270,7 @@ public virtual SKSurface CreateSkiaSurface() if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { var info = new SKImageInfo(_width, _height, _format.ToSkiaColorType(), SKAlphaType.Premul, SKColorSpace.CreateSrgbLinear()); + MarkSkiaAccess(); return SKSurface.Create(info); } @@ -272,7 +279,11 @@ public virtual SKSurface CreateSkiaSurface() Image = _image.Handle, Alloc = new GRVkAlloc { Memory = (ulong)_memory.Handle, Offset = 0, Size = _allocationSize }, ImageTiling = (uint)ImageTiling.Optimal, - ImageLayout = (uint)ImageLayout.ColorAttachmentOptimal, + // The layout the image is actually in, not the one it is usually in. Skia takes this as the + // starting point for its own tracking and barriers, so declaring a layout the image has not + // reached tells it to skip a transition it needs - and, when the image was just cleared, to + // treat contents as undefined that are not. + ImageLayout = (uint)_currentLayout, Format = (uint)_format.ToVulkanFormat(), ImageUsageFlags = (uint)(ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.SampledBit | ImageUsageFlags.TransferSrcBit | ImageUsageFlags.TransferDstBit), @@ -294,26 +305,112 @@ public virtual SKSurface CreateSkiaSurface() throw new InvalidOperationException("Failed to create SkiaSharp surface from Vulkan backend render target"); } + MarkSkiaAccess(); return surface; } public void PrepareForRender() { TransitionTo(ImageLayout.ColorAttachmentOptimal); + _accessDomain = TextureAccessDomain.Vulkan; + _hasTransparentContents = false; } public void PrepareForSampling() { TransitionTo(ImageLayout.ShaderReadOnlyOptimal); + _accessDomain = TextureAccessDomain.Vulkan; + } + + public bool RequiresSkiaFlushForBackendInterop => _accessDomain == TextureAccessDomain.Skia; + + protected bool RequiresVulkanToSkiaHandoff => _accessDomain == TextureAccessDomain.Vulkan; + + public virtual void PrepareForSkiaRendering() + { + bool requiresSubmission = _currentLayout != ImageLayout.ColorAttachmentOptimal + || RequiresVulkanToSkiaHandoff; + TransitionTo(ImageLayout.ColorAttachmentOptimal); + if (requiresSubmission) + { + _context.FlushCommands(waitForCompletion: false); + } + MarkSkiaAccess(); + _hasTransparentContents = false; + } + + public virtual void PrepareForSkiaSampling(bool requireCompletion) + { + if (RequiresVulkanToSkiaHandoff) + { + _context.FlushCommands(requireCompletion); + } + MarkSkiaAccess(); + } + + protected void MarkSkiaAccess() + { + _accessDomain = TextureAccessDomain.Skia; + } + + bool ITransparentClearableTexture.HasTransparentContents => _hasTransparentContents; + + void ITransparentClearableTexture.ClearToTransparent() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_format.IsDepthFormat()) + { + throw new InvalidOperationException( + "A depth texture cannot be initialized with a transparent color clear."); + } + if (_hasTransparentContents) + return; + + TransitionTo(ImageLayout.TransferDstOptimal); + _context.RecordCommands(commandBuffer => + { + var range = new ImageSubresourceRange + { + AspectMask = _format.GetAspectMask(), + BaseMipLevel = 0, + LevelCount = 1, + BaseArrayLayer = 0, + LayerCount = 1, + }; + var transparent = new ClearColorValue(0, 0, 0, 0); + _context.Vk.CmdClearColorImage( + commandBuffer, + _image, + ImageLayout.TransferDstOptimal, + &transparent, + 1, + &range); + }); + _accessDomain = TextureAccessDomain.Vulkan; + _hasTransparentContents = true; + } + + internal void MarkContentsUnknown() + { + _hasTransparentContents = false; + } + + void ITransparentClearableTexture.MarkContentsTransparent() + { + _hasTransparentContents = true; } public void TransitionTo(ImageLayout layout) { if (_currentLayout == layout) + { + _accessDomain = TextureAccessDomain.Vulkan; return; + } _context.TransitionImageLayout(_image, _currentLayout, layout, _format.GetAspectMask()); _currentLayout = layout; + _accessDomain = TextureAccessDomain.Vulkan; } public virtual void Dispose() @@ -321,22 +418,35 @@ public virtual void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; - - if (_imageView.Handle != 0) + ImageView imageView = _imageView; + Silk.NET.Vulkan.Image image = _image; + DeviceMemory memory = _memory; + _context.DeferRelease(() => { - vk.DestroyImageView(device, _imageView, null); - } + var vk = _context.Vk; + var device = _context.Device; - if (_image.Handle != 0) - { - vk.DestroyImage(device, _image, null); - } + if (imageView.Handle != 0) + { + vk.DestroyImageView(device, imageView, null); + } - if (_memory.Handle != 0) - { - vk.FreeMemory(device, _memory, null); - } + if (image.Handle != 0) + { + vk.DestroyImage(device, image, null); + } + + if (memory.Handle != 0) + { + vk.FreeMemory(device, memory, null); + } + }); } } + +internal enum TextureAccessDomain : byte +{ + None, + Skia, + Vulkan, +} diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureArray.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureArray.cs index 58e1a9b6b0..97863ca8d2 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureArray.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureArray.cs @@ -7,18 +7,19 @@ namespace Beutl.Graphics.Backend.Vulkan; /// Vulkan implementation of . /// Used for efficiently storing multiple shadow maps. /// -internal sealed unsafe class VulkanTextureArray : ITextureArray +internal sealed unsafe class VulkanTextureArray : ITextureArray, IVulkanContextResource { private readonly VulkanContext _context; private readonly Silk.NET.Vulkan.Image _image; private readonly DeviceMemory _memory; + + public VulkanContext OwnerContext => _context; private readonly ImageView _imageView; // Array view for sampling all layers private readonly ImageView[] _layerViews; // Individual layer views for framebuffer attachment private readonly int _width; private readonly int _height; private readonly uint _arraySize; private readonly TextureFormat _format; - private ImageLayout _currentLayout = ImageLayout.Undefined; private readonly ImageLayout[] _layerLayouts; // Track layout per layer private bool _disposed; @@ -28,7 +29,7 @@ public VulkanTextureArray( int height, uint arraySize, TextureFormat format, - ImageUsageFlags usage = ImageUsageFlags.SampledBit | ImageUsageFlags.DepthStencilAttachmentBit) + ImageUsageFlags usage) { if (arraySize == 0) throw new ArgumentException("Array size must be greater than 0", nameof(arraySize)); @@ -161,6 +162,36 @@ public VulkanTextureArray( _layerViews[i] = layerView; _layerLayouts[i] = ImageLayout.Undefined; } + + // Every allocated slot is covered by the array view the shader samples, whether or not anything + // ever writes to it - a shadow atlas binds its whole array while only the lights actually present + // fill a slot - so a slot has to start in a layout the sampler can read rather than in UNDEFINED, + // which is what the descriptor would otherwise present to the lighting pass. + // Recording it can fail - allocating or beginning a command buffer - and the type has no finalizer, + // so everything created above has to be released here rather than left to a caller that only holds + // a thrown exception. + try + { + _context.TransitionImageLayout( + _image, + ImageLayout.Undefined, + ImageLayout.ShaderReadOnlyOptimal, + _format.GetAspectMask(), + baseArrayLayer: 0, + layerCount: _arraySize); + } + catch + { + for (uint i = 0; i < _arraySize; i++) + vk.DestroyImageView(device, _layerViews[i], null); + vk.DestroyImageView(device, _imageView, null); + vk.FreeMemory(device, _memory, null); + vk.DestroyImage(device, _image, null); + throw; + } + + for (uint i = 0; i < _arraySize; i++) + _layerLayouts[i] = ImageLayout.ShaderReadOnlyOptimal; } public int Width => _width; @@ -188,18 +219,7 @@ public void TransitionLayerToAttachment(uint layerIndex) ? ImageLayout.DepthStencilAttachmentOptimal : ImageLayout.ColorAttachmentOptimal; - if (_layerLayouts[layerIndex] == targetLayout) - return; - - _context.TransitionImageLayout( - _image, - _layerLayouts[layerIndex], - targetLayout, - _format.GetAspectMask(), - baseArrayLayer: layerIndex, - layerCount: 1); - - _layerLayouts[layerIndex] = targetLayout; + TransitionLayer(layerIndex, targetLayout); } public void TransitionLayerToSampled(uint layerIndex) @@ -209,37 +229,25 @@ public void TransitionLayerToSampled(uint layerIndex) if (layerIndex >= _arraySize) throw new ArgumentOutOfRangeException(nameof(layerIndex)); - if (_layerLayouts[layerIndex] == ImageLayout.ShaderReadOnlyOptimal) - return; + TransitionLayer(layerIndex, ImageLayout.ShaderReadOnlyOptimal); + } - _context.TransitionImageLayout( - _image, - _layerLayouts[layerIndex], - ImageLayout.ShaderReadOnlyOptimal, - _format.GetAspectMask(), - baseArrayLayer: layerIndex, - layerCount: 1); + internal void TransitionLayerToTransferDestination(uint layerIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (layerIndex >= _arraySize) + throw new ArgumentOutOfRangeException(nameof(layerIndex)); - _layerLayouts[layerIndex] = ImageLayout.ShaderReadOnlyOptimal; + TransitionLayer(layerIndex, ImageLayout.TransferDstOptimal); } public void TransitionAllToSampled() { ObjectDisposedException.ThrowIf(_disposed, this); - // Transition all layers at once - _context.TransitionImageLayout( - _image, - _currentLayout, - ImageLayout.ShaderReadOnlyOptimal, - _format.GetAspectMask(), - baseArrayLayer: 0, - layerCount: _arraySize); - - _currentLayout = ImageLayout.ShaderReadOnlyOptimal; for (uint i = 0; i < _arraySize; i++) { - _layerLayouts[i] = ImageLayout.ShaderReadOnlyOptimal; + TransitionLayer(i, ImageLayout.ShaderReadOnlyOptimal); } } @@ -261,16 +269,10 @@ public void UploadLayer(uint layerIndex, ReadOnlySpan data) stagingBuffer.Upload(data); // Transition layer to transfer destination - _context.TransitionImageLayout( - _image, - _layerLayouts[layerIndex], - ImageLayout.TransferDstOptimal, - _format.GetAspectMask(), - baseArrayLayer: layerIndex, - layerCount: 1); + TransitionLayer(layerIndex, ImageLayout.TransferDstOptimal); // Copy buffer to image - _context.SubmitImmediateCommands(cmd => + _context.RecordCommands(cmd => { var region = new BufferImageCopy { @@ -293,15 +295,37 @@ public void UploadLayer(uint layerIndex, ReadOnlySpan data) }); // Transition to shader read + TransitionLayer(layerIndex, ImageLayout.ShaderReadOnlyOptimal); + } + + private void TransitionLayer(uint layerIndex, ImageLayout newLayout) + { + ImageLayout oldLayout = _layerLayouts[layerIndex]; + if (oldLayout == newLayout) + return; + _context.TransitionImageLayout( _image, - ImageLayout.TransferDstOptimal, - ImageLayout.ShaderReadOnlyOptimal, + oldLayout, + newLayout, _format.GetAspectMask(), baseArrayLayer: layerIndex, layerCount: 1); + _layerLayouts[layerIndex] = newLayout; + } - _layerLayouts[layerIndex] = ImageLayout.ShaderReadOnlyOptimal; + /// The layout the given slot is tracked as being in. + /// + /// Every allocated slot must be readable by a sampler even before anything writes to it, because the + /// array view a shader binds covers all of them. + /// + internal ImageLayout GetLayerLayout(uint layerIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (layerIndex >= _arraySize) + throw new ArgumentOutOfRangeException(nameof(layerIndex)); + + return _layerLayouts[layerIndex]; } public IntPtr GetLayerView(uint layerIndex) @@ -329,32 +353,37 @@ public void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; - - // Destroy layer views - for (uint i = 0; i < _arraySize; i++) + ImageView[] layerViews = _layerViews; + ImageView imageView = _imageView; + Silk.NET.Vulkan.Image image = _image; + DeviceMemory memory = _memory; + _context.DeferRelease(() => { - if (_layerViews[i].Handle != 0) + var vk = _context.Vk; + var device = _context.Device; + + foreach (ImageView layerView in layerViews) { - vk.DestroyImageView(device, _layerViews[i], null); + if (layerView.Handle != 0) + { + vk.DestroyImageView(device, layerView, null); + } } - } - // Destroy array view - if (_imageView.Handle != 0) - { - vk.DestroyImageView(device, _imageView, null); - } + if (imageView.Handle != 0) + { + vk.DestroyImageView(device, imageView, null); + } - if (_image.Handle != 0) - { - vk.DestroyImage(device, _image, null); - } + if (image.Handle != 0) + { + vk.DestroyImage(device, image, null); + } - if (_memory.Handle != 0) - { - vk.FreeMemory(device, _memory, null); - } + if (memory.Handle != 0) + { + vk.FreeMemory(device, memory, null); + } + }); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCube.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCube.cs index 2867cced0a..68b961e647 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCube.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCube.cs @@ -7,23 +7,25 @@ namespace Beutl.Graphics.Backend.Vulkan; /// Vulkan implementation of . /// Used for point light shadow maps. /// -internal sealed unsafe class VulkanTextureCube : ITextureCube +internal sealed unsafe class VulkanTextureCube : ITextureCube, IVulkanContextResource { private readonly VulkanContext _context; private readonly Silk.NET.Vulkan.Image _image; private readonly DeviceMemory _memory; + + public VulkanContext OwnerContext => _context; private readonly ImageView _imageView; // Cube map view for sampling private readonly ImageView[] _faceViews; // Individual face views for framebuffer attachment + private readonly ImageLayout[] _faceLayouts = new ImageLayout[6]; private readonly int _size; private readonly TextureFormat _format; - private ImageLayout _currentLayout = ImageLayout.Undefined; private bool _disposed; public VulkanTextureCube( VulkanContext context, int size, TextureFormat format, - ImageUsageFlags usage = ImageUsageFlags.SampledBit | ImageUsageFlags.DepthStencilAttachmentBit) + ImageUsageFlags usage) { _context = context; _size = size; @@ -150,6 +152,31 @@ public VulkanTextureCube( } _faceViews[i] = faceView; } + + // The cube view samples all six faces whether or not every one is rendered into, so a face has to + // start in a layout the sampler can read rather than in UNDEFINED. + try + { + _context.TransitionImageLayout( + _image, + ImageLayout.Undefined, + ImageLayout.ShaderReadOnlyOptimal, + _format.GetAspectMask(), + baseArrayLayer: 0, + layerCount: 6); + } + catch + { + for (int i = 0; i < _faceViews.Length; i++) + vk.DestroyImageView(device, _faceViews[i], null); + vk.DestroyImageView(device, _imageView, null); + vk.FreeMemory(device, _memory, null); + vk.DestroyImage(device, _image, null); + throw; + } + + for (int i = 0; i < _faceLayouts.Length; i++) + _faceLayouts[i] = ImageLayout.ShaderReadOnlyOptimal; } public int Size => _size; @@ -170,9 +197,6 @@ public void TransitionToAttachment() ? ImageLayout.DepthStencilAttachmentOptimal : ImageLayout.ColorAttachmentOptimal; - if (_currentLayout == targetLayout) - return; - TransitionAllFaces(targetLayout); } @@ -180,23 +204,49 @@ public void TransitionToSampled() { ObjectDisposedException.ThrowIf(_disposed, this); - if (_currentLayout == ImageLayout.ShaderReadOnlyOptimal) - return; - TransitionAllFaces(ImageLayout.ShaderReadOnlyOptimal); } private void TransitionAllFaces(ImageLayout newLayout) { - // Transition all 6 faces at once + for (int i = 0; i < _faceLayouts.Length; i++) + { + TransitionFace(i, newLayout); + } + } + + internal void TransitionFaceToTransferDestination(int faceIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (faceIndex < 0 || faceIndex >= 6) + throw new ArgumentOutOfRangeException(nameof(faceIndex)); + + TransitionFace(faceIndex, ImageLayout.TransferDstOptimal); + } + + internal void TransitionFaceToSampled(int faceIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (faceIndex < 0 || faceIndex >= 6) + throw new ArgumentOutOfRangeException(nameof(faceIndex)); + + TransitionFace(faceIndex, ImageLayout.ShaderReadOnlyOptimal); + } + + private void TransitionFace(int faceIndex, ImageLayout newLayout) + { + ImageLayout oldLayout = _faceLayouts[faceIndex]; + if (oldLayout == newLayout) + return; + _context.TransitionImageLayout( _image, - _currentLayout, + oldLayout, newLayout, _format.GetAspectMask(), - baseArrayLayer: 0, - layerCount: 6); - _currentLayout = newLayout; + baseArrayLayer: (uint)faceIndex, + layerCount: 1); + _faceLayouts[faceIndex] = newLayout; } public void UploadFace(int faceIndex, ReadOnlySpan data) @@ -217,16 +267,10 @@ public void UploadFace(int faceIndex, ReadOnlySpan data) stagingBuffer.Upload(data); // Transition face to transfer destination - _context.TransitionImageLayout( - _image, - _currentLayout, - ImageLayout.TransferDstOptimal, - _format.GetAspectMask(), - baseArrayLayer: (uint)faceIndex, - layerCount: 1); + TransitionFace(faceIndex, ImageLayout.TransferDstOptimal); // Copy buffer to image - _context.SubmitImmediateCommands(cmd => + _context.RecordCommands(cmd => { var region = new BufferImageCopy { @@ -249,13 +293,7 @@ public void UploadFace(int faceIndex, ReadOnlySpan data) }); // Transition back to shader read - _context.TransitionImageLayout( - _image, - ImageLayout.TransferDstOptimal, - ImageLayout.ShaderReadOnlyOptimal, - _format.GetAspectMask(), - baseArrayLayer: (uint)faceIndex, - layerCount: 1); + TransitionFace(faceIndex, ImageLayout.ShaderReadOnlyOptimal); } public IntPtr GetFaceView(int faceIndex) @@ -283,32 +321,37 @@ public void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; - - // Destroy face views - for (int i = 0; i < 6; i++) + ImageView[] faceViews = _faceViews; + ImageView imageView = _imageView; + Silk.NET.Vulkan.Image image = _image; + DeviceMemory memory = _memory; + _context.DeferRelease(() => { - if (_faceViews[i].Handle != 0) + var vk = _context.Vk; + var device = _context.Device; + + foreach (ImageView faceView in faceViews) { - vk.DestroyImageView(device, _faceViews[i], null); + if (faceView.Handle != 0) + { + vk.DestroyImageView(device, faceView, null); + } } - } - // Destroy cube map view - if (_imageView.Handle != 0) - { - vk.DestroyImageView(device, _imageView, null); - } + if (imageView.Handle != 0) + { + vk.DestroyImageView(device, imageView, null); + } - if (_image.Handle != 0) - { - vk.DestroyImage(device, _image, null); - } + if (image.Handle != 0) + { + vk.DestroyImage(device, image, null); + } - if (_memory.Handle != 0) - { - vk.FreeMemory(device, _memory, null); - } + if (memory.Handle != 0) + { + vk.FreeMemory(device, memory, null); + } + }); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCubeArray.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCubeArray.cs index cfed6130cf..9f6a7684ae 100644 --- a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCubeArray.cs +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanTextureCubeArray.cs @@ -7,17 +7,19 @@ namespace Beutl.Graphics.Backend.Vulkan; /// Vulkan implementation of . /// Used for multiple point light shadow maps. /// -internal sealed unsafe class VulkanTextureCubeArray : ITextureCubeArray +internal sealed unsafe class VulkanTextureCubeArray : ITextureCubeArray, IVulkanContextResource { private readonly VulkanContext _context; private readonly Silk.NET.Vulkan.Image _image; private readonly DeviceMemory _memory; + + public VulkanContext OwnerContext => _context; private readonly ImageView _imageView; // Cube array view for sampling private readonly ImageView[,] _faceViews; // Individual face views [arrayIndex, faceIndex] for framebuffer attachment + private readonly ImageLayout[,] _faceLayouts; private readonly int _size; private readonly uint _arraySize; private readonly TextureFormat _format; - private ImageLayout _currentLayout = ImageLayout.Undefined; private bool _disposed; public VulkanTextureCubeArray( @@ -25,16 +27,26 @@ public VulkanTextureCubeArray( int size, uint arraySize, TextureFormat format, - ImageUsageFlags usage = ImageUsageFlags.SampledBit | ImageUsageFlags.DepthStencilAttachmentBit) + ImageUsageFlags usage) { if (arraySize == 0) throw new ArgumentException("Array size must be greater than 0", nameof(arraySize)); + if (!context.SupportsImageCubeArray) + { + // Named here rather than left to vkCreateImageView, whose failure mentions neither this texture + // nor the feature that is missing. + throw new NotSupportedException( + "This Vulkan device does not support cube-array image views (imageCubeArray), which a cube " + + "texture array needs both for its view and for the SampledCubeArray capability its shaders " + + "declare."); + } _context = context; _size = size; _arraySize = arraySize; _format = format; _faceViews = new ImageView[arraySize, 6]; + _faceLayouts = new ImageLayout[arraySize, 6]; var vk = context.Vk; var device = context.Device; @@ -162,6 +174,34 @@ public VulkanTextureCubeArray( _faceViews[arrIdx, faceIdx] = faceView; } } + + // Every allocated face is covered by the cube-array view the shader samples, whether or not any + // light ever renders into it, so a face has to start in a layout the sampler can read rather than + // in UNDEFINED, which is what the descriptor would otherwise present to the lighting pass. + try + { + _context.TransitionImageLayout( + _image, + ImageLayout.Undefined, + ImageLayout.ShaderReadOnlyOptimal, + _format.GetAspectMask(), + baseArrayLayer: 0, + layerCount: totalLayers); + } + catch + { + CleanupFaceViews(arraySize - 1, 6, vk, device); + vk.DestroyImageView(device, _imageView, null); + vk.FreeMemory(device, _memory, null); + vk.DestroyImage(device, _image, null); + throw; + } + + for (uint arrIdx = 0; arrIdx < arraySize; arrIdx++) + { + for (int faceIdx = 0; faceIdx < 6; faceIdx++) + _faceLayouts[arrIdx, faceIdx] = ImageLayout.ShaderReadOnlyOptimal; + } } private void CleanupFaceViews(uint currentArrayIdx, int currentFaceIdx, Vk vk, Device device) @@ -195,18 +235,13 @@ public void TransitionToSampled() { ObjectDisposedException.ThrowIf(_disposed, this); - if (_currentLayout == ImageLayout.ShaderReadOnlyOptimal) - return; - - // Transition all layers at once - _context.TransitionImageLayout( - _image, - _currentLayout, - ImageLayout.ShaderReadOnlyOptimal, - _format.GetAspectMask(), - baseArrayLayer: 0, - layerCount: _arraySize * 6); - _currentLayout = ImageLayout.ShaderReadOnlyOptimal; + for (uint arrayIndex = 0; arrayIndex < _arraySize; arrayIndex++) + { + for (int faceIndex = 0; faceIndex < 6; faceIndex++) + { + TransitionFace(arrayIndex, faceIndex, ImageLayout.ShaderReadOnlyOptimal); + } + } } /// @@ -224,15 +259,10 @@ public void TransitionCubeToAttachment(uint arrayIndex) ? ImageLayout.DepthStencilAttachmentOptimal : ImageLayout.ColorAttachmentOptimal; - // Transition all 6 faces of this cube map - uint baseLayer = arrayIndex * 6; - _context.TransitionImageLayout( - _image, - _currentLayout, - targetLayout, - _format.GetAspectMask(), - baseArrayLayer: baseLayer, - layerCount: 6); + for (int faceIndex = 0; faceIndex < 6; faceIndex++) + { + TransitionFace(arrayIndex, faceIndex, targetLayout); + } } /// @@ -251,14 +281,54 @@ public void TransitionFaceToAttachment(uint arrayIndex, int faceIndex) ? ImageLayout.DepthStencilAttachmentOptimal : ImageLayout.ColorAttachmentOptimal; + TransitionFace(arrayIndex, faceIndex, targetLayout); + } + + internal void TransitionFaceToTransferDestination(uint arrayIndex, int faceIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ValidateFace(arrayIndex, faceIndex); + TransitionFace(arrayIndex, faceIndex, ImageLayout.TransferDstOptimal); + } + + internal void TransitionFaceToSampled(uint arrayIndex, int faceIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ValidateFace(arrayIndex, faceIndex); + TransitionFace(arrayIndex, faceIndex, ImageLayout.ShaderReadOnlyOptimal); + } + + /// + internal ImageLayout GetFaceLayout(uint arrayIndex, int faceIndex) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ValidateFace(arrayIndex, faceIndex); + return _faceLayouts[arrayIndex, faceIndex]; + } + + private void TransitionFace(uint arrayIndex, int faceIndex, ImageLayout newLayout) + { + ImageLayout oldLayout = _faceLayouts[arrayIndex, faceIndex]; + if (oldLayout == newLayout) + return; + uint layerIndex = arrayIndex * 6 + (uint)faceIndex; _context.TransitionImageLayout( _image, - ImageLayout.Undefined, // We don't track per-face layout - targetLayout, + oldLayout, + newLayout, _format.GetAspectMask(), baseArrayLayer: layerIndex, layerCount: 1); + _faceLayouts[arrayIndex, faceIndex] = newLayout; + } + + private void ValidateFace(uint arrayIndex, int faceIndex) + { + if (arrayIndex >= _arraySize) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + if (faceIndex < 0 || faceIndex >= 6) + throw new ArgumentOutOfRangeException(nameof(faceIndex)); } public IntPtr GetFaceView(uint arrayIndex, int faceIndex) @@ -290,35 +360,37 @@ public void Dispose() if (_disposed) return; _disposed = true; - var vk = _context.Vk; - var device = _context.Device; - - // Destroy face views - for (uint arrIdx = 0; arrIdx < _arraySize; arrIdx++) + ImageView[,] faceViews = _faceViews; + ImageView imageView = _imageView; + Silk.NET.Vulkan.Image image = _image; + DeviceMemory memory = _memory; + _context.DeferRelease(() => { - for (int faceIdx = 0; faceIdx < 6; faceIdx++) + var vk = _context.Vk; + var device = _context.Device; + + foreach (ImageView faceView in faceViews) { - if (_faceViews[arrIdx, faceIdx].Handle != 0) + if (faceView.Handle != 0) { - vk.DestroyImageView(device, _faceViews[arrIdx, faceIdx], null); + vk.DestroyImageView(device, faceView, null); } } - } - // Destroy cube array view - if (_imageView.Handle != 0) - { - vk.DestroyImageView(device, _imageView, null); - } + if (imageView.Handle != 0) + { + vk.DestroyImageView(device, imageView, null); + } - if (_image.Handle != 0) - { - vk.DestroyImage(device, _image, null); - } + if (image.Handle != 0) + { + vk.DestroyImage(device, image, null); + } - if (_memory.Handle != 0) - { - vk.FreeMemory(device, _memory, null); - } + if (memory.Handle != 0) + { + vk.FreeMemory(device, memory, null); + } + }); } } diff --git a/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanValidationErrorLog.cs b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanValidationErrorLog.cs new file mode 100644 index 0000000000..024d04b4f2 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Backend/Vulkan/VulkanValidationErrorLog.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; + +namespace Beutl.Graphics.Backend.Vulkan; + +/// +/// Records the validation errors the Vulkan debug messenger reports, so something other than a log reader +/// can act on them. +/// +/// +/// +/// A validation error names API misuse the driver is not required to diagnose — a render pass instance +/// begun inside another, a handle submitted to a device that never created it — so work that produced one +/// has already entered undefined behaviour whatever its own assertions concluded. Writing it only to the +/// log leaves it invisible to anything that could fail on it, which is what this record exists to change: +/// a run with validation enabled reads as a gate. +/// +/// +/// The debug messenger is only created when validation is enabled, so on an ordinary run nothing is ever +/// recorded. +/// +/// +internal sealed class VulkanValidationErrorLog +{ + // A single mistake inside a loop can report thousands of times. The count stays exact; only the + // retained text is bounded, because its purpose is to name the failure, not to archive it. + private const int MaxRetainedMessages = 32; + + private readonly ConcurrentQueue _messages = new(); + private int _count; + + /// Gets the log the debug messenger writes to. + public static VulkanValidationErrorLog Shared { get; } = new(); + + /// Gets how many validation errors have been recorded. + public int Count => Volatile.Read(ref _count); + + /// Gets the retained messages, oldest first. + /// Fewer than once more errors have arrived than are retained. + public IReadOnlyList Messages => [.. _messages]; + + /// Records one validation error. + /// + /// is written from the unmanaged debug-messenger callback, on whichever thread the + /// layer reported on. + /// + public void Record(string? message) + { + Interlocked.Increment(ref _count); + _messages.Enqueue(string.IsNullOrEmpty(message) ? "" : message); + while (_messages.Count > MaxRetainedMessages && _messages.TryDequeue(out _)) + { + } + } + + /// + /// Describes the errors recorded since , or an empty string when none + /// were. + /// + /// A value read before the work being attributed. + public string DescribeSince(int previousCount) + { + int added = Count - previousCount; + return added <= 0 ? string.Empty : Format(added, Messages); + } + + internal static string Format(int added, IReadOnlyList retained) + { + IEnumerable relevant = added >= retained.Count + ? retained + : retained.Skip(retained.Count - added); + string body = string.Join(Environment.NewLine, relevant.Select(static item => " " + item)); + string header = $"{added} Vulkan validation error(s) were reported"; + if (added > retained.Count) + header += $" (the {retained.Count} most recent are shown)"; + + return $"{header}:{Environment.NewLine}{body}"; + } +} diff --git a/src/Beutl.Engine/Graphics/BrushConstructor.cs b/src/Beutl.Engine/Graphics/BrushConstructor.cs index a030a62f1e..ad1e248eed 100644 --- a/src/Beutl.Engine/Graphics/BrushConstructor.cs +++ b/src/Beutl.Engine/Graphics/BrushConstructor.cs @@ -1,4 +1,5 @@ using Beutl.Animation; +using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; using Beutl.Logging; using Beutl.Media; @@ -8,11 +9,40 @@ namespace Beutl.Graphics; +/// +/// and are stated rather than +/// defaulted because either one left implicit turns a failure into missing pixels: a delivery render that +/// defaulted to would ship a frame whose fill could not be allocated, and +/// a without a materializer degrades to transparent. +/// passes the canvas's own intent and materializer; a +/// directly constructed instance names both, passing when it paints no drawable brush. +/// public readonly struct BrushConstructor( - Rect bounds, Brush.Resource? brush, BlendMode blendMode, float scale = 1f, + Rect bounds, Brush.Resource? brush, BlendMode blendMode, RenderIntent intent, + DrawableBrushMaterializer? drawableBrushMaterializer, float scale = 1f, float maxWorkingScale = float.PositiveInfinity) { private static readonly ILogger s_logger = Log.CreateLogger("BrushConstructor"); + private readonly DrawableBrushMaterializer? _drawableBrushMaterializer = drawableBrushMaterializer; + private readonly RenderTargetLeaseSession? _renderTargetLeaseSession; + + /// + /// Binds the constructor to the render pass's lease session so a tile-brush intermediate is allocated + /// through the caller's rather than the global allocator. + /// + internal BrushConstructor( + Rect bounds, + Brush.Resource? brush, + BlendMode blendMode, + float scale, + float maxWorkingScale, + RenderIntent intent, + DrawableBrushMaterializer? drawableBrushMaterializer, + RenderTargetLeaseSession? renderTargetLeaseSession) + : this(bounds, brush, blendMode, intent, drawableBrushMaterializer, scale, maxWorkingScale) + { + _renderTargetLeaseSession = renderTargetLeaseSession; + } public Rect Bounds { get; } = bounds; @@ -26,37 +56,40 @@ public readonly struct BrushConstructor( /// public float Scale { get; } = scale; - /// Working-scale ceiling forwarded into nested pulls (e.g. ). - public float MaxWorkingScale { get; } = RenderNodeContext.SanitizeMaxWorkingScale(maxWorkingScale); + /// Working-scale ceiling applied to brush-owned intermediates and scoped legacy nested pulls. + public float MaxWorkingScale { get; } = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + + /// + /// Preview or delivery classification of the render this brush paints into. + /// degrades when a brush-owned intermediate cannot be allocated; + /// fails the render instead of shipping the brush without content. + /// + public RenderIntent Intent { get; } = Enum.IsDefined(intent) + ? intent + : throw new ArgumentOutOfRangeException(nameof(intent), intent, "Unknown render intent."); public void ConfigurePaint(SKPaint paint) { - // Handle BrushPresenter by delegating to the target brush - if (Brush is BrushPresenter.Resource presenter && presenter.Target != null) - { - new BrushConstructor(Bounds, presenter.Target, BlendMode, Scale, MaxWorkingScale).ConfigurePaint(paint); - return; - } - - float opacity = (Brush?.Opacity ?? 0) / 100f; + Brush.Resource? brush = ResolvePresentedBrush(); + float opacity = (brush?.Opacity ?? 0) / 100f; paint.IsAntialias = true; paint.BlendMode = (SKBlendMode)BlendMode; paint.Color = new SKColor(255, 255, 255, (byte)(255 * opacity)); - if (Brush is SolidColorBrush.Resource solid) + if (brush is SolidColorBrush.Resource solid) { paint.Color = new SKColor(solid.Color.R, solid.Color.G, solid.Color.B, (byte)(solid.Color.A * opacity)); } - else if (Brush is GradientBrush.Resource gradient) + else if (brush is GradientBrush.Resource gradient) { ConfigureGradientBrush(paint, gradient); } - else if (Brush is TileBrush.Resource tileBrush) + else if (brush is TileBrush.Resource tileBrush) { ConfigureTileBrush(paint, tileBrush); } - else if (Brush is PerlinNoiseBrush.Resource perlinNoiseBrush) + else if (brush is PerlinNoiseBrush.Resource perlinNoiseBrush) { ConfigurePerlinNoiseBrush(paint, perlinNoiseBrush); } @@ -68,27 +101,22 @@ public void ConfigurePaint(SKPaint paint) public SKShader? CreateShader() { - // Handle BrushPresenter by delegating to the target brush - if (Brush is BrushPresenter.Resource presenter && presenter.Target != null) - { - return new BrushConstructor(Bounds, presenter.Target, BlendMode, Scale, MaxWorkingScale).CreateShader(); - } - - float opacity = (Brush?.Opacity ?? 0) / 100f; - if (Brush is SolidColorBrush.Resource solid) + Brush.Resource? brush = ResolvePresentedBrush(); + float opacity = (brush?.Opacity ?? 0) / 100f; + if (brush is SolidColorBrush.Resource solid) { return SKShader.CreateColor(new SKColor(solid.Color.R, solid.Color.G, solid.Color.B, (byte)(solid.Color.A * opacity))); } - else if (Brush is GradientBrush.Resource gradient) + else if (brush is GradientBrush.Resource gradient) { return CreateGradientShader(gradient); } - else if (Brush is TileBrush.Resource tileBrush) + else if (brush is TileBrush.Resource tileBrush) { return CreateTileShader(tileBrush); } - else if (Brush is PerlinNoiseBrush.Resource perlinNoiseBrush) + else if (brush is PerlinNoiseBrush.Resource perlinNoiseBrush) { return CreatePerlinNoiseShader(perlinNoiseBrush); } @@ -96,6 +124,21 @@ public void ConfigurePaint(SKPaint paint) return null; } + private Brush.Resource? ResolvePresentedBrush() + { + Brush.Resource? brush = Brush; + HashSet? visited = null; + while (brush is BrushPresenter.Resource { Target: { } target } presenter) + { + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + if (!visited.Add(presenter)) + throw new InvalidOperationException("A BrushPresenter target cycle was detected."); + brush = target; + } + + return brush; + } + private SKShader? CreateGradientShader(GradientBrush.Resource gradientBrush) { var tileMode = gradientBrush.SpreadMethod.ToSKShaderTileMode(); @@ -230,63 +273,38 @@ private void ConfigureGradientBrush(SKPaint paint, GradientBrush.Resource gradie private SKShader? CreateTileShader(TileBrush.Resource tileBrush) { float s = Scale; - RenderTarget? renderTarget = null; SKImage? skImage; - PixelSize pixelSize; // logical content size (drives TileBrushCalculator) - float contentDensity; // skImage device px per logical content unit + Size contentSize; // logical content size (drives TileBrushCalculator) + float contentDensity; // skImage device px per logical content unit - if (tileBrush is ImageBrush.Resource imageBrush - && imageBrush.Source?.Bitmap is { } bitmap) - { - skImage = SKImage.FromBitmap(bitmap.SKBitmap); - pixelSize = new(bitmap.Width, bitmap.Height); - contentDensity = 1f; // the bitmap's native pixels ARE the logical content (1:1) - } - else if (tileBrush is DrawableBrush.Resource drawableBrush) + if (tileBrush is DrawableBrush.Resource drawableBrush) { - if (drawableBrush.Drawable is null) return null; - - var drawable = drawableBrush.Drawable; - using var node = new DrawableRenderNode(drawable); - using var context = new GraphicsContext2D(node, new Size((int)Bounds.Width, (int)Bounds.Height), s); - drawable.GetOriginal().Render(context, drawable); - var processor = new RenderNodeProcessor(node, true, s, MaxWorkingScale); - var ops = processor.RasterizeToRenderTargets(); - var totalBounds = ops.Aggregate(Rect.Empty, (current, item) => current.Union(item.Bounds)); - - int dw = Math.Max(1, (int)MathF.Ceiling((float)totalBounds.Width * s)); - int dh = Math.Max(1, (int)MathF.Ceiling((float)totalBounds.Height * s)); - renderTarget = RenderTarget.Create(dw, dh); - if (renderTarget == null) + if (_drawableBrushMaterializer is not { } materializer) { - // Dispose ops that the blit loop below would have consumed. - foreach (var op in ops) - op.RenderTarget.Dispose(); - s_logger.LogWarning( - "DrawableBrush content buffer allocation failed ({Width}x{Height} px, density {Scale}); preview fill degrades to solid white, delivery render fails fast.", - dw, dh, s); - ThrowIfDeliveryAllocationFailure( - $"DrawableBrush content buffer allocation failed ({dw}x{dh} px, density {s})."); + "DrawableBrush '{Brush}' cannot be materialized because no runtime materializer is available; the fill degrades to transparent.", + drawableBrush); return null; } - // Density 1: raw device-px blits with hand-computed offsets (no base CTM re-scale). - using (var icanvas = new ImmediateCanvas(renderTarget, 1f, MaxWorkingScale)) + if (materializer(drawableBrush, Bounds, s) is not { } materialized) { - icanvas.Clear(); - - foreach (var op in ops) - { - Point offset = (op.Bounds.Position - totalBounds.Position) * s; - icanvas.DrawRenderTarget(op.RenderTarget, offset); - op.RenderTarget.Dispose(); - } + s_logger.LogWarning( + "The drawable-brush materializer returned no image for '{Brush}'; the fill degrades to transparent.", + drawableBrush); + return null; } - pixelSize = new PixelSize((int)totalBounds.Width, (int)totalBounds.Height); + skImage = materialized.Image; + contentSize = materialized.ContentBounds.Size; contentDensity = s; - skImage = renderTarget.Value.Snapshot(); + } + else if (tileBrush is ImageBrush.Resource imageBrush + && imageBrush.Source?.Bitmap is { } bitmap) + { + skImage = SKImage.FromBitmap(bitmap.SKBitmap); + contentSize = new Size(bitmap.Width, bitmap.Height); + contentDensity = 1f; // the bitmap's native pixels ARE the logical content (1:1) } else { @@ -294,30 +312,42 @@ private void ConfigureGradientBrush(SKPaint paint, GradientBrush.Resource gradie } RenderTarget? intermediate = null; + RenderTargetLease? intermediateLease = null; try { if (skImage == null) return null; - var calc = new TileBrushCalculator(tileBrush, pixelSize.ToSize(1), Bounds.Size); + var calc = new TileBrushCalculator(tileBrush, contentSize, Bounds.Size); int iw = Math.Max(1, (int)MathF.Ceiling((float)calc.IntermediateSize.Width * s)); int ih = Math.Max(1, (int)MathF.Ceiling((float)calc.IntermediateSize.Height * s)); - intermediate = RenderTarget.Create(iw, ih); + if (_renderTargetLeaseSession is { HasTargetFactory: true } leaseSession) + { + intermediateLease = leaseSession.TryAcquire(new PixelSize(iw, ih)); + intermediate = intermediateLease?.Target; + } + else + { + intermediate = RenderTarget.Create(iw, ih); + } + if (intermediate == null) { s_logger.LogWarning( - "Tile-brush intermediate allocation failed ({Width}x{Height} px, density {Scale}); preview fill degrades to solid white, delivery render fails fast.", + "Tile-brush intermediate allocation failed ({Width}x{Height} px, density {Scale}); preview fill degrades to transparent, delivery render fails fast.", iw, ih, s); ThrowIfDeliveryAllocationFailure( $"Tile-brush intermediate allocation failed ({iw}x{ih} px, density {s})."); + _renderTargetLeaseSession?.MarkContentDropped(); return null; } // Density 1: the SetMatrix below builds an absolute device matrix with Scale(s) folded in. - using (var canvas = new ImmediateCanvas(intermediate, 1f, MaxWorkingScale)) + using (var canvas = new ImmediateCanvas(intermediate, 1f, MaxWorkingScale, intent: Intent)) using (var paintTmp = new SKPaint()) { + canvas.DrawableBrushMaterializer = _drawableBrushMaterializer; canvas.Canvas.Clear(); canvas.Canvas.Save(); Rect clip = calc.IntermediateClip; @@ -372,14 +402,17 @@ private void ConfigureGradientBrush(SKPaint paint, GradientBrush.Resource gradie finally { skImage?.Dispose(); - intermediate?.Dispose(); - renderTarget?.Dispose(); + // A leased target belongs to the pool: release the lease, never the target behind it. + if (intermediateLease is not null) + intermediateLease.Dispose(); + else + intermediate?.Dispose(); } } private void ThrowIfDeliveryAllocationFailure(string message) { - if (float.IsPositiveInfinity(MaxWorkingScale)) + if (Intent == RenderIntent.Delivery) { throw new InvalidOperationException(message); } @@ -392,6 +425,10 @@ private void ConfigureTileBrush(SKPaint paint, TileBrush.Resource tileBrush) { paint.Shader = shader; } + else + { + paint.Color = SKColors.Transparent; + } } private SKShader? CreatePerlinNoiseShader(PerlinNoiseBrush.Resource perlinNoiseBrush) @@ -437,4 +474,5 @@ private void ConfigurePerlinNoiseBrush(SKPaint paint, PerlinNoiseBrush.Resource paint.Shader = shader; } } + } diff --git a/src/Beutl.Engine/Graphics/CanvasPushedState.cs b/src/Beutl.Engine/Graphics/CanvasPushedState.cs index 2370ca5a57..357c76781e 100644 --- a/src/Beutl.Engine/Graphics/CanvasPushedState.cs +++ b/src/Beutl.Engine/Graphics/CanvasPushedState.cs @@ -15,6 +15,8 @@ public override void Pop(ImmediateCanvas canvas) } } + internal sealed record LayerPushedState(int Count) : SKCanvasPushedState(Count); + // No-op pop for PushDeviceSpace when the canvas is already in device space. internal sealed record NoOpPushedState : CanvasPushedState { @@ -57,31 +59,41 @@ public override void Pop(ImmediateCanvas canvas) } } - internal record BlendModePushedState(BlendMode BlendMode, int Count, SKPaint Paint) : CanvasPushedState + internal record BlendModePushedState( + BlendMode BlendMode, + bool ProductRectangleCoverage, + int Count, + SKPaint Paint) : CanvasPushedState { public override void Pop(ImmediateCanvas canvas) { canvas.Canvas.RestoreToCount(Count); canvas.BlendMode = BlendMode; + canvas._productRectangleCoverage = ProductRectangleCoverage; Paint.Dispose(); } } - internal record OpacityPushedState(float Opacity, int Count, SKPaint Paint) : CanvasPushedState + internal record DirectBlendModePushedState( + BlendMode BlendMode, + BlendMode? DirectBlendMode, + int Count) : CanvasPushedState { public override void Pop(ImmediateCanvas canvas) { - canvas._sharedFillPaint.Reset(); - canvas._sharedFillPaint.BlendMode = SKBlendMode.DstIn; - - canvas.Canvas.SaveLayer(canvas._sharedFillPaint); - using (SKPaint maskPaint = Paint) - { - canvas.Canvas.DrawPaint(maskPaint); - } - - canvas.Canvas.Restore(); + canvas.Canvas.RestoreToCount(Count); + canvas._currentTransform = canvas.Canvas.TotalMatrix.ToMatrix(); + canvas.BlendMode = BlendMode; + canvas._directBlendMode = DirectBlendMode; + } + } + internal record OpacityPushedState(float Opacity, int Count) : CanvasPushedState + { + public override void Pop(ImmediateCanvas canvas) + { + // The opacity rides on the layer paint's color filter, so restoring the layer applies it. + // No mask draw and no retained paint are needed on pop. canvas.Canvas.RestoreToCount(Count); canvas.Opacity = Opacity; } diff --git a/src/Beutl.Engine/Graphics/DrawableBrushMaterializer.cs b/src/Beutl.Engine/Graphics/DrawableBrushMaterializer.cs new file mode 100644 index 0000000000..af29bcbcd4 --- /dev/null +++ b/src/Beutl.Engine/Graphics/DrawableBrushMaterializer.cs @@ -0,0 +1,37 @@ +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.Graphics; + +/// +/// The rasterized content, sized at the request density. Ownership transfers to +/// the requesting , which disposes it before +/// or returns — +/// on every path, including tile-intermediate allocation failure and exceptions. A materializer therefore returns +/// an image it does not retain — a fresh one per call, or an independently ref-counted copy — and never disposes +/// it itself. Handing back a cached or shared instance destroys it for the materializer's own later uses. +/// +/// +/// The drawable's own logical bounds. A tile brush stretches and tiles against these, so they must +/// describe the content itself rather than the destination the brush was asked to fill. +/// +public readonly record struct MaterializedDrawableBrush(SKImage Image, Rect ContentBounds); + +/// Rasterizes the content of a so a tile shader can sample it. +/// The brush whose drawable content to rasterize. +/// The logical frame the brush was asked to fill. +/// Device pixels per logical unit to rasterize at. +/// +/// The materialized content, or when there is nothing to draw. The caller takes ownership of +/// and disposes it once the tile shader is built or the fill fails. +/// +/// +/// A built by inherits the +/// canvas's materializer. One constructed directly has none until the caller supplies it, and a +/// painted without one degrades to transparent. +/// +public delegate MaterializedDrawableBrush? DrawableBrushMaterializer( + DrawableBrush.Resource brush, + Rect bounds, + float scale); diff --git a/src/Beutl.Engine/Graphics/DrawableDecorator.cs b/src/Beutl.Engine/Graphics/DrawableDecorator.cs index 5cbc9d9c2e..696e602ed8 100644 --- a/src/Beutl.Engine/Graphics/DrawableDecorator.cs +++ b/src/Beutl.Engine/Graphics/DrawableDecorator.cs @@ -45,7 +45,7 @@ public override void Render(GraphicsContext2D context, Drawable.Resource resourc using (r.FilterEffect == null ? new() : context.PushFilterEffect(r.FilterEffect)) using (context.PushNode( boundsMemory, - b => new DrawableGroup.BoundsObserveNode(b), + b => new DrawableGroup.ContentBoundsRenderNode(b), (n, b) => n.Update(b))) { context.DrawDrawable(child); diff --git a/src/Beutl.Engine/Graphics/DrawableGroup.cs b/src/Beutl.Engine/Graphics/DrawableGroup.cs index 9348d44178..d16043f17c 100644 --- a/src/Beutl.Engine/Graphics/DrawableGroup.cs +++ b/src/Beutl.Engine/Graphics/DrawableGroup.cs @@ -30,6 +30,9 @@ public override void Render(GraphicsContext2D context, Drawable.Resource resourc Size availableSize = context.Size; var boundsMemory = context.UseMemory(); var transformParams = (r.Transform, r.TransformOrigin, availableSize, boundsMemory); + bool isolatesContent = resource.Opacity != 100f + || r.BlendMode != Graphics.BlendMode.SrcOver + || r.Children.Any(static child => child.BlendMode != Graphics.BlendMode.SrcOver); using (context.PushBlendMode(r.BlendMode)) using (context.PushNode( @@ -41,10 +44,14 @@ public override void Render(GraphicsContext2D context, Drawable.Resource resourc b.Transform, b.TransformOrigin, b.availableSize, Media.AlignmentX.Left, Media.AlignmentY.Top, b.boundsMemory))) using (context.PushOpacity(resource.Opacity / 100f)) + using (context.PushNode( + isolatesContent, + b => new ContentIsolationRenderNode(b), + (n, b) => n.Update(b))) using (r.FilterEffect == null ? new() : context.PushFilterEffect(r.FilterEffect)) using (context.PushNode( boundsMemory, - b => new BoundsObserveNode(b), + b => new ContentBoundsRenderNode(b), (n, b) => n.Update(b))) { OnDraw(context, r); @@ -114,14 +121,9 @@ partial void PostDispose(bool disposing) } } - internal sealed class BoundsObserveNode : ContainerRenderNode + internal sealed class ContentBoundsRenderNode(MemoryNode memoryNode) : ContainerRenderNode { - public BoundsObserveNode(MemoryNode memoryNode) - { - MemoryNode = memoryNode; - } - - public MemoryNode MemoryNode { get; private set; } + public MemoryNode MemoryNode { get; private set; } = memoryNode; public bool Update(MemoryNode memoryNode) { @@ -135,10 +137,45 @@ public bool Update(MemoryNode memoryNode) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - MemoryNode.Value = context.CalculateBounds(); - return context.Input; + MemoryNode.Value = context.CalculateRecordedInputBoundsHint(); + context.PassThrough(); + } + } + + internal sealed class ContentIsolationRenderNode(bool isolatesContent) : ContainerRenderNode + { + public bool IsolatesContent { get; private set; } = isolatesContent; + + public bool Update(bool isolatesContent) + { + if (isolatesContent != IsolatesContent) + { + IsolatesContent = isolatesContent; + HasChanges = true; + return true; + } + + return false; + } + + public override void Process(RenderNodeContext context) + { + if (IsolatesContent) + { + // A full-target write in the group - a clear, an opaque raw command - has no recorded value + // bounds, so scoping by them would make the isolation scope empty and drop the group's whole + // contribution instead of compositing it. + TargetRegion region = context.HasSymbolicInputTargetWrite() + ? TargetRegion.Full + : TargetRegion.Region(context.CalculateRecordedInputBoundsHint()); + context.Publish(context.TargetLayerScope(context.Inputs, region)); + } + else + { + context.PassThrough(); + } } } @@ -203,7 +240,11 @@ public bool Update( changed = true; } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } @@ -264,30 +305,78 @@ private Point CalculateTranslate(Size bounds) return new Point(x, y); } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { var bounds = Bounds.Value; var transform = GetTransformMatrix(bounds); - return context.Input.Select(r => - RenderNodeOperation.CreateLambda( - r.Bounds.TransformToAABB(transform), - canvas => - { - using (canvas.PushTransform(transform)) - { - r.Render(canvas); - } - }, - hitTest: point => - { - if (transform.HasInverse) - point *= transform.Invert(); - return r.HitTest(point); - }, - onDispose: r.Dispose, - // Re-scale a bitmap child's supply density through the transform boundary. - effectiveScale: TransformRenderNode.RescaleDensity(r.EffectiveScale, transform))) - .ToArray(); + bool hasInverse = transform.HasInverse; + Matrix inverse = hasInverse ? transform.Invert() : Matrix.Identity; + var metadataState = new CustomTransformMetadataState( + transform, + hasInverse, + inverse, + context.TargetDomain); + RenderBoundsContract boundsContract = hasInverse + ? RenderBoundsContract.Create( + metadataState.TransformBounds, + metadataState.GetRequiredInputBounds) + : RenderBoundsContract.CreateFullInput( + metadataState.TransformBounds); + var scaleMapper = new TransformScaleMapper(transform); + TargetScopeDescription description = TargetScopeDescription.CreateValueReplayMap( + execute: session => ExecuteTransform(session, transform), + bounds: boundsContract, + hitTest: RenderHitTestContract.Custom(metadataState.HitTest), + scale: RenderScaleContract.MapInputSupply( + scaleMapper.MapSupply, + scaleMapper.MapDemand), + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: transform.IsIdentity + ? RenderDeviceGridMapping.Preserved + : RenderDeviceGridMapping.Remapped, + builtInBackdropCapturesBackingTarget: true); + context.PublishMappedInputs( + description, + static (context, input, value) => context.TargetScope(input, value)); + } + + private static void ExecuteTransform(TargetScopeSession session, Matrix transform) + { + session.Canvas.Use(canvas => + { + using (canvas.PushTransform(transform)) + { + session.ReplayInput(); + } + }); + } + + private readonly record struct CustomTransformMetadataState( + Matrix Transform, + bool HasInverse, + Matrix Inverse, + Rect? DeliveredTo) + { + public Rect TransformBounds(Rect inputBounds) + => inputBounds.TransformToDeliveredAABB(Transform, DeliveredTo); + + public Rect GetRequiredInputBounds(Rect outputBounds) => outputBounds.TransformToAABB(Inverse); + + public bool HitTest(RenderHitTestContext context, Point point) + { + if (HasInverse) + point *= Inverse; + return context.Inputs[0].HitTest(point); + } + } + + private readonly record struct TransformScaleMapper(Matrix Transform) + { + public EffectiveScale MapSupply(EffectiveScale inputSupply) + => TransformRenderNode.RescaleDensity(inputSupply, Transform); + + public EffectiveScale MapDemand(EffectiveScale outputDemand) + => TransformRenderNode.RescaleDemand(outputDemand, Transform); } } } diff --git a/src/Beutl.Engine/Graphics/DrawablePresenter.cs b/src/Beutl.Engine/Graphics/DrawablePresenter.cs index 53832d043b..d2f3f5baec 100644 --- a/src/Beutl.Engine/Graphics/DrawablePresenter.cs +++ b/src/Beutl.Engine/Graphics/DrawablePresenter.cs @@ -19,7 +19,7 @@ public DrawablePresenter() public override void Render(GraphicsContext2D context, Drawable.Resource resource) { var r = (Resource)resource; - r.Target?.GetOriginal().Render(context, r.Target); + r.Target?.GetOriginal()!.Render(context, r.Target); } protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) @@ -29,6 +29,6 @@ protected override void OnDraw(GraphicsContext2D context, Drawable.Resource reso protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) { var r = (Resource)resource; - return r.Target?.GetOriginal().MeasureInternal(availableSize, r.Target) ?? Size.Empty; + return r.Target?.GetOriginal()!.MeasureInternal(availableSize, r.Target) ?? Size.Empty; } } diff --git a/src/Beutl.Engine/Graphics/DrawableTimeController.cs b/src/Beutl.Engine/Graphics/DrawableTimeController.cs index de59f19dae..5463087850 100644 --- a/src/Beutl.Engine/Graphics/DrawableTimeController.cs +++ b/src/Beutl.Engine/Graphics/DrawableTimeController.cs @@ -154,13 +154,13 @@ private TimeSpan CalculateTargetTime(TimeSpan currentTime, Resource resource, Dr public override void Render(GraphicsContext2D context, Drawable.Resource resource) { var r = (Resource)resource; - r.Target?.GetOriginal().Render(context, r.Target); + r.Target?.GetOriginal()!.Render(context, r.Target); } protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) { var r = (Resource)resource; - return r.Target?.GetOriginal().MeasureInternal(availableSize, r.Target) ?? Size.Empty; + return r.Target?.GetOriginal()!.MeasureInternal(availableSize, r.Target) ?? Size.Empty; } protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) diff --git a/src/Beutl.Engine/Graphics/FilterEffects/BuiltInColorFilterShader.cs b/src/Beutl.Engine/Graphics/FilterEffects/BuiltInColorFilterShader.cs new file mode 100644 index 0000000000..358f287a8f --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/BuiltInColorFilterShader.cs @@ -0,0 +1,103 @@ +using Beutl.Utilities; + +namespace Beutl.Graphics.Effects; + +/// +/// Provides CurrentPixel equivalents of Skia built-in color filters whose behavior is not expressible as a +/// color matrix. +/// +internal static class BuiltInColorFilterShader +{ + private const string LumaColorSource = + """ + half4 apply(half4 color) { + // CurrentPixel input is premultiplied, so this dot product includes the source alpha. That is the + // defining difference between Skia's LumaColor and a luminance-to-alpha color matrix. + half luma = saturate(dot(half3(0.2126, 0.7152, 0.0722), color.rgb)); + return half4(0.0, 0.0, 0.0, luma); + } + """; + + private const string HighContrastSource = + """ + uniform half grayscale; + uniform half invertStyle; + uniform half contrast; + + half3 hslToRgb(half3 hsl) { + half chroma = (1.0 - abs(2.0 * hsl.z - 1.0)) * hsl.y; + half3 p = hsl.xxx + half3(0.0, 2.0 / 3.0, 1.0 / 3.0); + half3 q = saturate(abs(fract(p) * 6.0 - 3.0) - 1.0); + return (q - 0.5) * chroma + hsl.z; + } + + half3 rgbToHsl(half3 color) { + half maximum = max(max(color.r, color.g), color.b); + half minimum = min(min(color.r, color.g), color.b); + half delta = maximum - minimum; + half inverseDelta = 1.0 / delta; + half greenLessThanBlue = color.g < color.b ? 6.0 : 0.0; + half hue = (1.0 / 6.0) * (maximum == minimum + ? 0.0 + : color.r >= color.g && color.r >= color.b + ? inverseDelta * (color.g - color.b) + greenLessThanBlue + : color.g >= color.b + ? inverseDelta * (color.b - color.r) + 2.0 + : inverseDelta * (color.r - color.g) + 4.0); + half sum = maximum + minimum; + half lightness = sum * 0.5; + half saturation = maximum == minimum + ? 0.0 + : delta / (lightness > 0.5 ? 2.0 - sum : sum); + return half3(hue, saturation, lightness); + } + + half4 apply(half4 color) { + // Skia evaluates HighContrast in a linear, unpremultiplied working format. CurrentPixel receives + // linear premultiplied pixels, so make that conversion explicit and restore premultiplication below. + half4 straight = unpremul(color); + half3 transformed = straight.rgb; + if (grayscale == 1.0) { + transformed = dot(half3(0.2126, 0.7152, 0.0722), transformed).rrr; + } + if (invertStyle == 1.0) { + transformed = 1.0 - transformed; + } else if (invertStyle == 2.0) { + transformed = rgbToHsl(transformed); + transformed.b = 1.0 - transformed.b; + transformed = hslToRgb(transformed); + } + transformed = mix(half3(0.5), transformed, contrast); + return half4(saturate(transformed) * color.a, color.a); + } + """; + + private static readonly SkslSource s_lumaColorSource = + new(LumaColorSource, ShaderDescriptionKind.CurrentPixel); + + private static readonly SkslSource s_highContrastSource = + new(HighContrastSource, ShaderDescriptionKind.CurrentPixel); + + internal static ShaderDescription LumaColor() + => ShaderDescription.CurrentPixel(s_lumaColorSource, bindings: null); + + internal static ShaderDescription HighContrast( + bool grayscale, + HighContrastInvertStyle invertStyle, + float contrast) + { + float pinned = Math.Clamp( + contrast, + -1f + MathUtilities.FloatEpsilon, + 1f - MathUtilities.FloatEpsilon); + float contrastScale = (1f + pinned) / (1f - pinned); + return ShaderDescription.CurrentPixel( + s_highContrastSource, + bindings => + { + bindings.Uniform("grayscale", grayscale ? 1f : 0f); + bindings.Uniform("invertStyle", (float)invertStyle); + bindings.Uniform("contrast", contrastScale); + }); + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ChromaKey.cs b/src/Beutl.Engine/Graphics/FilterEffects/ChromaKey.cs index 84841000b8..0ef8a96af5 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ChromaKey.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ChromaKey.cs @@ -1,100 +1,114 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.ChromaKey), ResourceType = typeof(GraphicsStrings))] public partial class ChromaKey : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static ChromaKey() - { - string sksl = - """ - uniform shader src; - uniform float4 color; - uniform float hueRange; - uniform float saturationRange; - uniform float boundary; - - // RGBからHSVへの変換関数 - half3 rgb2hsv(half3 c) { - half r = c.r; - half g = c.g; - half b = c.b; - half maxc = max(r, max(g, b)); - half minc = min(r, min(g, b)); - half delta = maxc - minc; - half h = 0.0; - - if (delta > 0.00001) { - if (maxc == r) { - h = mod((g - b) / delta, 6.0); - } else if (maxc == g) { - h = (b - r) / delta + 2.0; - } else { - h = (r - g) / delta + 4.0; - } - h = h / 6.0; // 0~1の範囲に正規化 + private const string ShaderSource = + """ + uniform float3 keyColor; + uniform float3 keyColorLinear; + uniform float hueRange; + uniform float saturationRange; + uniform float boundary; + + // A solid fill reaches this shader quantized onto an 8-bit grid in the render target's colour + // space, which is linear light, so a pixel authored as the key colour arrives up to half a linear + // code away from the uniform. Only in linear light is that error uniform - half a code spans about + // ten sRGB levels near black and a fifth of one near white - so the match is tested here, before + // the transfer curve, instead of on the hue and saturation differences below. The second term + // covers the render target's own half-precision storage of the quantized value. Testing + // premultiplied keeps the bound independent of alpha, at the cost of matching any colour once + // alpha is small enough that the quantum swamps the difference. + const float kLinearQuantum = 0.5 / 255.0; + const float kHalfStorageUlp = 1.0 / 2048.0; + + // Hue divides by the chroma, and that same quantization can manufacture one linear code of chroma, so + // a key colour below one code has no dependable hue and full confidence only above two. Only the key + // is measured: withholding the hue term is a vote to remove, because the shader removes what no term + // claims, and a pixel whose own chroma is low is thereby known not to be a chromatic key. + const half kHueChromaFloor = 1.0 / 255.0; + + // Slack for content that arrives near, but not on, the key colour, and the narrowest smoothstep + // this shader will run: equal edges are undefined in the shading languages, and Boundary 0 is the + // natural authoring choice for a hard key. + const half kEdgeTolerance = 1.0 / 255.0; + + half3 rgb2hsv(half3 value) { + half r = value.r; + half g = value.g; + half b = value.b; + half maxc = max(r, max(g, b)); + half minc = min(r, min(g, b)); + half delta = maxc - minc; + half h = 0.0; + + if (delta > 0.00001) { + if (maxc == r) { + h = mod((g - b) / delta, 6.0); + } else if (maxc == g) { + h = (b - r) / delta + 2.0; + } else { + h = (r - g) / delta + 4.0; } - - half s = (maxc <= 0.0) ? 0.0 : (delta / maxc); - half v = maxc; - return half3(h, s, v); - } - - // リニアsRGB -> sRGBガンマ変換 - half3 linearToSrgb(half3 c) { - half3 lo = c * 12.92; - half3 hi = 1.055 * pow(c, half3(1.0/2.4)) - 0.055; - return mix(lo, hi, step(half3(0.0031308), c)); + h = h / 6.0; } - half4 main(float2 fragCoord) { - half4 c = src.eval(fragCoord); + half s = (maxc <= 0.0) ? 0.0 : (delta / maxc); + half v = maxc; + return half3(h, s, v); + } - // プリマルチプライドアルファを解除 - half alpha = c.a; - if (alpha <= 0.0001) return half4(0.0); - half3 rgb = c.rgb / alpha; + half3 linearToSrgb(half3 value) { + half3 lo = value * 12.92; + half3 hi = 1.055 * pow(value, half3(1.0 / 2.4)) - 0.055; + return mix(lo, hi, step(half3(0.0031308), value)); + } - // リニアsRGB → sRGBガンマに変換してからHSV比較 - // (color uniformはsRGBガンマ空間のため、同じ空間で比較する) - half3 srgbColor = linearToSrgb(rgb); + half chroma(half3 value) { + return max(value.r, max(value.g, value.b)) - min(value.r, min(value.g, value.b)); + } - half3 hsv = rgb2hsv(srgbColor); - half3 keyHSV = rgb2hsv(color.rgb); + half4 apply(half4 color) { + half alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + half3 rgb = color.rgb / alpha; - // 色相の差を計算(周期性を考慮) - half hueDiff = abs(hsv.x - keyHSV.x); - hueDiff = min(hueDiff, 1.0 - hueDiff); + float3 keyPremul = keyColorLinear * float(alpha); + float3 excess = abs(float3(color.rgb) - keyPremul) + - (kLinearQuantum + (kHalfStorageUlp * keyPremul)); + half onKeyColor = max(excess.r, max(excess.g, excess.b)) <= 0.0 ? 1.0 : 0.0; - // 彩度の差の絶対値 - half satDiff = abs(hsv.y - keyHSV.y); + half3 hsv = rgb2hsv(linearToSrgb(rgb)); + half3 keyHSV = rgb2hsv(keyColor); - half maskHue = smoothstep(hueRange, hueRange + boundary, hueDiff); - half maskSat = smoothstep(saturationRange, saturationRange + boundary, satDiff); + half hueDiff = abs(hsv.x - keyHSV.x); + hueDiff = min(hueDiff, 1.0 - hueDiff); - // 色相と彩度の両条件を満たすかを判定 - half mask = max(maskHue, maskSat); + half satDiff = abs(hsv.y - keyHSV.y); - // 元のプリマルチプライド値にマスクを乗算して透過させる - return c * mask; - } - """; + half width = max(boundary, kEdgeTolerance); + half hueEdge0 = hueRange + kEdgeTolerance; + half satEdge0 = saturationRange + kEdgeTolerance; + half hueSignal = smoothstep( + kHueChromaFloor, + 2.0 * kHueChromaFloor, + chroma(half3(keyColorLinear))); + half maskHue = smoothstep(hueEdge0, hueEdge0 + width, hueDiff) * hueSignal; + half maskSat = smoothstep(satEdge0, satEdge0 + width, satDiff); + half mask = max(maskHue, maskSat) * (1.0 - onKeyColor); - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + return color * mask; } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public ChromaKey() { @@ -116,32 +130,21 @@ public ChromaKey() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - context.CustomEffect( - (color: r.Color, hueRange: r.HueRange, satRange: r.SaturationRange, boundary: r.Boundary), - OnApplyTo, - static (_, r) => r); - } - - private static void OnApplyTo((Color color, float hueRange, float satRange, float boundary) data, CustomFilterEffectContext c) - { - if (s_shader is null) return; - for (int i = 0; i < c.Targets.Count; i++) - { - using EffectTarget effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); - - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["color"] = data.color.ToSKColor(); - builder.Uniforms["hueRange"] = data.hueRange / 360f; - builder.Uniforms["saturationRange"] = data.satRange / 100f; - builder.Uniforms["boundary"] = data.boundary / 100f; - - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } + Vector4 linear = r.Color.ToLinear(); + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform( + "keyColor", + new Vector3( + r.Color.R / 255f, + r.Color.G / 255f, + r.Color.B / 255f)); + bindings.Uniform("keyColorLinear", new Vector3(linear.X, linear.Y, linear.Z)); + bindings.Uniform("hueRange", r.HueRange / 360f); + bindings.Uniform("saturationRange", r.SaturationRange / 100f); + bindings.Uniform("boundary", r.Boundary / 100f); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Clipping.cs b/src/Beutl.Engine/Graphics/FilterEffects/Clipping.cs index 74577db19b..646749b804 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Clipping.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Clipping.cs @@ -52,12 +52,8 @@ private static Rect TransformBounds((Thickness thickness, bool autoCenter, bool return result; } - private static Thickness FindRectAndReturnThickness(SKSurface surface) + private static Thickness FindRectAndReturnThickness(Bitmap bitmap) { - surface.Flush(true, true); - using var image = surface.Snapshot(); - using var bitmap = image.ToBitmap(BitmapColorType.Alpha8); - int x0 = bitmap.Width; int y0 = bitmap.Height; int x1 = 0; @@ -91,11 +87,11 @@ private static void Apply((Thickness thickness, bool autoCenter, bool autoClip) Thickness thickness = originalThickness; var target = context.Targets[i]; float w = context.WorkingScale; - var surface = target.RenderTarget!.Value; if (data.autoClip) { // FindRect detects in device px; convert to logical (/ w). - Thickness detected = FindRectAndReturnThickness(surface); + using Bitmap bitmap = target.RenderTarget!.SnapshotAlpha(); + Thickness detected = FindRectAndReturnThickness(bitmap); thickness += new Thickness(detected.Left / w, detected.Top / w, detected.Right / w, detected.Bottom / w); } @@ -133,6 +129,12 @@ private static void Apply((Thickness thickness, bool autoCenter, bool autoClip) ? originalRect.CenterRect(clipRect).Translate(target.Bounds.Position) : newBounds; EffectTarget newTarget = context.CreateTarget(targetBounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + continue; + } + // Crop offset and source blit are device px; enter device space. using (ImmediateCanvas newCanvas = context.Open(newTarget)) using (newCanvas.PushDeviceSpace()) diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ColorGrading.cs b/src/Beutl.Engine/Graphics/FilterEffects/ColorGrading.cs index ad25ef3102..e5d3c17153 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ColorGrading.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ColorGrading.cs @@ -1,25 +1,16 @@ using System.ComponentModel.DataAnnotations; -using System.Reactive; +using System.Numerics; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.ColorGrading), ResourceType = typeof(GraphicsStrings))] public sealed partial class ColorGrading : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static ColorGrading() - { - const string sksl = - """ - uniform shader src; + private const string ShaderSource = + """ uniform float exposure; // EV stops (-5 to +5) uniform float contrast; // -1 to +1 uniform float contrastPivot; // typically 0.18 or 0.5 @@ -38,6 +29,7 @@ static ColorGrading() uniform float lowRange; uniform float highRange; + const float HALF_MAX = 65504.0; const float3 LUMINANCE_COEFF = float3(0.2126, 0.7152, 0.0722); float get_luminance(float3 color) { @@ -55,8 +47,10 @@ float3 apply_lift_gamma_gain(float3 color, float3 l, float3 g, float3 gn) { color = color + l * (1.0 - color); float3 safe_gamma = max(g, float3(0.001)); - color = pow(max(color, float3(0.0)), 1.0 / safe_gamma); - color *= gn; + color = min( + pow(max(color, float3(0.0)), 1.0 / safe_gamma), + float3(HALF_MAX)); + color = clamp(color * gn, float3(-HALF_MAX), float3(HALF_MAX)); return color; } @@ -76,8 +70,8 @@ float3 apply_saturation(float3 color, float sat) { return mix(float3(luma), color, 1.0 + sat); } - float3 apply_hue(float3 color, float hue) { - float rad = radians(hue); + float3 apply_hue(float3 color, float hueAmount) { + float rad = radians(hueAmount); float cos_a = cos(rad); float sin_a = sin(rad); @@ -102,53 +96,45 @@ float3 apply_hue(float3 color, float hue) { return yiq_to_rgb * (rotation * (rgb_to_yiq * color)); } - float3 apply_temperature_tint(float3 color, float temperature, float tint) { + float3 apply_temperature_tint(float3 color, float temperatureAmount, float tintAmount) { float3 temp_adjustment = float3( - 1.0 + temperature * 0.1, + 1.0 + temperatureAmount * 0.1, 1.0, - 1.0 - temperature * 0.1 + 1.0 - temperatureAmount * 0.1 ); float3 tint_adjustment = float3( - 1.0 + tint * 0.05, - 1.0 - tint * 0.1, - 1.0 + tint * 0.05 + 1.0 + tintAmount * 0.05, + 1.0 - tintAmount * 0.1, + 1.0 + tintAmount * 0.05 ); return color * temp_adjustment * tint_adjustment; } - half4 main(float2 coord) { - half4 srcColor = src.eval(coord); - float alpha = srcColor.a; - float3 color; - if (alpha > 0.0001) { - color = srcColor.rgb / alpha; - } else { - return half4(0.0); - } - - color *= exp2(exposure); - color = apply_lift_gamma_gain(color, lift, gamma, gain); - color = (color - contrastPivot) * (1.0 + contrast) + contrastPivot; - color = apply_tonal_balance(color, shadows, midtones, highlights); - color = apply_temperature_tint(color, temperature, tint); - float satWeight = 1.0 - clamp(saturation_of(color), 0.0, 1.0); - color = apply_saturation(color, saturation * (1.0 + vibrance * satWeight)); - color = apply_hue(color, hue); - - color += offset; - - return half4(color * alpha, alpha); - } + half4 apply(half4 color) { + float alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + float3 rgb = color.rgb / alpha; - """; + rgb *= exp2(exposure); + rgb = apply_lift_gamma_gain(rgb, lift, gamma, gain); + rgb = (rgb - contrastPivot) * (1.0 + contrast) + contrastPivot; + rgb = apply_tonal_balance(rgb, shadows, midtones, highlights); + rgb = apply_temperature_tint(rgb, temperature, tint); + float satWeight = 1.0 - clamp(saturation_of(rgb), 0.0, 1.0); + rgb = apply_saturation(rgb, saturation * (1.0 + vibrance * satWeight)); + rgb = apply_hue(rgb, hue); - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile color grading shader: {ErrorText}", errorText); - } - } + rgb += offset; + + float3 boundedResult = clamp(rgb * alpha, float3(-HALF_MAX), float3(HALF_MAX)); + return half4(half3(boundedResult), half(alpha)); + } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public ColorGrading() { @@ -218,70 +204,41 @@ public ColorGrading() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) + var r = (Resource)resource; + float lowRange = Math.Clamp(r.LowRange, 0f, 100f); + float highRange = Math.Clamp(r.HighRange, 0f, 100f); + if (lowRange > highRange) { - throw new InvalidOperationException("Failed to compile SKSL."); + (lowRange, highRange) = (highRange, lowRange); } - var r = (Resource)resource; - // TODO: 第二引数がIEquatableを要求しているので,タプルにしている - context.CustomEffect( - (r, Unit.Default), - (t, c) => OnApply(t.r, c), - static (_, rect) => rect); - } - - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["exposure"] = data.Exposure; - builder.Uniforms["contrast"] = data.Contrast / 100f; - builder.Uniforms["contrastPivot"] = data.ContrastPivot; - builder.Uniforms["saturation"] = data.Saturation / 100f; - builder.Uniforms["vibrance"] = data.Vibrance / 100f; - builder.Uniforms["hue"] = data.Hue; - builder.Uniforms["temperature"] = data.Temperature / 100f; - builder.Uniforms["tint"] = data.Tint / 100f; - - float lowRange = Math.Clamp(data.LowRange, 0f, 100f); - float highRange = Math.Clamp(data.HighRange, 0f, 100f); - if (lowRange > highRange) + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => { - (lowRange, highRange) = (highRange, lowRange); - } - - builder.Uniforms["lowRange"] = lowRange / 100f; - builder.Uniforms["highRange"] = highRange / 100f; - builder.Uniforms["shadows"] = ToColorVector(data.Shadows); - builder.Uniforms["midtones"] = ToColorVector(data.Midtones); - builder.Uniforms["highlights"] = ToColorVector(data.Highlights); - builder.Uniforms["lift"] = ToColorVector(data.Lift); - builder.Uniforms["gamma"] = ToColorVector(data.Gamma, 0.001f); - builder.Uniforms["gain"] = ToColorVector(data.Gain, 0.0f); - builder.Uniforms["offset"] = ToColorVector(data.Offset); - - // 新しいターゲットに適用 - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } + bindings.Uniform("exposure", r.Exposure); + bindings.Uniform("contrast", r.Contrast / 100f); + bindings.Uniform("contrastPivot", r.ContrastPivot); + bindings.Uniform("saturation", r.Saturation / 100f); + bindings.Uniform("vibrance", r.Vibrance / 100f); + bindings.Uniform("hue", r.Hue); + bindings.Uniform("temperature", r.Temperature / 100f); + bindings.Uniform("tint", r.Tint / 100f); + bindings.Uniform("lowRange", lowRange / 100f); + bindings.Uniform("highRange", highRange / 100f); + bindings.Uniform("shadows", ToColorVector(r.Shadows)); + bindings.Uniform("midtones", ToColorVector(r.Midtones)); + bindings.Uniform("highlights", ToColorVector(r.Highlights)); + bindings.Uniform("lift", ToColorVector(r.Lift)); + bindings.Uniform("gamma", ToColorVector(r.Gamma, 0.001f)); + bindings.Uniform("gain", ToColorVector(r.Gain, 0.0f)); + bindings.Uniform("offset", ToColorVector(r.Offset)); + })); } - private static SKColorF ToColorVector(GradingColor value, float minValue = float.NegativeInfinity) - { - return new SKColorF( + private static Vector3 ToColorVector(GradingColor value, float minValue = float.NegativeInfinity) + => new( Math.Max(value.R, minValue), Math.Max(value.G, minValue), - Math.Max(value.B, minValue), - 1f); - } + Math.Max(value.B, minValue)); } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ColorKey.cs b/src/Beutl.Engine/Graphics/FilterEffects/ColorKey.cs index 861cc7a86d..4610ac807e 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ColorKey.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ColorKey.cs @@ -1,57 +1,49 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.ColorKey), ResourceType = typeof(GraphicsStrings))] public partial class ColorKey : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static ColorKey() - { - string sksl = - """ - uniform shader src; - uniform float4 color; - uniform float range; - uniform float boundary; - - // Rec.709 での輝度変換(リニアsRGB用) - half calcLuma(half3 c) { - return dot(c, half3(0.2126, 0.7152, 0.0722)); - } - - half4 main(float2 fragCoord) { - half4 c = src.eval(fragCoord); - - // プリマルチプライドアルファを解除 - half alpha = c.a; - if (alpha <= 0.0001) return half4(0.0); - half3 rgb = c.rgb / alpha; + private const string ShaderSource = + """ + uniform float3 keyColor; + uniform float range; + uniform float boundary; + + // A solid fill reaches this shader quantized onto an 8-bit colour grid, so a pixel that was + // authored as the key colour arrives up to one 8-bit step away from the uniform. Matching on + // exact equality would make the mask depend on that, and it also leaves the smoothstep edges + // equal, which the shading languages do not define. + const half kMatchTolerance = 1.0 / 255.0; + + half calcLuma(half3 value) { + return dot(value, half3(0.2126, 0.7152, 0.0722)); + } - // リニア空間でRec.709係数を使って輝度を計算 - half luma = calcLuma(rgb); - half keyLuma = calcLuma(color.rgb); + half4 apply(half4 color) { + half alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + half3 rgb = color.rgb / alpha; - half diff = abs(luma - keyLuma); - half mask = smoothstep(range, range + boundary, diff); + half luma = calcLuma(rgb); + half keyLuma = calcLuma(keyColor); - // 元のプリマルチプライド値にマスクを乗算して透過させる - return c * mask; - } - """; + half diff = abs(luma - keyLuma); + half edge0 = range + kMatchTolerance; + half edge1 = edge0 + max(boundary, kMatchTolerance); + half mask = smoothstep(edge0, edge1, diff); - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + return color * mask; } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public ColorKey() { @@ -70,32 +62,14 @@ public ColorKey() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - context.CustomEffect( - (r.Color, r.Range, r.Boundary), - OnApplyTo, - static (_, r) => r); - } - - private static void OnApplyTo((Color color, float range, float boundary) data, CustomFilterEffectContext c) - { - if (s_shader is null) return; - - for (int i = 0; i < c.Targets.Count; i++) - { - using EffectTarget effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); - - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["color"] = data.color.ToLinear().ToSKColorF(); - builder.Uniforms["range"] = data.range / 100f; - builder.Uniforms["boundary"] = data.boundary / 100f; - - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } + Vector4 linear = r.Color.ToLinear(); + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform("keyColor", new Vector3(linear.X, linear.Y, linear.Z)); + bindings.Uniform("range", r.Range / 100f); + bindings.Uniform("boundary", r.Boundary / 100f); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ColorMatrixShader.cs b/src/Beutl.Engine/Graphics/FilterEffects/ColorMatrixShader.cs new file mode 100644 index 0000000000..7d87768abc --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/ColorMatrixShader.cs @@ -0,0 +1,91 @@ +namespace Beutl.Graphics.Effects; + +/// +/// Provides the shared stage that reproduces +/// SKColorFilter.CreateColorMatrix, so color-matrix filters stay inside a fusable shader chain instead of +/// falling back to a legacy Skia color-filter segment. +/// +/// +/// Skia unpremultiplies, multiplies the straight components by the matrix, clamps the product to [0, 1], and +/// re-premultiplies by the transformed alpha. Only the product is clamped: an RGBA16F buffer may carry straight +/// components outside [0, 1], and Skia feeds those through the matrix unclamped. Clamping the input instead would +/// diverge on exactly those out-of-range samples. +/// +/// The unpremultiply divides by max(a, 1e-4), matching Skia's own SkSL unpremul() helper. That +/// clamp is part of the contract, not an optimization: it is what keeps a near-zero alpha from producing an +/// infinite straight value, and it applies unconditionally. Branching on a > 0 instead would diverge +/// on a non-canonical premultiplied sample (alpha 0 with non-zero RGB), which Skia carries through the matrix +/// rather than forcing to black. +/// +/// +internal static class ColorMatrixShader +{ + /// The component count of a Skia color-matrix array: four rows of five columns. + internal const int SkiaColorMatrixLength = 20; + + private const string MatrixUniformName = "colorMatrix"; + + private const string OffsetUniformName = "colorOffset"; + + private const string ShaderSource = + """ + uniform float4x4 colorMatrix; + uniform float4 colorOffset; + + half4 apply(half4 color) { + float alpha = color.a; + // Every sample goes through the matrix, including a transparent one: a non-zero offset column - + // the alpha offset, matrix[19], in particular - can turn a transparent pixel into a visible one, + // and the RGB offsets survive the re-premultiply. Short-circuiting transparent pixels to black + // would drop that. The divisor is clamped exactly the way Skia's unpremul() clamps it, which both + // bounds the near-zero-alpha band and keeps a non-canonical (a == 0, rgb != 0) sample in parity. + float4 straight = float4(color.rgb / max(alpha, 0.0001), alpha); + + float4 transformed = clamp(colorMatrix * straight + colorOffset, float4(0.0), float4(1.0)); + + return half4(half3(transformed.rgb * transformed.a), half(transformed.a)); + } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); + + /// Builds the shared color-matrix stage from a Skia-layout 4x5 color matrix. + /// + /// A row-major 4x5 color matrix in the layout SKColorFilter.CreateColorMatrix consumes. Its values are + /// copied while the description is created; the storage is never retained. + /// + /// + /// does not contain exactly values. + /// + internal static ShaderDescription CurrentPixel(ReadOnlySpan matrix) + { + if (matrix.Length != SkiaColorMatrixLength) + { + throw new ArgumentException( + $"A Skia color matrix requires exactly {SkiaColorMatrixLength} values.", + nameof(matrix)); + } + + // The row-major 4x5 array splits into a 4x4 multiplier and its fifth translation column. SkSL reads a + // matrix uniform column-major and indexes it as [column][row], so source element (row, column) moves to + // the flat slot (column * 4) + row. + float[] multiplier = new float[16]; + float[] offset = new float[4]; + for (int row = 0; row < 4; row++) + { + for (int column = 0; column < 4; column++) + multiplier[(column * 4) + row] = matrix[(row * 5) + column]; + + offset[row] = matrix[(row * 5) + 4]; + } + + return ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform(MatrixUniformName, multiplier); + bindings.Uniform(OffsetUniformName, offset); + }); + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ColorShift.cs b/src/Beutl.Engine/Graphics/FilterEffects/ColorShift.cs index 2998517292..82dcc713e8 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ColorShift.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ColorShift.cs @@ -1,10 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Engine; using Beutl.Graphics.Rendering; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; using SkiaSharp; namespace Beutl.Graphics.Effects; @@ -12,43 +11,28 @@ namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.ColorShift), ResourceType = typeof(GraphicsStrings))] public partial class ColorShift : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static ColorShift() - { - string sksl = - """ - uniform shader src; - uniform float2 redOffset; - uniform float2 greenOffset; - uniform float2 blueOffset; - uniform float2 alphaOffset; - uniform float2 minOffset; - - half4 main(float2 fragCoord) { - // 出力画素座標 fragCoord に対し、各色成分のサンプル位置を計算 - float2 redCoord = fragCoord - redOffset + minOffset; - float2 greenCoord = fragCoord - greenOffset + minOffset; - float2 blueCoord = fragCoord - blueOffset + minOffset; - float2 alphaCoord = fragCoord - alphaOffset + minOffset; - - // 各色成分をそれぞれのオフセット位置からサンプル - // ※ サンプラーは通常 RGBA 順で色成分を返します - float red = src.eval(redCoord).r; - float green = src.eval(greenCoord).g; - float blue = src.eval(blueCoord).b; - float alpha = src.eval(alphaCoord).a; - - return half4(red, green, blue, alpha); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + private const string ShaderSource = + """ + uniform shader src; + uniform float2 redOffset; + uniform float2 greenOffset; + uniform float2 blueOffset; + uniform float2 alphaOffset; + + half4 main(float2 fragCoord) { + float2 redCoord = fragCoord - redOffset; + float2 greenCoord = fragCoord - greenOffset; + float2 blueCoord = fragCoord - blueOffset; + float2 alphaCoord = fragCoord - alphaOffset; + + float red = src.eval(redCoord).r; + float green = src.eval(greenCoord).g; + float blue = src.eval(blueCoord).b; + float alpha = src.eval(alphaCoord).a; + + return half4(red, green, blue, alpha); } - } + """; public ColorShift() { @@ -70,81 +54,60 @@ public ColorShift() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - - context.CustomEffect( - (r.RedOffset, r.GreenOffset, r.BlueOffset, r.AlphaOffset), - OnApply, - TransformBoundsCore); + var boundsState = new ColorShiftBoundsState( + r.RedOffset, + r.GreenOffset, + r.BlueOffset, + r.AlphaOffset); + RenderBoundsContract bounds = RenderBoundsContract.Create( + boundsState.TransformBounds, + boundsState.GetRequiredInputBounds); + + context.Shader(ShaderDescription.WholeSource( + ShaderSource, + bounds, + bindings => + { + BindOffset(bindings, "redOffset", r.RedOffset); + BindOffset(bindings, "greenOffset", r.GreenOffset); + BindOffset(bindings, "blueOffset", r.BlueOffset); + BindOffset(bindings, "alphaOffset", r.AlphaOffset); + }, + SKShaderTileMode.Decal)); } - private static Rect TransformBoundsCore( - (PixelPoint RedOffset, PixelPoint GreenOffset, PixelPoint BlueOffset, PixelPoint AlphaOffset) data, - Rect bounds) + private static void BindOffset(ShaderBindingBuilder bindings, string name, PixelPoint value) { - return bounds.Translate(data.RedOffset.ToPoint(1)) - .Union(bounds.Translate(data.GreenOffset.ToPoint(1))) - .Union(bounds.Translate(data.BlueOffset.ToPoint(1))) - .Union(bounds.Translate(data.AlphaOffset.ToPoint(1))); + bindings.Uniform( + name, + new Vector2(value.X, value.Y), + BindScaledOffset); } - private static void OnApply( - (PixelPoint RedOffset, PixelPoint GreenOffset, PixelPoint BlueOffset, PixelPoint AlphaOffset) data, - CustomFilterEffectContext context) + private static void BindScaledOffset( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + => writer.Set(value * context.WorkingScale); + + private readonly record struct ColorShiftBoundsState( + PixelPoint RedOffset, + PixelPoint GreenOffset, + PixelPoint BlueOffset, + PixelPoint AlphaOffset) { - if (s_shader is null) return; - for (int i = 0; i < context.Targets.Count; i++) - { - // Not `using`: the skip paths below keep this target in context.Targets[i], so it must - // only be disposed after the slot is replaced with the shifted output. - EffectTarget effectTarget = context.Targets[i]; - RenderTarget? renderTarget = effectTarget.RenderTarget; - if (renderTarget is null) - { - continue; - } - - var bounds = TransformBoundsCore(data, effectTarget.Bounds); - int minOffsetX = Math.Min(data.RedOffset.X, - Math.Min(data.GreenOffset.X, Math.Min(data.BlueOffset.X, data.AlphaOffset.X))); - int minOffsetY = Math.Min(data.RedOffset.Y, - Math.Min(data.GreenOffset.Y, Math.Min(data.BlueOffset.Y, data.AlphaOffset.Y))); - - using var image = renderTarget.Value.Snapshot(); - if (image is null) - { - // Delivery (MaxWorkingScale == +inf) must not silently ship an unshifted layer; - // preview keeps the source pixels. - if (float.IsPositiveInfinity(context.MaxWorkingScale)) - { - throw new InvalidOperationException( - $"ColorShift snapshot failed for target {i}; the GPU surface could not be read back."); - } - - continue; - } - - using var baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - - // SKRuntimeShaderBuilderを作成して、child shaderとuniformを設定 - var builder = s_shader.CreateBuilder(); - - // child shaderとしてテクスチャ用のシェーダーを設定 - builder.Children["src"] = baseShader; - // Scale offsets by working density so they match the device-px buffer. - float w = context.ResolveTargetDensity(bounds); - builder.Uniforms["redOffset"] = new SKPoint(data.RedOffset.X * w, data.RedOffset.Y * w); - builder.Uniforms["greenOffset"] = new SKPoint(data.GreenOffset.X * w, data.GreenOffset.Y * w); - builder.Uniforms["blueOffset"] = new SKPoint(data.BlueOffset.X * w, data.BlueOffset.Y * w); - builder.Uniforms["alphaOffset"] = new SKPoint(data.AlphaOffset.X * w, data.AlphaOffset.Y * w); - builder.Uniforms["minOffset"] = new SKPoint(minOffsetX * w, minOffsetY * w); - - // 新しいターゲットに適用 - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, bounds); - effectTarget.Dispose(); - } + public Rect TransformBounds(Rect bounds) + => bounds.Translate(RedOffset.ToPoint(1)) + .Union(bounds.Translate(GreenOffset.ToPoint(1))) + .Union(bounds.Translate(BlueOffset.ToPoint(1))) + .Union(bounds.Translate(AlphaOffset.ToPoint(1))); + + public Rect GetRequiredInputBounds(Rect bounds) + => bounds.Translate(ToInverseOffset(RedOffset)) + .Union(bounds.Translate(ToInverseOffset(GreenOffset))) + .Union(bounds.Translate(ToInverseOffset(BlueOffset))) + .Union(bounds.Translate(ToInverseOffset(AlphaOffset))); + + private static Point ToInverseOffset(PixelPoint value) => new(-value.X, -value.Y); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ContourTracer.cs b/src/Beutl.Engine/Graphics/FilterEffects/ContourTracer.cs index e6a8d6b0d4..00a2883743 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ContourTracer.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ContourTracer.cs @@ -66,8 +66,17 @@ public static void FindContoursWithHierarchy( out Contours contours, out PooledList parentIndices) { - using var alphaBitmap = bitmap.Convert(BitmapColorType.Alpha8); - FindContoursCore(alphaBitmap, out var contoursList, out parentIndices); + PooledList> contoursList; + if (bitmap.ColorType == BitmapColorType.Alpha8) + { + FindContoursCore(bitmap, out contoursList, out parentIndices); + } + else + { + using var alphaBitmap = bitmap.Convert(BitmapColorType.Alpha8); + FindContoursCore(alphaBitmap, out contoursList, out parentIndices); + } + contours = new Contours(contoursList); } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Curves.cs b/src/Beutl.Engine/Graphics/FilterEffects/Curves.cs index f6c5c69b80..93da3ee7e0 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Curves.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Curves.cs @@ -1,109 +1,96 @@ using System.ComponentModel.DataAnnotations; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Language; -using Beutl.Logging; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.Curves), ResourceType = typeof(GraphicsStrings))] public sealed partial class Curves : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; + private const string ShaderSource = + """ + uniform shader masterCurve; + uniform shader redCurve; + uniform shader greenCurve; + uniform shader blueCurve; + uniform shader hueVsHue; + uniform shader hueVsSat; + uniform shader hueVsLuma; + uniform shader lumaVsSat; + uniform shader satVsSat; + + const float3 LUMA = float3(0.2126, 0.7152, 0.0722); + + float3 rgb_to_hsv(float3 c) { + float4 K = float4(0., -1./3., 2./3., -1.); + float4 p = mix(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g)); + float4 q = mix(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r)); + + float d = q.x - min(q.w, q.y); + float e = 1e-10; + return float3(abs(q.z + (q.w - q.y) / (6. * d + e)), d / (q.x + e), q.x); + } - static Curves() - { - const string sksl = - """ - uniform shader src; - uniform shader masterCurve; - uniform shader redCurve; - uniform shader greenCurve; - uniform shader blueCurve; - uniform shader hueVsHue; - uniform shader hueVsSat; - uniform shader hueVsLuma; - uniform shader lumaVsSat; - uniform shader satVsSat; - - const float3 LUMA = float3(0.2126, 0.7152, 0.0722); - - float3 rgb_to_hsv(float3 c) { - float4 K = float4(0., -1./3., 2./3., -1.); - float4 p = mix(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g)); - float4 q = mix(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r)); - - float d = q.x - min(q.w, q.y); - float e = 1e-10; - return float3(abs(q.z + (q.w - q.y) / (6. * d + e)), d / (q.x + e), q.x); - } - - float3 hsv_to_rgb(float3 c) { - float4 K = float4(1., 2./3., 1./3., 3.); - float3 p = abs(fract(c.xxx + K.xyz) * 6. - K.www); - return c.z * mix(K.xxx, clamp(p - K.xxx, 0., 1.), c.y); - } - - // リニアsRGB -> sRGBガンマ変換 - float3 linearToSrgb(float3 c) { - float3 lo = c * 12.92; - float3 hi = 1.055 * pow(c, float3(1.0/2.4)) - 0.055; - return mix(lo, hi, step(float3(0.0031308), c)); - } - - // sRGBガンマ -> リニアsRGB変換 - float3 srgbToLinear(float3 c) { - float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4)); - return mix(lo, hi, step(float3(0.04045), c)); - } - - half4 main(float2 coord) { - half4 baseColor = src.eval(coord); - if (baseColor.a <= 0.0001) return baseColor; - - // プリマルチプライドアルファを解除し、sRGBガンマ空間に変換 - float3 rgb = linearToSrgb(baseColor.rgb / baseColor.a); - float luma = dot(rgb, LUMA); - float3 hsv = rgb_to_hsv(rgb); - - float hueShift = hueVsHue.eval(float2(hsv.x, 0.5)).a - 0.5; - hsv.x = fract(hsv.x + hueShift + 1.0); - - // Curve value 0.5 = no change, 0.0 = 0x, 1.0 = 2x - hsv.y *= hueVsSat.eval(float2(hsv.x, 0.5)).a * 2.0; - hsv.z *= hueVsLuma.eval(float2(hsv.x, 0.5)).a * 2.0; - - hsv.y *= lumaVsSat.eval(float2(luma, 0.5)).a * 2.0; - hsv.y = clamp(hsv.y, 0.0, 1.0); - - hsv.y *= satVsSat.eval(float2(hsv.y, 0.5)).a * 2.0; - hsv.y = clamp(hsv.y, 0.0, 1.0); - - rgb = hsv_to_rgb(hsv); - - rgb.r = redCurve.eval(float2(rgb.r, 0.5)).a; - rgb.g = greenCurve.eval(float2(rgb.g, 0.5)).a; - rgb.b = blueCurve.eval(float2(rgb.b, 0.5)).a; - - rgb.r = masterCurve.eval(float2(rgb.r, 0.5)).a; - rgb.g = masterCurve.eval(float2(rgb.g, 0.5)).a; - rgb.b = masterCurve.eval(float2(rgb.b, 0.5)).a; - - // リニア空間に戻してプリマルチプライドアルファを再適用 - float3 result = srgbToLinear(rgb); - return half4(half3(result * baseColor.a), baseColor.a); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile curves shader: {ErrorText}", errorText); + float3 hsv_to_rgb(float3 c) { + float4 K = float4(1., 2./3., 1./3., 3.); + float3 p = abs(fract(c.xxx + K.xyz) * 6. - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0., 1.), c.y); } - } + + float3 linearToSrgb(float3 c) { + float3 lo = c * 12.92; + // mix evaluates both branches, so the unused power branch must also stay in its domain. + float3 hi = 1.055 * pow(max(c, float3(0.0)), float3(1.0/2.4)) - 0.055; + return mix(lo, hi, step(float3(0.0031308), c)); + } + + float3 srgbToLinear(float3 c) { + float3 lo = c / 12.92; + // Negative extended-range values use lo, but a NaN in the unused branch still poisons mix. + float3 hi = pow(max((c + 0.055) / 1.055, float3(0.0)), float3(2.4)); + return mix(lo, hi, step(float3(0.04045), c)); + } + + half4 apply(half4 color) { + half4 baseColor = color; + if (baseColor.a <= 0.0001) return baseColor; + + float3 rgb = linearToSrgb(baseColor.rgb / baseColor.a); + float luma = dot(rgb, LUMA); + float3 hsv = rgb_to_hsv(rgb); + + float hueShift = hueVsHue.eval(float2(hsv.x, 0.5)).a - 0.5; + hsv.x = fract(hsv.x + hueShift + 1.0); + + // Curve value 0.5 = no change, 0.0 = 0x, 1.0 = 2x. + hsv.y *= hueVsSat.eval(float2(hsv.x, 0.5)).a * 2.0; + hsv.z *= hueVsLuma.eval(float2(hsv.x, 0.5)).a * 2.0; + + hsv.y *= lumaVsSat.eval(float2(luma, 0.5)).a * 2.0; + hsv.y = clamp(hsv.y, 0.0, 1.0); + + hsv.y *= satVsSat.eval(float2(hsv.y, 0.5)).a * 2.0; + hsv.y = clamp(hsv.y, 0.0, 1.0); + + rgb = hsv_to_rgb(hsv); + + rgb.r = redCurve.eval(float2(rgb.r, 0.5)).a; + rgb.g = greenCurve.eval(float2(rgb.g, 0.5)).a; + rgb.b = blueCurve.eval(float2(rgb.b, 0.5)).a; + + rgb.r = masterCurve.eval(float2(rgb.r, 0.5)).a; + rgb.g = masterCurve.eval(float2(rgb.g, 0.5)).a; + rgb.b = masterCurve.eval(float2(rgb.b, 0.5)).a; + + float3 result = srgbToLinear(rgb); + return half4(half3(result * baseColor.a), baseColor.a); + } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public Curves() { @@ -139,56 +126,43 @@ public Curves() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - var r = (Resource)resource; - - context.CustomEffect( - (Resource: r, Dummy: 0), - static (data, ctx) => OnApply(data.Resource, ctx), - static (_, rect) => rect); + RenderResource master = Borrow(context, r.MasterCurve); + RenderResource red = Borrow(context, r.RedCurve); + RenderResource green = Borrow(context, r.GreenCurve); + RenderResource blue = Borrow(context, r.BlueCurve); + RenderResource hueHue = Borrow(context, r.HueVsHue); + RenderResource hueSaturation = Borrow(context, r.HueVsSaturation); + RenderResource hueLuminance = Borrow(context, r.HueVsLuminance); + RenderResource luminanceSaturation = Borrow(context, r.LuminanceVsSaturation); + RenderResource saturationSaturation = Borrow(context, r.SaturationVsSaturation); + + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + BindCurve(bindings, "masterCurve", master); + BindCurve(bindings, "redCurve", red); + BindCurve(bindings, "greenCurve", green); + BindCurve(bindings, "blueCurve", blue); + BindCurve(bindings, "hueVsHue", hueHue); + BindCurve(bindings, "hueVsSat", hueSaturation); + BindCurve(bindings, "hueVsLuma", hueLuminance); + BindCurve(bindings, "lumaVsSat", luminanceSaturation); + BindCurve(bindings, "satVsSat", saturationSaturation); + })); } - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - using SKShader master = data.MasterCurve.ToShader(); - using SKShader red = data.RedCurve.ToShader(); - using SKShader green = data.GreenCurve.ToShader(); - using SKShader blue = data.BlueCurve.ToShader(); - using SKShader hueHue = data.HueVsHue.ToShader(); - using SKShader hueSat = data.HueVsSaturation.ToShader(); - using SKShader hueLum = data.HueVsLuminance.ToShader(); - using SKShader lumSat = data.LuminanceVsSaturation.ToShader(); - using SKShader satSat = data.SaturationVsSaturation.ToShader(); - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Children["masterCurve"] = master; - builder.Children["redCurve"] = red; - builder.Children["greenCurve"] = green; - builder.Children["blueCurve"] = blue; - builder.Children["hueVsHue"] = hueHue; - builder.Children["hueVsSat"] = hueSat; - builder.Children["hueVsLuma"] = hueLum; - builder.Children["lumaVsSat"] = lumSat; - builder.Children["satVsSat"] = satSat; - - // 新しいターゲットに適用 - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } - } + private static RenderResource Borrow(FilterEffectContext context, CurveMap curve) + => context.Borrow(curve); + + private static void BindCurve( + ShaderBindingBuilder bindings, + string name, + RenderResource curve) + => bindings.Resource( + name, + curve, + ShaderResourceCoordinateSpace.Value, + static (writer, value, _) => writer.Set(value.ToShader())); } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs b/src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs index 71fdceff9f..3cc7ac7826 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/CustomFilterEffectContext.cs @@ -1,20 +1,48 @@ -using Beutl.Graphics.Rendering; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Rendering; using Beutl.Logging; +using Beutl.Media; using Microsoft.Extensions.Logging; +using SkiaSharp; namespace Beutl.Graphics.Effects; public class CustomFilterEffectContext { private static readonly ILogger s_logger = Log.CreateLogger("CustomFilterEffectContext"); + private readonly Vector _deviceGridOffset; + private readonly DrawableBrushMaterializer? _drawableBrushMaterializer; + private readonly bool _useExecutorManagedCanvas; + private readonly RenderTargetLeaseSession? _renderTargetLeaseSession; - internal CustomFilterEffectContext(EffectTargets targets, float outputScale = 1f, float workingScale = 1f, - float maxWorkingScale = float.PositiveInfinity) + internal CustomFilterEffectContext( + EffectTargets targets, + RenderIntent intent, + RenderRequestPurpose purpose, + float outputScale = 1f, + float workingScale = 1f, + float maxWorkingScale = float.PositiveInfinity, + Vector? deviceGridOffset = null, + DrawableBrushMaterializer? drawableBrushMaterializer = null, + bool useExecutorManagedCanvas = false, + RenderTargetLeaseSession? renderTargetLeaseSession = null) { + if (!Enum.IsDefined(intent)) + throw new ArgumentOutOfRangeException(nameof(intent), intent, "The render intent is invalid."); + if (!Enum.IsDefined(purpose)) + throw new ArgumentOutOfRangeException(nameof(purpose), purpose, "The render request purpose is invalid."); + Targets = targets; + _deviceGridOffset = deviceGridOffset + ?? (targets.Count > 0 ? targets[0].DeviceGridOffset : default); OutputScale = outputScale; WorkingScale = workingScale; - MaxWorkingScale = RenderNodeContext.SanitizeMaxWorkingScale(maxWorkingScale); + MaxWorkingScale = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + Intent = intent; + Purpose = purpose; + _drawableBrushMaterializer = drawableBrushMaterializer; + _useExecutorManagedCanvas = useExecutorManagedCanvas; + _renderTargetLeaseSession = renderTargetLeaseSession; } public EffectTargets Targets { get; } @@ -23,14 +51,48 @@ internal CustomFilterEffectContext(EffectTargets targets, float outputScale = 1f public float OutputScale { get; } /// - /// The working density w this effect's buffers are allocated at: - /// sizes them ceil(bounds * w). Absolute-length pixel parameters must be multiplied by this. + /// Gets the nominal working density w requested for this callback. + /// can clamp a specific allocation below this value; call before + /// allocation or use the returned target's for device-pixel math. /// public float WorkingScale { get; } /// Working-scale ceiling forwarded into canvases from . +Inf = no ceiling. public float MaxWorkingScale { get; } + /// + /// Gets the translation from effect-local coordinates to the composition-device grid used + /// for intermediate allocation. + /// + public Vector DeviceGridOffset => _deviceGridOffset; + + /// Gets the explicit preview or delivery classification for this execution. + public RenderIntent Intent { get; } + + /// Gets the explicit request purpose for this execution. + public RenderRequestPurpose Purpose { get; } + + internal DrawableBrushMaterializer? DrawableBrushMaterializer => _drawableBrushMaterializer; + + internal bool UsesExecutorManagedCanvas => _useExecutorManagedCanvas; + + internal RenderTargetLeaseSession? RenderTargetLeaseSession => _renderTargetLeaseSession; + + internal BrushConstructor CreateBrushConstructor( + Rect bounds, + Brush.Resource? brush, + BlendMode blendMode, + float scale) + => new( + bounds, + brush, + blendMode, + scale, + MaxWorkingScale, + Intent, + _drawableBrushMaterializer, + _renderTargetLeaseSession); + public void ForEach(Action action) { for (int i = 0; i < Targets.Count; i++) @@ -69,28 +131,60 @@ public void ForEach(Func action) /// /// Device-buffer dimensions for a logical at density . - /// Shared so shader resolution uniforms match 's allocation. + /// The legacy custom-effect contract sizes the local buffer from the logical dimensions only; + /// a fractional logical origin does not add a rounding pixel. /// public static (int Width, int Height) DeviceBufferSize(Rect bounds, float w) { - int bw = w == 1f ? (int)bounds.Width : (int)MathF.Ceiling(bounds.Width * w); - int bh = w == 1f ? (int)bounds.Height : (int)MathF.Ceiling(bounds.Height * w); - return (bw, bh); + int width = w == 1f ? (int)bounds.Width : (int)MathF.Ceiling(bounds.Width * w); + int height = w == 1f ? (int)bounds.Height : (int)MathF.Ceiling(bounds.Height * w); + return (width, height); } /// - /// The density will allocate for - /// (working scale after per-buffer dimension clamp). Call on the same bounds passed to - /// so shader uniforms match the actual buffer. + /// Gets the canonical composition-device footprint allocated for logical bounds at a concrete density. + /// The origin is retained because fractional logical positions can add a rounding pixel to the buffer. + /// + /// + /// is non-finite or not positive. + /// + public static PixelRect DeviceBufferBounds(Rect bounds, float w) + { + if (!float.IsFinite(w) || w <= 0) + throw new ArgumentOutOfRangeException(nameof(w), w, "Buffer density must be positive and finite."); + + return PixelRect.FromRect(bounds, w); + } + + /// + /// The density will allocate for , + /// after applying the legacy per-buffer dimension clamp. /// public float ResolveTargetDensity(Rect bounds) - => RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, WorkingScale); + => RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + new Rect(default, bounds.Size), + WorkingScale); + /// + /// Creates a target for the requested logical bounds at the resolved working density. + /// + /// + /// If allocation fails, logs the failure and returns an empty + /// target, while throws. + /// + /// + /// The allocation failed during a render. + /// public EffectTarget CreateTarget(Rect bounds) + => CreateTargetCore(bounds, WorkingScale); + + private EffectTarget CreateTargetCore(Rect bounds, float requestedDensity) { - float w = WorkingScale; + float w = requestedDensity; // Re-clamp at allocation site: bounds may exceed what node-level clamps saw. - float fit = ResolveTargetDensity(bounds); + float fit = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + new Rect(default, bounds.Size), + w); if (fit < w) { s_logger.LogWarning( @@ -99,25 +193,405 @@ public EffectTarget CreateTarget(Rect bounds) w = fit; } - (int bw, int bh) = DeviceBufferSize(bounds, w); - using var renderTarget = RenderTarget.Create(bw, bh); - if (renderTarget != null) + PixelPoint deviceOrigin = DeviceBufferBounds( + bounds.Translate(_deviceGridOffset), + w).Position; + (int width, int height) = DeviceBufferSize(bounds, w); + var deviceBounds = new PixelRect( + deviceOrigin, + new PixelSize(width, height)); + return AllocateTarget(bounds, w, deviceBounds); + } + + /// + /// Creates a replacement target with the source's complete physical footprint and current + /// logical placement. Use this for same-bounds raster effects so fractional-origin pixels and + /// raster aprons are preserved. + /// + /// + /// An unmaterialized or unbounded is a legitimate skip and returns an + /// empty target for either intent. If the replacement allocation itself fails, + /// logs the failure and returns an empty target so the caller + /// can keep the source, while throws. + /// + /// + /// The replacement allocation failed during a render. + /// + public EffectTarget CreateTargetLike(EffectTarget source) + { + ArgumentNullException.ThrowIfNull(source); + if (source.RenderTarget is null || source.Scale.IsUnbounded) + return new EffectTarget(); + + EffectTarget? replacement = AllocateReplacement(source, FactoryBackedSession); + if (replacement != null) + { + return replacement; + } + + if (Intent == RenderIntent.Delivery) + { + throw new InvalidOperationException( + $"Custom-effect replacement target allocation failed ({source.DeviceBounds.Width}x{source.DeviceBounds.Height} px, " + + $"target density {source.Scale.Value}, bounds {source.Bounds}); " + + "the delivery render fails instead of shipping an unprocessed frame."); + } + + s_logger.LogWarning( + "Custom-effect replacement target allocation failed ({Width}x{Height} px, target density {TargetDensity}, bounds {Bounds}); returning an empty target so the preview can keep the source pixels.", + source.DeviceBounds.Width, + source.DeviceBounds.Height, + source.Scale.Value, + source.Bounds); + _renderTargetLeaseSession?.MarkContentDropped(); + return new EffectTarget(); + } + + /// + /// A declined allocation leaves the caller holding the unfiltered source, and the request is told it + /// dropped content. Without that, an executor can publish the unfiltered frame into a persistent + /// render-node cache or a backdrop snapshot, and a later hit keeps bypassing the effect long after the + /// factory recovered. This reaches only a preview: a lease session declines by throwing under + /// , so a delivery render fails before it gets here. + /// + internal EffectTarget CreateNativeTargetLike(EffectTarget source) + { + ArgumentNullException.ThrowIfNull(source); + if (_renderTargetLeaseSession is null) + return CreateTargetLike(source); + if (source.RenderTarget is null || source.Scale.IsUnbounded) + return new EffectTarget(); + + // Every consumer of this target is a full-frame shader pass: its load op either clears the + // attachment or the shader provably writes every pixel, so the pool's own clear - and the two + // layout transitions around it - would be undone before anything read them. + EffectTarget? replacement = AllocateReplacement( + source, + _renderTargetLeaseSession, + clearContents: false); + if (replacement != null) + return replacement; + + s_logger.LogWarning( + "Native custom-effect replacement target allocation failed ({Width}x{Height} px, target density {TargetDensity}, bounds {Bounds}); returning an empty target so the preview can keep the source pixels.", + source.DeviceBounds.Width, + source.DeviceBounds.Height, + source.Scale.Value, + source.Bounds); + _renderTargetLeaseSession.MarkContentDropped(); + return new EffectTarget(); + } + + /// + /// Allocates a same-footprint replacement for , through the caller's lease session + /// when there is one, and reports a declined allocation as . + /// + /// + /// A configured is reachable only through the session, and its targets may + /// come from a context the global allocator knows nothing about. Going around it here would both ignore the + /// caller's allocation policy and let a custom effect sample a factory-backed input into a foreign surface. + /// + private EffectTarget? AllocateReplacement( + EffectTarget source, + RenderTargetLeaseSession? leaseSession, + bool clearContents = true) + { + if (leaseSession is not null) + { + RenderTargetLease? lease = leaseSession.TryAcquire(source.DeviceBounds.Size, clearContents); + if (lease is null) + return null; + + try + { + return source.CreateReplacement(lease); + } + catch + { + lease.Dispose(); + throw; + } + } + + using RenderTarget? renderTarget = RenderTarget.Create( + source.DeviceBounds.Width, + source.DeviceBounds.Height); + return renderTarget is null ? null : source.CreateReplacement(renderTarget); + } + + internal NativeFilterTextureLease AcquireNativeScratchTexture( + IGraphicsContext graphicsContext, + int width, + int height) + { + ArgumentNullException.ThrowIfNull(graphicsContext); + NativeFilterTextureLease lease; + if (_renderTargetLeaseSession is null) { - return new EffectTarget(renderTarget, bounds, EffectiveScale.At(w)); + lease = NativeFilterTextureLease.Own( + graphicsContext.CreateTexture2D(width, height, TextureFormat.RGBA16Float)); } else { - // The empty target makes the subsequent Open() throw — log the cause before that happens. + var size = new PixelSize(width, height); + RenderTargetLease? renderTargetLease = _renderTargetLeaseSession.TryAcquire(size); + if (renderTargetLease is null) + throw RenderTargetPool.CreateAllocationFailure(size); + + ITexture2D? texture = renderTargetLease.Target.Texture; + if (texture is null + || texture.Width != width + || texture.Height != height + || texture.Format != TextureFormat.RGBA16Float) + { + renderTargetLease.Dispose(); + throw new InvalidOperationException( + "A native filter scratch lease requires an exact-size RGBA16F GPU texture."); + } + + lease = NativeFilterTextureLease.Lease(texture, renderTargetLease); + } + + try + { + if (lease.Texture is not ITransparentClearableTexture clearableTexture) + { + throw new InvalidOperationException( + "A native filter scratch texture must support an ordered transparent clear."); + } + + clearableTexture.ClearToTransparent(); + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + + /// + /// Wraps a caller-created target as a replacement with the source's logical placement, + /// density, physical footprint, device-grid alignment, and legacy placement mode. + /// + /// + /// The returned effect target owns a shallow copy; the caller retains ownership of + /// . + /// + public EffectTarget CreateReplacement( + EffectTarget source, + RenderTarget renderTarget) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(renderTarget); + if (source.RenderTarget is null || source.Scale.IsUnbounded) + { + throw new ArgumentException( + "The source must have a materialized target and concrete scale.", + nameof(source)); + } + if (renderTarget.Width != source.DeviceBounds.Width + || renderTarget.Height != source.DeviceBounds.Height) + { + throw new ArgumentException( + $"The replacement render target must match the source device footprint " + + $"{source.DeviceBounds.Width}x{source.DeviceBounds.Height}.", + nameof(renderTarget)); + } + + return source.CreateReplacement(renderTarget); + } + + /// + /// Creates a child shader that maps destination backing-buffer coordinates to the source + /// target's current physical raster placement. + /// + /// The caller owns and must dispose the returned shader. + public SKShader CreateMappedInputShader( + EffectTarget source, + EffectTarget destination, + SKShader sourceShader) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(sourceShader); + if (source.RenderTarget is null || source.Scale.IsUnbounded) + throw new ArgumentException("The source must have a materialized target and concrete scale.", nameof(source)); + if (destination.RenderTarget is null || destination.Scale.IsUnbounded) + { + throw new ArgumentException( + "The destination must have a materialized target and concrete scale.", + nameof(destination)); + } + + return sourceShader.WithLocalMatrix( + RasterShaderMapping.CreateLocalMatrix( + destination.Scale.Value, + source.Scale.Value, + destination.RasterBounds, + source.RasterBounds)); + } + + /// + /// Supplies a borrowed GPU-backed snapshot shader for a materialized source, mapped into the + /// destination's backing-buffer coordinates. + /// + /// + /// when ran. when the source + /// could not be read back under : the callback never ran, so the + /// caller must keep its source target instead of committing a destination it never painted. + /// + /// + /// The source could not be read back under . + /// + /// + /// The shader and its backing image are valid only during . The callback must + /// complete every draw that references the shader and must not retain or dispose it. + /// + public bool UseMappedInputShader( + EffectTarget source, + EffectTarget destination, + TState state, + Action use, + SKShaderTileMode x = SKShaderTileMode.Decal, + SKShaderTileMode y = SKShaderTileMode.Decal) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); + ArgumentNullException.ThrowIfNull(use); + if (!Enum.IsDefined(x)) + throw new ArgumentOutOfRangeException(nameof(x), x, "The shader tile mode is invalid."); + if (!Enum.IsDefined(y)) + throw new ArgumentOutOfRangeException(nameof(y), y, "The shader tile mode is invalid."); + if (source.RenderTarget is null || source.Scale.IsUnbounded) + throw new ArgumentException("The source must have a materialized target and concrete scale.", nameof(source)); + if (source.RenderTarget.RawValue is null) + throw new ArgumentException("The source target has no backing surface to sample.", nameof(source)); + if (destination.RenderTarget is null || destination.Scale.IsUnbounded) + { + throw new ArgumentException( + "The destination must have a materialized target and concrete scale.", + nameof(destination)); + } + + source.RenderTarget.PrepareForSampling( + RenderTargetSamplingIntent.SameContextTextureSampling(destination.RenderTarget.RawValue.Context)); + using SKImage? image = source.RenderTarget.Value.Snapshot(); + if (image is null) + { + ThrowIfDeliveryReadbackFailure(Intent, source.DeviceBounds); s_logger.LogWarning( - "Custom-effect target allocation failed ({Width}x{Height} px, w {WorkingScale}, bounds {Bounds}); returning an empty target.", - bw, bh, w, bounds); + "The source surface could not be snapshotted for sampling ({Width}x{Height} px); the preview keeps the source pixels.", + source.DeviceBounds.Width, + source.DeviceBounds.Height); + return false; + } + + using SKShader sourceShader = image.ToShader(x, y); + using SKShader mappedShader = CreateMappedInputShader(source, destination, sourceShader); + use(state, mappedShader); + return true; + } + + // The intent alone decides degrade-vs-fail, independently of the working-scale ceiling: + // a delivery render must not ship a frame the effect was never applied to. + internal static void ThrowIfDeliveryReadbackFailure(RenderIntent intent, PixelRect footprint) + { + if (intent == RenderIntent.Delivery) + { + throw new InvalidOperationException( + $"The source surface could not be snapshotted for sampling ({footprint.Width}x{footprint.Height} px); " + + "the delivery render fails instead of shipping an unfiltered frame."); + } + } + + private EffectTarget AllocateTarget( + Rect bounds, + float density, + PixelRect deviceBounds) + { + Vector legacyGridOffset = deviceBounds + .ToRect(density) + .Position - bounds.Position; + EffectTarget? allocated = Allocate(bounds, density, deviceBounds, legacyGridOffset); + if (allocated != null) + { + return allocated; + } + else + { + s_logger.LogWarning( + "Custom-effect target allocation failed ({Width}x{Height} px, w {WorkingScale}, bounds {Bounds}); preview returns an empty target, delivery render fails fast.", + deviceBounds.Width, deviceBounds.Height, density, bounds); + + if (Intent == RenderIntent.Delivery) + { + throw new InvalidOperationException( + $"Custom-effect target allocation failed ({deviceBounds.Width}x{deviceBounds.Height} px, " + + $"w {density}, bounds {bounds}); the delivery render fails instead of shipping an incomplete frame."); + } + + _renderTargetLeaseSession?.MarkContentDropped(); return new EffectTarget(); } } + /// + /// Allocates one custom-effect target, through the caller's lease session when there is one. + /// + /// + /// The lease session only when the caller supplied a factory. A path that already allocated its own + /// surfaces keeps doing so without one, so routing it through the pool does not change which targets a + /// render reuses; with a factory it must route through the session or the factory is bypassed. + /// + private RenderTargetLeaseSession? FactoryBackedSession + => _renderTargetLeaseSession is { HasTargetFactory: true } session ? session : null; + + private EffectTarget? Allocate( + Rect bounds, + float density, + PixelRect deviceBounds, + Vector deviceGridOffset) + { + if (FactoryBackedSession is { } leaseSession) + { + RenderTargetLease? lease = leaseSession.TryAcquire(deviceBounds.Size); + if (lease is null) + return null; + + try + { + return EffectTarget.FromLease( + lease, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement: true); + } + catch + { + lease.Dispose(); + throw; + } + } + + using RenderTarget? renderTarget = RenderTarget.Create(deviceBounds.Width, deviceBounds.Height); + return renderTarget is null + ? null + : new EffectTarget( + renderTarget, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement: true); + } + /// /// Opens an over 's buffer. - /// Throws if the target is empty (allocation failed in ). + /// Throws if the target is empty. can return an empty target after a + /// Preview allocation failure; Delivery allocation failures are thrown by . /// public ImmediateCanvas Open(EffectTarget target) { @@ -130,6 +604,75 @@ public ImmediateCanvas Open(EffectTarget target) // Prefer the target's concrete Scale (may be clamped below WorkingScale by CreateTarget). float density = target.Scale.IsUnbounded ? WorkingScale : target.Scale.Value; - return new ImmediateCanvas(target.RenderTarget, density, MaxWorkingScale, logicalSize: target.Bounds.Size); + ImmediateCanvas canvas; + if (_useExecutorManagedCanvas) + { + canvas = ImmediateCanvas.CreateExecutorManaged( + target.RenderTarget, + density, + MaxWorkingScale, + target.Bounds.Size, + Intent); + canvas.ConfigureCustomEffectExecution(); + } + else + { + canvas = new ImmediateCanvas( + target.RenderTarget, + density, + MaxWorkingScale, + logicalSize: target.Bounds.Size, + intent: Intent); + } + + canvas.DrawableBrushMaterializer = _drawableBrushMaterializer; + return canvas; + } +} + +internal sealed class NativeFilterTextureLease : IDisposable +{ + private ITexture2D? _texture; + private RenderTargetLease? _renderTargetLease; + private readonly bool _ownsTexture; + + private NativeFilterTextureLease( + ITexture2D texture, + RenderTargetLease? renderTargetLease, + bool ownsTexture) + { + _texture = texture; + _renderTargetLease = renderTargetLease; + _ownsTexture = ownsTexture; + } + + public ITexture2D Texture + => _texture ?? throw new ObjectDisposedException(nameof(NativeFilterTextureLease)); + + public static NativeFilterTextureLease Own(ITexture2D texture) + { + ArgumentNullException.ThrowIfNull(texture); + return new NativeFilterTextureLease(texture, renderTargetLease: null, ownsTexture: true); + } + + public static NativeFilterTextureLease Lease(ITexture2D texture, RenderTargetLease renderTargetLease) + { + ArgumentNullException.ThrowIfNull(texture); + ArgumentNullException.ThrowIfNull(renderTargetLease); + return new NativeFilterTextureLease(texture, renderTargetLease, ownsTexture: false); + } + + public void Dispose() + { + ITexture2D? texture = _texture; + if (texture is null) + return; + + _texture = null; + if (_ownsTexture) + texture.Dispose(); + else + _renderTargetLease?.Dispose(); + _renderTargetLease = null; } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs index f6754d93f8..9cbce98290 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs @@ -27,7 +27,7 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource var r = (Resource)resource; if (r.Effect == null) return; - var childEffect = r.Effect.GetOriginal(); + FilterEffect childEffect = r.Effect.GetOriginal()!; context.CustomEffect( (delay: r.Delay, globalTime: r.GlobalTime, childEffect, cache: r.DelayedResources, @@ -77,16 +77,24 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource // Forward output scale and working density into the nested re-application. using var childFEContext = new FilterEffectContext( target.Bounds, effectContext.OutputScale, effectContext.WorkingScale); - data.childEffect.ApplyTo(childFEContext, data.cache[j]); + childFEContext.ApplyTransactional(data.childEffect, data.cache[j]); target.OriginalBounds = target.Bounds.WithX(0).WithY(0); using var singleTargets = new EffectTargets(); singleTargets.Add(target.Clone()); using var builder = new SKImageFilterBuilder(); - // Forward the working-scale ceiling into the nested pull. using var activator = new FilterEffectActivator( - singleTargets, builder, effectContext.OutputScale, effectContext.WorkingScale, - effectContext.MaxWorkingScale); + singleTargets, + builder, + effectContext.Intent, + effectContext.Purpose, + effectContext.OutputScale, + effectContext.WorkingScale, + effectContext.MaxWorkingScale, + effectContext.DeviceGridOffset, + effectContext.DrawableBrushMaterializer, + effectContext.UsesExecutorManagedCanvas, + effectContext.RenderTargetLeaseSession); activator.Apply(childFEContext); activator.Flush(false); diff --git a/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapEffect.cs index 1e84cd8d74..adca314e59 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapEffect.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Runtime.ExceptionServices; using Beutl.Animation; using Beutl.Engine; using Beutl.Language; @@ -60,33 +61,76 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource EffectTarget effectTarget = effectContext.Targets[i]; // Create target first so the map brush uses the buffer's post-clamp density. var newTarget = effectContext.CreateTarget(effectTarget.Bounds); - float w = newTarget.Scale.Value; - using var displacementMapShader = - new BrushConstructor(new Rect(effectTarget.Bounds.Size), brush, BlendMode.SrcOver, w, - effectContext.MaxWorkingScale) - .CreateShader(); - - using (var paint = new SKPaint()) - using (var canvas = effectContext.Open(newTarget)) + if (newTarget.IsEmpty) { - paint.Shader = displacementMapShader; - canvas.Clear(); - // The base CTM CreateScale(w) maps the logical DrawRect onto the full - // ceil(bounds × w) device buffer; no manual prescale. w == 1 = bare logical rect. - canvas.Canvas.DrawRect( - new SKRect(0, 0, effectTarget.Bounds.Width, effectTarget.Bounds.Height), - paint); - - effectContext.Targets[i] = newTarget; + newTarget.Dispose(); + continue; } - effectTarget.Dispose(); + RenderAndCommitReplacement( + effectContext, + i, + effectTarget, + newTarget, + (Context: effectContext, Brush: brush, Original: effectTarget, Replacement: newTarget), + static state => + { + float w = state.Replacement.Scale.Value; + using SKShader displacementMapShader = DisplacementMapShaderFactory.CreateOrTransparent( + state.Context, + state.Brush, + new Rect(state.Original.Bounds.Size), + w); + + using (var paint = new SKPaint()) + using (var canvas = state.Context.Open(state.Replacement)) + { + paint.Shader = displacementMapShader; + canvas.Clear(); + // The base CTM CreateScale(w) maps the logical DrawRect onto the full + // ceil(bounds × w) device buffer; no manual prescale. w == 1 = bare logical rect. + canvas.Canvas.DrawRect( + new SKRect(0, 0, state.Original.Bounds.Width, state.Original.Bounds.Height), + paint); + } + }); } }); } else if (r.Transform is { } transform) { - transform.GetOriginal().ApplyTo(displacementMap, transform, r.SpreadMethod, r.Channel, r.Signed, context); + transform.ApplyTo(displacementMap, r.SpreadMethod, r.Channel, r.Signed, context); + } + } + + internal static void RenderAndCommitReplacement( + CustomFilterEffectContext context, + int index, + EffectTarget original, + EffectTarget replacement, + TState state, + Action draw) + { + try + { + draw(state); + } + catch (Exception ex) + { + ExceptionDispatchInfo primary = ExceptionDispatchInfo.Capture(ex); + try + { + replacement.Dispose(); + } + catch (Exception cleanupFailure) + { + ex.Data["DisplacementMapReplacementCleanupFailure"] = cleanupFailure; + } + + primary.Throw(); } + + context.Targets[index] = replacement; + original.Dispose(); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapShaderFactory.cs b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapShaderFactory.cs new file mode 100644 index 0000000000..762522a017 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapShaderFactory.cs @@ -0,0 +1,16 @@ +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.Graphics.Effects; + +internal static class DisplacementMapShaderFactory +{ + public static SKShader CreateOrTransparent( + CustomFilterEffectContext context, + Brush.Resource? brush, + Rect bounds, + float density) + => context.CreateBrushConstructor(bounds, brush, BlendMode.SrcOver, density).CreateShader() + ?? SKShader.CreateColor(SKColors.Transparent); +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapTransform.cs b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapTransform.cs index f04310a5e6..adca55f1b0 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapTransform.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/DisplacementMapTransform.cs @@ -1,69 +1,344 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; using Beutl.Utilities; -using Microsoft.Extensions.Logging; using SkiaSharp; namespace Beutl.Graphics.Effects; public abstract partial class DisplacementMapTransform : EngineObject { - internal abstract void ApplyTo( - Brush.Resource displacementMap, Resource resource, GradientSpreadMethod spreadMethod, - DisplacementMapChannel channel, bool signed, FilterEffectContext context); -} + private const string LegacyDrawableMapShaderSource = + """ + uniform shader src; + uniform shader uDisplacementMap; + + uniform int uMode; + uniform float2 uVector; + uniform float uAngle; + uniform float2 uPivot; + uniform int uChannel; + uniform int uSigned; + + float getDisplacement(half4 dispColor) { + float d; + if (uChannel == 0) d = dispColor.a; + else { + if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); + else if (uChannel == 2) d = dispColor.r; + else if (uChannel == 3) d = dispColor.g; + else d = dispColor.b; + d = d * dispColor.a; + } + if (uSigned != 0) d = d * 2.0 - 1.0; + return d; + } -[Display(Name = nameof(GraphicsStrings.TranslateTransform), ResourceType = typeof(GraphicsStrings))] -public partial class DisplacementMapTranslateTransform : DisplacementMapTransform -{ - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; + half4 main(float2 coord) { + float disp = getDisplacement(uDisplacementMap.eval(coord)); + if (uMode == 0) { + return src.eval(coord + uVector * disp); + } + if (uMode == 1) { + float2 scale = max( + mix(float2(1.0, 1.0), uVector, disp), + float2(0.001, 0.001)); + return src.eval((coord - uPivot) / scale + uPivot); + } + + float2 rotation = float2(cos(uAngle * disp), sin(uAngle * disp)); + float2 uv = coord - uPivot; + uv = float2( + uv.x * rotation.x - uv.y * rotation.y, + uv.x * rotation.y + uv.y * rotation.x); + return src.eval(uv + uPivot); + } + """; + + private static readonly Lazy s_legacyDrawableMapShader = + new(() => SKSLShader.Create(LegacyDrawableMapShaderSource)); + + public partial class Resource + { + internal abstract void ApplyTo( + Brush.Resource displacementMap, GradientSpreadMethod spreadMethod, + DisplacementMapChannel channel, bool signed, FilterEffectContext context); + } + + private protected static RenderResource BorrowDisplacementMap( + FilterEffectContext context, + Brush.Resource displacementMap) + => context.Borrow(displacementMap); + + private protected static void AddDisplacementBindings( + ShaderBindingBuilder bindings, + RenderResource displacementMap, + DisplacementMapChannel channel, + bool signed) + { + bindings.Resource( + "uDisplacementMap", + displacementMap, + ShaderResourceCoordinateSpace.OutputDevice, + CreateDisplacementMapShader); + bindings.Uniform("uChannel", (int)channel); + bindings.Uniform("uSigned", signed ? 1 : 0); + } + + private protected static void BindScaledVector( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + => writer.Set(value * context.WorkingScale); + + private protected static void BindPivot( + ShaderUniformWriter writer, + Vector2 center, + ShaderExecutionContext context) + { + var semanticOrigin = context.OutputBounds.Position - context.LogicalOrigin; + writer.Set(new Vector2( + (semanticOrigin.X + context.OutputBounds.Width / 2 + center.X) * context.WorkingScale, + (semanticOrigin.Y + context.OutputBounds.Height / 2 + center.Y) * context.WorkingScale)); + } + + private protected static bool TryApplyLegacyDrawableMap( + FilterEffectContext context, + Brush.Resource displacementMap, + GradientSpreadMethod spreadMethod, + DisplacementMapChannel channel, + bool signed, + DrawableMapTransformKind kind, + Vector2 vector, + float angle, + Vector2 center) + { + if (ResolveDrawableBrush(displacementMap) is null) + return false; + + context.CustomEffect( + new LegacyDrawableMapData( + displacementMap, + spreadMethod, + channel, + signed, + kind, + vector, + angle, + center), + ApplyLegacyDrawableMap, + static (_, bounds) => bounds); + return true; + } - static DisplacementMapTranslateTransform() + private static DrawableBrush.Resource? ResolveDrawableBrush(Brush.Resource? brush) { - // SKSLコード(child shaderとして uBaseTexture と uDisplacementMap を使用) - string sksl = - """ - uniform shader uBaseTexture; - uniform shader uDisplacementMap; - - uniform float2 uTranslation; - uniform float2 uPivot; - uniform int uChannel; - uniform int uSigned; - - float getDisplacement(half4 dispColor) { - float d; - if (uChannel == 0) d = dispColor.a; - else { - if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); - else if (uChannel == 2) d = dispColor.r; - else if (uChannel == 3) d = dispColor.g; - else d = dispColor.b; - d = d * dispColor.a; + var seen = new HashSet(ReferenceEqualityComparer.Instance); + while (brush is BrushPresenter.Resource presenter) + { + if (!seen.Add(brush)) + { + throw new InvalidOperationException( + "A BrushPresenter cycle was detected while lowering a displacement map."); + } + + if (presenter.Target is not { } target) + return null; + brush = target; + } + + return brush as DrawableBrush.Resource; + } + + private static void ApplyLegacyDrawableMap( + LegacyDrawableMapData data, + CustomFilterEffectContext context) + { + for (int i = 0; i < context.Targets.Count; i++) + { + EffectTarget effectTarget = context.Targets[i]; + EffectTarget output = context.CreateTargetLike(effectTarget); + try + { + if (output.RenderTarget is null || output.Scale.IsUnbounded) + { + output.Dispose(); + continue; } - if (uSigned != 0) d = d * 2.0 - 1.0; - return d; + + float density = output.Scale.Value; + using SKShader displacementMapShaderRaw = DisplacementMapShaderFactory.CreateOrTransparent( + context, + data.Map, + new Rect(effectTarget.Bounds.Size), + density); + + Vector semanticOrigin = effectTarget.Bounds.Position - effectTarget.RasterBounds.Position; + SKMatrix mapMatrix = SKMatrix.CreateScaleTranslation( + density, + density, + (float)semanticOrigin.X * density, + (float)semanticOrigin.Y * density); + using SKShader? mappedDisplacementMap = mapMatrix.IsIdentity + ? null + : displacementMapShaderRaw.WithLocalMatrix(mapMatrix); + SKShader displacementMapShader = mappedDisplacementMap ?? displacementMapShaderRaw; + + using SKSLShaderBuilder builder = s_legacyDrawableMapShader.Value.CreateBuilder(); + builder.Children["uDisplacementMap"] = displacementMapShader; + builder.Uniforms["uMode"] = (int)data.Kind; + builder.Uniforms["uVector"] = data.Kind == DrawableMapTransformKind.Translate + ? new SKPoint(data.Vector.X * density, data.Vector.Y * density) + : new SKPoint(data.Vector.X, data.Vector.Y); + builder.Uniforms["uAngle"] = data.Angle; + builder.Uniforms["uPivot"] = new SKPoint( + (float)(semanticOrigin.X + effectTarget.Bounds.Width / 2 + data.Center.X) * density, + (float)(semanticOrigin.Y + effectTarget.Bounds.Height / 2 + data.Center.Y) * density); + builder.Uniforms["uChannel"] = (int)data.Channel; + builder.Uniforms["uSigned"] = data.Signed ? 1 : 0; + + SKShaderTileMode tileMode = data.SpreadMethod.ToSKShaderTileMode(); + bool rendered = context.UseMappedInputShader( + effectTarget, + output, + (Builder: builder, Shader: s_legacyDrawableMapShader.Value, Context: context, Output: output), + static (state, mappedSource) => + { + state.Builder.Children["src"] = mappedSource; + state.Shader.RenderToTarget(state.Context, state.Builder, state.Output); + }, + tileMode, + tileMode); + if (!rendered) + { + output.Dispose(); + continue; + } + + effectTarget.Dispose(); + context.Targets[i] = output; + } + catch + { + output.Dispose(); + throw; } + } + } - half4 main(float2 coord) { - half4 dispColor = uDisplacementMap.eval(coord); - float2 offset = uTranslation * getDisplacement(dispColor); + private static void CreateDisplacementMapShader( + ShaderResourceWriter writer, + Brush.Resource displacementMap, + ShaderExecutionContext context) + { + SKShader? shader = new BrushConstructor( + new Rect(context.OutputBounds.Size), + displacementMap, + BlendMode.SrcOver, + context.Intent, + // A drawable map never reaches this binder: TryApplyLegacyDrawableMap routes it to the + // custom-effect path, whose canvas carries the request's materializer. + drawableBrushMaterializer: null, + context.WorkingScale, + context.MaxWorkingScale) + .CreateShader(); + if (shader is null) + { + writer.Set(SKShader.CreateColor(SKColors.Transparent)); + return; + } - float2 uv = coord + offset; - return uBaseTexture.eval(uv); + SKShader? mapped = null; + try + { + var semanticOrigin = context.OutputBounds.Position - context.LogicalOrigin; + SKMatrix localMatrix = SKMatrix.CreateScaleTranslation( + context.WorkingScale, + context.WorkingScale, + semanticOrigin.X * context.WorkingScale, + semanticOrigin.Y * context.WorkingScale); + if (localMatrix.IsIdentity) + { + writer.Set(shader); + shader = null; + return; } - """; - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) + mapped = shader.WithLocalMatrix(localMatrix); + if (mapped is null) + { + writer.Set(shader); + shader = null; + } + else + { + writer.Set(mapped); + mapped = null; + } + } + finally { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + mapped?.Dispose(); + shader?.Dispose(); } } + private protected enum DrawableMapTransformKind : byte + { + Translate, + Scale, + Rotation, + } + + private readonly record struct LegacyDrawableMapData( + Brush.Resource Map, + GradientSpreadMethod SpreadMethod, + DisplacementMapChannel Channel, + bool Signed, + DrawableMapTransformKind Kind, + Vector2 Vector, + float Angle, + Vector2 Center); +} + +[Display(Name = nameof(GraphicsStrings.TranslateTransform), ResourceType = typeof(GraphicsStrings))] +public partial class DisplacementMapTranslateTransform : DisplacementMapTransform +{ + private const string ShaderSource = + """ + uniform shader src; + uniform shader uDisplacementMap; + + uniform float2 uTranslation; + uniform int uChannel; + uniform int uSigned; + + float getDisplacement(half4 dispColor) { + float d; + if (uChannel == 0) d = dispColor.a; + else { + if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); + else if (uChannel == 2) d = dispColor.r; + else if (uChannel == 3) d = dispColor.g; + else d = dispColor.b; + d = d * dispColor.a; + } + if (uSigned != 0) d = d * 2.0 - 1.0; + return d; + } + + half4 main(float2 coord) { + half4 dispColor = uDisplacementMap.eval(coord); + float2 offset = uTranslation * getDisplacement(dispColor); + + float2 uv = coord + offset; + return src.eval(uv); + } + """; + public DisplacementMapTranslateTransform() { ScanProperties(); @@ -75,102 +350,78 @@ public DisplacementMapTranslateTransform() [Display(Name = nameof(GraphicsStrings.TranslateTransform_Y), ResourceType = typeof(GraphicsStrings))] public IProperty Y { get; } = Property.CreateAnimatable(); - internal override void ApplyTo( - Brush.Resource displacementMap, DisplacementMapTransform.Resource resource, - GradientSpreadMethod spreadMethod, DisplacementMapChannel channel, bool signed, FilterEffectContext context) + public partial class Resource { - if (s_shader is null) throw new InvalidOperationException("Failed to compile SKSL."); - var r = (Resource)resource; - - context.CustomEffect((displacementMap, r, spreadMethod, channel, signed, r.X, r.Y), - (d, c) => + internal override void ApplyTo( + Brush.Resource displacementMap, GradientSpreadMethod spreadMethod, + DisplacementMapChannel channel, bool signed, FilterEffectContext context) + { + if (TryApplyLegacyDrawableMap( + context, + displacementMap, + spreadMethod, + channel, + signed, + DrawableMapTransformKind.Translate, + new Vector2(X, Y), + angle: 0, + center: default)) { - var (map, r, sm, ch, isSigned, x, y) = d; - for (int i = 0; i < c.Targets.Count; i++) + return; + } + + RenderResource map = BorrowDisplacementMap(context, displacementMap); + context.Shader(ShaderDescription.WholeSource( + ShaderSource, + RenderBoundsContract.FullInput, + bindings => { - using EffectTarget effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - // Use the clamped density so uniforms / map brush match the buffer. - float w = c.ResolveTargetDensity(effectTarget.Bounds); - using var displacementMapShaderRaw = - new BrushConstructor(new(effectTarget.Bounds.Size), map, BlendMode.SrcOver, w, - c.MaxWorkingScale) - .CreateShader(); - // Scale the map's local matrix by w so it cross-samples at device-px coords. - using SKShader? displacementMapShaderScaled = - w != 1f && displacementMapShaderRaw is { } rawShader - ? rawShader.WithLocalMatrix(SKMatrix.CreateScale(w, w)) - : null; - SKShader? displacementMapShader = displacementMapShaderScaled ?? displacementMapShaderRaw; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(sm.ToSKShaderTileMode(), sm.ToSKShaderTileMode()); - - // SKRuntimeShaderBuilderを作成して、child shaderとuniformを設定 - var builder = s_shader.CreateBuilder(); - - // child shaderとしてテクスチャ用のシェーダーを設定 - builder.Children["uBaseTexture"] = baseShader; - builder.Children["uDisplacementMap"] = displacementMapShader; - - // Absolute-px translation scales by w (shader operates in device px). - builder.Uniforms["uTranslation"] = new SKPoint(x * w, y * w); - builder.Uniforms["uChannel"] = (int)ch; - builder.Uniforms["uSigned"] = isSigned ? 1 : 0; - - // 新しいターゲットに適用 - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } - }); + AddDisplacementBindings(bindings, map, channel, signed); + bindings.Uniform( + "uTranslation", + new Vector2(X, Y), + BindScaledVector); + }, + spreadMethod.ToSKShaderTileMode())); + } } } [Display(Name = nameof(GraphicsStrings.Scale), ResourceType = typeof(GraphicsStrings))] public partial class DisplacementMapScaleTransform : DisplacementMapTransform { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static DisplacementMapScaleTransform() - { - string sksl = - """ - uniform shader uBaseTexture; - uniform shader uDisplacementMap; - - uniform float2 uScale; - uniform float2 uPivot; - uniform int uChannel; - uniform int uSigned; - - float getDisplacement(half4 dispColor) { - float d; - if (uChannel == 0) d = dispColor.a; - else { - if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); - else if (uChannel == 2) d = dispColor.r; - else if (uChannel == 3) d = dispColor.g; - else d = dispColor.b; - d = d * dispColor.a; - } - if (uSigned != 0) d = d * 2.0 - 1.0; - return d; + private const string ShaderSource = + """ + uniform shader src; + uniform shader uDisplacementMap; + + uniform float2 uScale; + uniform float2 uPivot; + uniform int uChannel; + uniform int uSigned; + + float getDisplacement(half4 dispColor) { + float d; + if (uChannel == 0) d = dispColor.a; + else { + if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); + else if (uChannel == 2) d = dispColor.r; + else if (uChannel == 3) d = dispColor.g; + else d = dispColor.b; + d = d * dispColor.a; } + if (uSigned != 0) d = d * 2.0 - 1.0; + return d; + } - half4 main(float2 coord) { - half4 dispColor = uDisplacementMap.eval(coord); - float2 s = max(mix(float2(1.0, 1.0), uScale, getDisplacement(dispColor)), float2(0.001, 0.001)); - - float2 uv = (coord - uPivot) / s + uPivot; - return uBaseTexture.eval(uv); - } - """; + half4 main(float2 coord) { + half4 dispColor = uDisplacementMap.eval(coord); + float2 s = max(mix(float2(1.0, 1.0), uScale, getDisplacement(dispColor)), float2(0.001, 0.001)); - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + float2 uv = (coord - uPivot) / s + uPivot; + return src.eval(uv); } - } + """; public DisplacementMapScaleTransform() { @@ -192,110 +443,88 @@ public DisplacementMapScaleTransform() [Display(Name = nameof(GraphicsStrings.CenterY), ResourceType = typeof(GraphicsStrings))] public IProperty CenterY { get; } = Property.CreateAnimatable(); - internal override void ApplyTo( - Brush.Resource displacementMap, DisplacementMapTransform.Resource resource, - GradientSpreadMethod spreadMethod, DisplacementMapChannel channel, bool signed, FilterEffectContext context) + public partial class Resource { - if (s_shader is null) throw new InvalidOperationException("Failed to compile SKSL."); - var r = (Resource)resource; - - context.CustomEffect( - (displacementMap, spreadMethod, channel, signed, x: r.Scale * r.ScaleX / 10000, y: r.Scale * r.ScaleY / 10000, - center: new Point(r.CenterX, r.CenterY)), - (d, c) => + internal override void ApplyTo( + Brush.Resource displacementMap, GradientSpreadMethod spreadMethod, + DisplacementMapChannel channel, bool signed, FilterEffectContext context) + { + if (TryApplyLegacyDrawableMap( + context, + displacementMap, + spreadMethod, + channel, + signed, + DrawableMapTransformKind.Scale, + new Vector2( + Scale * ScaleX / 10000, + Scale * ScaleY / 10000), + angle: 0, + center: new Vector2(CenterX, CenterY))) { - var (map, sm, ch, isSigned, scaleX, scaleY, center) = d; - for (int i = 0; i < c.Targets.Count; i++) + return; + } + + RenderResource map = BorrowDisplacementMap(context, displacementMap); + context.Shader(ShaderDescription.WholeSource( + ShaderSource, + RenderBoundsContract.FullInput, + bindings => { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - // Use the clamped density so uniforms / map brush match the buffer. - float w = c.ResolveTargetDensity(effectTarget.Bounds); - using var displacementMapShaderRaw = - new BrushConstructor(new(effectTarget.Bounds.Size), map, BlendMode.SrcOver, w, - c.MaxWorkingScale) - .CreateShader(); - // Scale the map's local matrix by w so it cross-samples at device-px coords. - using SKShader? displacementMapShaderScaled = - w != 1f && displacementMapShaderRaw is { } rawShader - ? rawShader.WithLocalMatrix(SKMatrix.CreateScale(w, w)) - : null; - SKShader? displacementMapShader = displacementMapShaderScaled ?? displacementMapShaderRaw; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(sm.ToSKShaderTileMode(), sm.ToSKShaderTileMode()); - - // SKRuntimeShaderBuilderを作成して、child shaderとuniformを設定 - var builder = s_shader.CreateBuilder(); - - // child shaderとしてテクスチャ用のシェーダーを設定 - builder.Children["uBaseTexture"] = baseShader; - builder.Children["uDisplacementMap"] = displacementMapShader; - - // uScale is density-independent; the pivot maps logical-px to device-px, so it scales by w. - builder.Uniforms["uScale"] = new SKPoint(scaleX, scaleY); - builder.Uniforms["uPivot"] = new SKPoint( - (effectTarget.Bounds.Width / 2 + center.X) * w, - (effectTarget.Bounds.Height / 2 + center.Y) * w); - builder.Uniforms["uChannel"] = (int)ch; - builder.Uniforms["uSigned"] = isSigned ? 1 : 0; - - // 新しいターゲットに適用 - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } - }); + AddDisplacementBindings(bindings, map, channel, signed); + bindings.Uniform( + "uScale", + new Vector2( + Scale * ScaleX / 10000, + Scale * ScaleY / 10000)); + bindings.Uniform( + "uPivot", + new Vector2(CenterX, CenterY), + BindPivot); + }, + spreadMethod.ToSKShaderTileMode())); + } } } [Display(Name = nameof(GraphicsStrings.Rotation), ResourceType = typeof(GraphicsStrings))] public partial class DisplacementMapRotationTransform : DisplacementMapTransform { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static DisplacementMapRotationTransform() - { - string sksl = - """ - uniform shader uBaseTexture; - uniform shader uDisplacementMap; - - uniform float uAngle; - uniform float2 uPivot; - uniform int uChannel; - uniform int uSigned; - - float getDisplacement(half4 dispColor) { - float d; - if (uChannel == 0) d = dispColor.a; - else { - if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); - else if (uChannel == 2) d = dispColor.r; - else if (uChannel == 3) d = dispColor.g; - else d = dispColor.b; - d = d * dispColor.a; - } - if (uSigned != 0) d = d * 2.0 - 1.0; - return d; + private const string ShaderSource = + """ + uniform shader src; + uniform shader uDisplacementMap; + + uniform float uAngle; + uniform float2 uPivot; + uniform int uChannel; + uniform int uSigned; + + float getDisplacement(half4 dispColor) { + float d; + if (uChannel == 0) d = dispColor.a; + else { + if (uChannel == 1) d = dot(dispColor.rgb, half3(0.2126, 0.7152, 0.0722)); + else if (uChannel == 2) d = dispColor.r; + else if (uChannel == 3) d = dispColor.g; + else d = dispColor.b; + d = d * dispColor.a; } + if (uSigned != 0) d = d * 2.0 - 1.0; + return d; + } - half4 main(float2 coord) { - half4 dispColor = uDisplacementMap.eval(coord); - float disp = getDisplacement(dispColor); - float2 offset = float2(cos(uAngle * disp), sin(uAngle * disp)); - - float2 uv = coord - uPivot; - float2 rotated = float2(uv.x * offset.x - uv.y * offset.y, uv.x * offset.y + uv.y * offset.x); - uv = rotated + uPivot; - return uBaseTexture.eval(uv); - } - """; + half4 main(float2 coord) { + half4 dispColor = uDisplacementMap.eval(coord); + float disp = getDisplacement(dispColor); + float2 offset = float2(cos(uAngle * disp), sin(uAngle * disp)); - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + float2 uv = coord - uPivot; + float2 rotated = float2(uv.x * offset.x - uv.y * offset.y, uv.x * offset.y + uv.y * offset.x); + uv = rotated + uPivot; + return src.eval(uv); } - } + """; public DisplacementMapRotationTransform() { @@ -311,56 +540,40 @@ public DisplacementMapRotationTransform() [Display(Name = nameof(GraphicsStrings.CenterY), ResourceType = typeof(GraphicsStrings))] public IProperty CenterY { get; } = Property.CreateAnimatable(0); - internal override void ApplyTo( - Brush.Resource displacementMap, DisplacementMapTransform.Resource resource, - GradientSpreadMethod spreadMethod, DisplacementMapChannel channel, bool signed, FilterEffectContext context) + public partial class Resource { - if (s_shader is null) throw new InvalidOperationException("Failed to compile SKSL."); - var r = (Resource)resource; - - context.CustomEffect( - (displacementMap, spreadMethod, channel, signed, r.Rotation, new Point(r.CenterX, r.CenterY)), - (d, c) => + internal override void ApplyTo( + Brush.Resource displacementMap, GradientSpreadMethod spreadMethod, + DisplacementMapChannel channel, bool signed, FilterEffectContext context) + { + if (TryApplyLegacyDrawableMap( + context, + displacementMap, + spreadMethod, + channel, + signed, + DrawableMapTransformKind.Rotation, + vector: default, + angle: MathUtilities.Deg2Rad(Rotation), + center: new Vector2(CenterX, CenterY))) { - var (map, sm, ch, isSigned, rotation, center) = d; - for (int i = 0; i < c.Targets.Count; i++) + return; + } + + RenderResource map = BorrowDisplacementMap(context, displacementMap); + context.Shader(ShaderDescription.WholeSource( + ShaderSource, + RenderBoundsContract.FullInput, + bindings => { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - // Use the clamped density so uniforms / map brush match the buffer. - float w = c.ResolveTargetDensity(effectTarget.Bounds); - using var displacementMapShaderRaw = - new BrushConstructor(new(effectTarget.Bounds.Size), map, BlendMode.SrcOver, w, - c.MaxWorkingScale) - .CreateShader(); - // Scale the map's local matrix by w so it cross-samples at device-px coords. - using SKShader? displacementMapShaderScaled = - w != 1f && displacementMapShaderRaw is { } rawShader - ? rawShader.WithLocalMatrix(SKMatrix.CreateScale(w, w)) - : null; - SKShader? displacementMapShader = displacementMapShaderScaled ?? displacementMapShaderRaw; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(sm.ToSKShaderTileMode(), sm.ToSKShaderTileMode()); - - // SKRuntimeShaderBuilderを作成して、child shaderとuniformを設定 - var builder = s_shader.CreateBuilder(); - - // child shaderとしてテクスチャ用のシェーダーを設定 - builder.Children["uBaseTexture"] = baseShader; - builder.Children["uDisplacementMap"] = displacementMapShader; - - // Pivot maps logical-px to device-px (scales by w); the angle is density-independent. - builder.Uniforms["uAngle"] = MathUtilities.Deg2Rad(rotation); - builder.Uniforms["uPivot"] = new SKPoint( - (effectTarget.Bounds.Width / 2 + center.X) * w, - (effectTarget.Bounds.Height / 2 + center.Y) * w); - builder.Uniforms["uChannel"] = (int)ch; - builder.Uniforms["uSigned"] = isSigned ? 1 : 0; - - // 新しいターゲットに適用 - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } - }); + AddDisplacementBindings(bindings, map, channel, signed); + bindings.Uniform("uAngle", MathUtilities.Deg2Rad(Rotation)); + bindings.Uniform( + "uPivot", + new Vector2(CenterX, CenterY), + BindPivot); + }, + spreadMethod.ToSKShaderTileMode())); + } } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs b/src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs index 3684b444c3..72306a248f 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/EffectTarget.cs @@ -1,26 +1,92 @@ using Beutl.Graphics.Rendering; +using Beutl.Media; namespace Beutl.Graphics.Effects; public sealed class EffectTarget : IDisposable { private object? _target; + private readonly Rect _allocationBounds; + private readonly Rect _allocationRasterBounds; - public EffectTarget(RenderNodeOperation node) + public EffectTarget(RenderTarget renderTarget, Rect originalBounds, EffectiveScale scale = default) + : this( + renderTarget, + originalBounds, + scale.IsUnbounded ? EffectiveScale.At(1f) : scale, + CreateDeviceBounds( + renderTarget, + originalBounds, + scale.IsUnbounded ? EffectiveScale.At(1f) : scale), + CreateLegacyDeviceGridOffset( + originalBounds, + scale.IsUnbounded ? EffectiveScale.At(1f) : scale), + preserveLegacyRasterPlacement: true) { - _target = node; - OriginalBounds = node.Bounds; - Bounds = node.Bounds; - Scale = node.EffectiveScale; } - public EffectTarget(RenderTarget renderTarget, Rect originalBounds, EffectiveScale scale = default) + internal EffectTarget( + RenderTarget renderTarget, + Rect originalBounds, + EffectiveScale scale, + PixelRect deviceBounds, + Vector deviceGridOffset = default, + bool preserveLegacyRasterPlacement = false) { + ArgumentNullException.ThrowIfNull(renderTarget); + if (scale.IsUnbounded) + throw new ArgumentException("An effect target requires a concrete density.", nameof(scale)); + if (deviceBounds.Size != new PixelSize(renderTarget.Width, renderTarget.Height)) + { + throw new ArgumentException( + "Effect target device bounds must match the backing target size.", + nameof(deviceBounds)); + } + _target = renderTarget.ShallowCopy(); + _allocationBounds = originalBounds; + _allocationRasterBounds = deviceBounds + .ToRect(scale.Value) + .Translate(-deviceGridOffset); + OriginalBounds = originalBounds; + Bounds = originalBounds; + Scale = scale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + PreserveLegacyRasterPlacement = preserveLegacyRasterPlacement; + } + + private EffectTarget( + EffectTargetRenderTargetLease renderTargetLease, + Rect originalBounds, + EffectiveScale scale, + PixelRect deviceBounds, + Vector deviceGridOffset, + bool preserveLegacyRasterPlacement) + { + ArgumentNullException.ThrowIfNull(renderTargetLease); + if (scale.IsUnbounded) + throw new ArgumentException("An effect target requires a concrete density.", nameof(scale)); + if (deviceBounds.Size != new PixelSize( + renderTargetLease.Target.Width, + renderTargetLease.Target.Height)) + { + throw new ArgumentException( + "Effect target device bounds must match the backing target size.", + nameof(deviceBounds)); + } + + _target = renderTargetLease; + _allocationBounds = originalBounds; + _allocationRasterBounds = deviceBounds + .ToRect(scale.Value) + .Translate(-deviceGridOffset); OriginalBounds = originalBounds; Bounds = originalBounds; - // A RenderTarget is a concrete buffer; map Unbounded to At(1) for coherent density. - Scale = scale.IsUnbounded ? EffectiveScale.At(1f) : scale; + Scale = scale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + PreserveLegacyRasterPlacement = preserveLegacyRasterPlacement; } public EffectTarget() @@ -36,17 +102,48 @@ public EffectTarget() /// public EffectiveScale Scale { get; init; } - public RenderNodeOperation? NodeOperation => _target as RenderNodeOperation; + /// + /// Gets the immutable composition-device footprint used to allocate the backing target. + /// + /// + /// Convert this footprint to effect-local coordinates with + /// DeviceBounds.ToRect(Scale.Value).Translate(-DeviceGridOffset). + /// + public PixelRect DeviceBounds { get; } - public RenderTarget? RenderTarget => _target as RenderTarget; + /// + /// Gets the translation from effect-local logical coordinates to the composition-device grid + /// used to round the backing target. + /// + public Vector DeviceGridOffset { get; } + + internal bool PreserveLegacyRasterPlacement { get; } + + /// + /// Gets the current effect-local, pixel-aligned logical footprint. Moving + /// translates this footprint without stretching the backing pixels. + /// + public Rect RasterBounds + => _allocationRasterBounds.Translate(Bounds.Position - _allocationBounds.Position); + + public RenderTarget? RenderTarget => _target switch + { + RenderTarget renderTarget => renderTarget, + EffectTargetRenderTargetLease renderTargetLease => renderTargetLease.Target, + _ => null, + }; public bool IsEmpty => _target == null; public EffectTarget Clone() { - if (RenderTarget != null) + if (_target is EffectTargetRenderTargetLease renderTargetLease) + { + return CreateReplacement(renderTargetLease.Retain()); + } + else if (RenderTarget != null) { - return new EffectTarget(RenderTarget, OriginalBounds, Scale) { Bounds = Bounds }; + return CreateReplacement(RenderTarget); } else { @@ -54,10 +151,82 @@ public EffectTarget Clone() } } + /// + /// Wraps a freshly acquired pooled lease as a target, so a path that allocates its own surfaces can honour + /// the caller's without also taking over the lease's lifetime. + /// + internal static EffectTarget FromLease( + RenderTargetLease renderTargetLease, + Rect originalBounds, + EffectiveScale scale, + PixelRect deviceBounds, + Vector deviceGridOffset = default, + bool preserveLegacyRasterPlacement = false) + { + ArgumentNullException.ThrowIfNull(renderTargetLease); + return new EffectTarget( + new EffectTargetRenderTargetLease(renderTargetLease), + originalBounds, + scale, + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement); + } + + internal EffectTarget CreateReplacement(RenderTarget renderTarget) + { + return new EffectTarget( + renderTarget, + _allocationBounds, + Scale, + DeviceBounds, + DeviceGridOffset, + PreserveLegacyRasterPlacement) + { + Bounds = Bounds, + OriginalBounds = OriginalBounds, + }; + } + + internal EffectTarget CreateReplacement(RenderTargetLease renderTargetLease) + => CreateReplacement(new EffectTargetRenderTargetLease(renderTargetLease)); + + private EffectTarget CreateReplacement(EffectTargetRenderTargetLease renderTargetLease) + { + return new EffectTarget( + renderTargetLease, + _allocationBounds, + Scale, + DeviceBounds, + DeviceGridOffset, + PreserveLegacyRasterPlacement) + { + Bounds = Bounds, + OriginalBounds = OriginalBounds, + }; + } + + internal EffectTargetRenderTargetLease? TakeRenderTargetLease() + { + if (_target is not EffectTargetRenderTargetLease renderTargetLease) + return null; + + _target = null; + return renderTargetLease; + } + public void Dispose() { - RenderTarget?.Dispose(); - NodeOperation?.Dispose(); + switch (_target) + { + case RenderTarget renderTarget: + renderTarget.Dispose(); + break; + case EffectTargetRenderTargetLease renderTargetLease: + renderTargetLease.Dispose(); + break; + } + _target = null; OriginalBounds = default; } @@ -66,22 +235,155 @@ public void Draw(ImmediateCanvas canvas) { if (RenderTarget != null) { - // Dest size from buffer footprint (pixels / density), not from Bounds — Bounds may be - // inflated by downstream effects. Density-1 uses a bare point blit; otherwise Mitchell. - if ((Scale.IsUnbounded || Scale.Value == 1f) && canvas.Density == 1f) + Rect rasterBounds = RasterBounds; + Point localOrigin = PreserveLegacyRasterPlacement + ? default + : rasterBounds.Position - Bounds.Position; + // Draw the complete backing footprint. Bounds is semantic metadata and can be + // translated or inflated independently, so it must never be used as the image size. + // A point blit samples nearest, so it only reproduces the buffer when the destination + // lands on exact device pixels; a filter chain anchored at a fractional frame offset + // does not, and must resample instead of snapping the content to the grid. + var destination = new Rect(localOrigin, rasterBounds.Size); + if ((Scale.IsUnbounded || Scale.Value == 1f) + && canvas.Density == 1f + && canvas.CanBlitLossless(destination, new PixelSize(RenderTarget.Width, RenderTarget.Height))) { - canvas.DrawRenderTarget(RenderTarget, default); + canvas.DrawRenderTarget(RenderTarget, localOrigin); } else { - float density = Scale.IsUnbounded ? 1f : Scale.Value; - canvas.DrawRenderTargetScaled(RenderTarget, - new Rect(0, 0, RenderTarget.Width / density, RenderTarget.Height / density)); + canvas.DrawRenderTargetScaled(RenderTarget, destination); } } - else + } + + private static PixelRect CreateDeviceBounds( + RenderTarget renderTarget, + Rect bounds, + EffectiveScale scale) + { + ArgumentNullException.ThrowIfNull(renderTarget); + PixelRect canonical = PixelRect.FromRect(bounds, scale.Value); + return new PixelRect(canonical.Position, new PixelSize(renderTarget.Width, renderTarget.Height)); + } + + private static Vector CreateLegacyDeviceGridOffset(Rect bounds, EffectiveScale scale) + { + Point deviceOrigin = PixelRect.FromRect(bounds, scale.Value) + .ToRect(scale.Value) + .Position; + return deviceOrigin - bounds.Position; + } +} + +internal sealed class EffectTargetRenderTargetLease : IDisposable +{ + private SharedLease? _sharedLease; + private RenderTarget? _target; + private readonly bool _ownsTargetReference; + + public EffectTargetRenderTargetLease(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + _sharedLease = new SharedLease(lease); + _target = lease.Target; + } + + public RenderTarget Target + { + get + { + ObjectDisposedException.ThrowIf(_sharedLease is null, this); + return _target!; + } + } + + public EffectTargetRenderTargetLease Retain() + { + SharedLease sharedLease = _sharedLease + ?? throw new ObjectDisposedException(nameof(EffectTargetRenderTargetLease)); + sharedLease.Retain(); + try + { + return new EffectTargetRenderTargetLease( + sharedLease, + Target.ShallowCopy()); + } + catch + { + sharedLease.Release(); + throw; + } + } + + public RenderTarget TransferToAcceptedCache() + { + SharedLease sharedLease = _sharedLease + ?? throw new ObjectDisposedException(nameof(EffectTargetRenderTargetLease)); + return sharedLease.TransferToAcceptedCache(); + } + + public void Dispose() + { + SharedLease? sharedLease = _sharedLease; + if (sharedLease is null) + return; + + _sharedLease = null; + RenderTarget? target = _target; + _target = null; + try + { + if (_ownsTargetReference) + target?.Dispose(); + } + finally + { + sharedLease.Release(); + } + } + + private EffectTargetRenderTargetLease(SharedLease sharedLease, RenderTarget target) + { + _sharedLease = sharedLease; + _target = target; + _ownsTargetReference = true; + } + + private sealed class SharedLease(RenderTargetLease lease) + { + private RenderTargetLease? _lease = lease; + private int _references = 1; + + public void Retain() + { + ObjectDisposedException.ThrowIf(_references == 0, this); + _references = checked(_references + 1); + } + + public void Release() + { + if (_references == 0) + return; + + _references--; + if (_references == 0) + { + _lease?.ReleaseForBackendReuse(); + _lease = null; + } + } + + public RenderTarget TransferToAcceptedCache() { - NodeOperation?.Render(canvas); + ObjectDisposedException.ThrowIf(_references == 0, this); + RenderTargetLease activeLease = _lease + ?? throw new InvalidOperationException( + "The effect-target lease has already transferred into a persistent cache."); + RenderTarget target = activeLease.TransferToAcceptedCache(); + _lease = null; + return target; } } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FEImpl.cs b/src/Beutl.Engine/Graphics/FilterEffects/FEImpl.cs index 4441761b80..99ccaa97ce 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FEImpl.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FEImpl.cs @@ -1,4 +1,5 @@ -using SkiaSharp; +using Beutl.Graphics.Rendering; +using SkiaSharp; namespace Beutl.Graphics.Effects; @@ -19,25 +20,92 @@ internal record FEItem_Skia( T Data, Func Factory, Func TransformBounds) : FEItem(Data, TransformBounds), IFEItem_Skia { + public Func? DirectFactory { get; init; } + + /// + /// Resolves from the combined execution-time target + /// bounds when authoring-time bounds are unavailable (symbolic input). + /// + public bool ResolveBoundsAtExecutionTime { get; init; } + + /// + /// Maps a requested output region to the input region the built reads, or + /// when the footprint is not proven. + /// + public Func? TransformSamplingBounds { get; init; } + + public bool TryTransformSamplingBounds(Rect output, out Rect input) + { + if (TransformSamplingBounds is null) + { + input = default; + return false; + } + + input = TransformSamplingBounds(Data, output); + return true; + } + public void Accepts(FilterEffectActivator activator, SKImageFilterBuilder builder) { builder.AppendSkiaFilter(Data, activator, Factory); } + + public bool SupportsDirectReplay => DirectFactory is not null; + + public void AcceptsDirect(SKImageFilterBuilder builder) + { + builder.AppendSkiaFilter(Data, DirectFactory!); + } } internal record FEItem_SKColorFilter( T Data, Func Factory) : FEItem(Data, (_, rect) => rect), IFEItem_Skia { + public bool ResolveBoundsAtExecutionTime => false; + + public bool TryTransformSamplingBounds(Rect output, out Rect input) + { + // A color filter is evaluated per pixel, so it never reads outside the requested region. + input = output; + return true; + } + public void Accepts(FilterEffectActivator activator, SKImageFilterBuilder builder) { builder.AppendSKColorFilter(Data, activator, Factory); } + + public bool SupportsDirectReplay => false; + + public void AcceptsDirect(SKImageFilterBuilder builder) + => throw new InvalidOperationException("This color filter has no direct-replay factory."); } internal interface IFEItem_Skia { void Accepts(FilterEffectActivator activator, SKImageFilterBuilder builder); + + bool SupportsDirectReplay { get; } + + void AcceptsDirect(SKImageFilterBuilder builder); + + /// + /// When true, the bounds mapping is resolved from the combined execution-time target + /// bounds instead of per-target authoring-time bounds. + /// + bool ResolveBoundsAtExecutionTime { get; } + + /// + /// Maps a requested output region to the input region this item reads while producing it. + /// + /// + /// when the item declares no proven sampling footprint; the caller must then + /// require the complete input. A footprint is never inferred from , + /// which may legitimately be narrower than what the filter reads. + /// + bool TryTransformSamplingBounds(Rect output, out Rect input); } internal record FEItem_CustomEffect( @@ -54,3 +122,13 @@ internal interface IFEItem_Custom { void Accepts(CustomFilterEffectContext context); } + +internal sealed record FEItem_Shader(ShaderDescription Description) : IFEItem +{ + public Rect TransformBounds(Rect bounds) => Description.Bounds.TransformBounds(bounds); +} + +internal sealed record FEItem_Geometry(GeometryDescription Description) : IFEItem +{ + public Rect TransformBounds(Rect bounds) => Description.Bounds.TransformBounds(bounds); +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs index 7d70aa8fab..4591d4af93 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectActivator.cs @@ -6,32 +6,172 @@ namespace Beutl.Graphics.Effects; -public sealed class FilterEffectActivator( - EffectTargets targets, SKImageFilterBuilder builder, float outputScale = 1f, float workingScale = 1f, - float maxWorkingScale = float.PositiveInfinity) : IDisposable +internal delegate ProgramCacheLease SkRuntimeEffectProgramAcquirer( + EffectTarget target, + string source); + +public sealed class FilterEffectActivator : IDisposable { private static readonly ILogger s_logger = Log.CreateLogger("FilterEffectActivator"); + private readonly SkRuntimeEffectProgramAcquirer? _injectedProgramAcquirer; + private readonly Vector? _deviceGridOffset; + private readonly DrawableBrushMaterializer? _drawableBrushMaterializer; + private readonly bool _useExecutorManagedCanvas; + private readonly RenderTargetLeaseSession? _renderTargetLeaseSession; + private ProgramCache? _ownedProgramCache; + private Dictionary? _pendingSkiaTargets; + private bool _customEffectBoundaryMaterialized; + + public FilterEffectActivator( + EffectTargets targets, + SKImageFilterBuilder builder, + RenderIntent intent, + RenderRequestPurpose purpose, + float outputScale = 1f, + float workingScale = 1f, + float maxWorkingScale = float.PositiveInfinity, + DrawableBrushMaterializer? drawableBrushMaterializer = null) + : this( + targets, + builder, + intent, + purpose, + outputScale, + workingScale, + maxWorkingScale, + acquireProgram: null, + deviceGridOffset: null, + ownsProgramCache: true, + drawableBrushMaterializer, + useExecutorManagedCanvas: false, + renderTargetLeaseSession: null) + { + } + + internal FilterEffectActivator( + EffectTargets targets, + SKImageFilterBuilder builder, + RenderIntent intent, + RenderRequestPurpose purpose, + float outputScale, + float workingScale, + float maxWorkingScale, + Vector deviceGridOffset, + DrawableBrushMaterializer? drawableBrushMaterializer = null, + bool useExecutorManagedCanvas = false, + RenderTargetLeaseSession? renderTargetLeaseSession = null) + : this( + targets, + builder, + intent, + purpose, + outputScale, + workingScale, + maxWorkingScale, + acquireProgram: null, + deviceGridOffset, + ownsProgramCache: true, + drawableBrushMaterializer, + useExecutorManagedCanvas, + renderTargetLeaseSession) + { + } + + internal FilterEffectActivator( + EffectTargets targets, + SKImageFilterBuilder builder, + RenderIntent intent, + RenderRequestPurpose purpose, + float outputScale, + float workingScale, + float maxWorkingScale, + Vector deviceGridOffset, + SkRuntimeEffectProgramAcquirer acquireProgram, + DrawableBrushMaterializer? drawableBrushMaterializer = null, + bool useExecutorManagedCanvas = false, + RenderTargetLeaseSession? renderTargetLeaseSession = null) + : this( + targets, + builder, + intent, + purpose, + outputScale, + workingScale, + maxWorkingScale, + acquireProgram ?? throw new ArgumentNullException(nameof(acquireProgram)), + deviceGridOffset, + ownsProgramCache: false, + drawableBrushMaterializer, + useExecutorManagedCanvas, + renderTargetLeaseSession) + { + } + + private FilterEffectActivator( + EffectTargets targets, + SKImageFilterBuilder builder, + RenderIntent intent, + RenderRequestPurpose purpose, + float outputScale, + float workingScale, + float maxWorkingScale, + SkRuntimeEffectProgramAcquirer? acquireProgram, + Vector? deviceGridOffset, + bool ownsProgramCache, + DrawableBrushMaterializer? drawableBrushMaterializer, + bool useExecutorManagedCanvas, + RenderTargetLeaseSession? renderTargetLeaseSession) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(builder); + if (!Enum.IsDefined(intent)) + throw new ArgumentOutOfRangeException(nameof(intent), intent, "The render intent is invalid."); + if (!Enum.IsDefined(purpose)) + throw new ArgumentOutOfRangeException(nameof(purpose), purpose, "The render request purpose is invalid."); + + Builder = builder; + CurrentTargets = targets; + OutputScale = SanitizePositiveFinite(outputScale, nameof(outputScale)); + WorkingScale = SanitizePositiveFinite(workingScale, nameof(workingScale)); + MaxWorkingScale = SanitizeCeiling(maxWorkingScale, nameof(maxWorkingScale)); + Intent = intent; + Purpose = purpose; + _deviceGridOffset = deviceGridOffset; + _drawableBrushMaterializer = drawableBrushMaterializer; + _useExecutorManagedCanvas = useExecutorManagedCanvas; + _renderTargetLeaseSession = renderTargetLeaseSession; + if (!ownsProgramCache) + { + _injectedProgramAcquirer = acquireProgram + ?? throw new ArgumentNullException(nameof(acquireProgram)); + } + } - public SKImageFilterBuilder Builder { get; } = builder; + public SKImageFilterBuilder Builder { get; } - public EffectTargets CurrentTargets { get; } = targets; + public EffectTargets CurrentTargets { get; } /// The render request's output scale s_out. Sanitized to positive-finite. - public float OutputScale { get; } = SanitizePositiveFinite(outputScale, nameof(outputScale)); + public float OutputScale { get; } /// /// Working density w for buffer allocation. Reduced in place by /// when the dimension clamp fires. Sanitized to positive-finite. /// - public float WorkingScale { get; private set; } = SanitizePositiveFinite(workingScale, nameof(workingScale)); + public float WorkingScale { get; private set; } /// Working-scale ceiling forwarded into nested canvases. NaN or non-positive becomes +Inf (no ceiling). - public float MaxWorkingScale { get; } = SanitizeCeiling(maxWorkingScale, nameof(maxWorkingScale)); + public float MaxWorkingScale { get; } + + /// Gets the explicit preview or delivery classification for this execution. + public RenderIntent Intent { get; } + + /// Gets the explicit request purpose for this execution. + public RenderRequestPurpose Purpose { get; } - // Canonical ceiling rule, plus a warning when it substitutes. private static float SanitizeCeiling(float value, string name) { - float sanitized = RenderNodeContext.SanitizeMaxWorkingScale(value); + float sanitized = RenderScaleUtilities.SanitizeMaxWorkingScale(value); return sanitized != value ? LogAndFallback(value, name, sanitized) : sanitized; } @@ -51,112 +191,394 @@ private static float LogAndFallback(float value, string name, float fallback) return fallback; } + private ProgramCacheLease AcquireOwnedProgram( + EffectTarget target, + string source) + { + ProgramCache cache = + _ownedProgramCache ??= SkRuntimeEffectProgramCache.Create(); + RenderTarget destination = target.RenderTarget + ?? throw new InvalidOperationException( + "A legacy shader program requires a materialized execution destination."); + return SkRuntimeEffectProgramCache.AcquireForDestination( + cache, + destination, + source); + } + + private SkRuntimeEffectProgramAcquirer GetProgramAcquirer() + => _injectedProgramAcquirer ?? AcquireOwnedProgram; + public void Dispose() { + _ownedProgramCache?.Dispose(); } public void Flush(bool force = true) { - if (force - || Builder.HasFilter() - || CurrentTargets is [{ NodeOperation: not null }]) + bool hasFilter = Builder.HasFilter(); + if (!force && !hasFilter) { - using var paint = Builder.HasFilter() ? new SKPaint() : null; - paint?.ImageFilter = Builder.GetFilter(); + _pendingSkiaTargets = null; + return; + } + + using var paint = hasFilter ? new SKPaint() : null; + paint?.ImageFilter = Builder.GetFilter(); - // Re-clamp working scale: Skia filters may have inflated OriginalBounds past the node-level clamp. - for (int i = 0; i < CurrentTargets.Count; i++) + // A forced flush without pending Skia work is the legacy CustomEffect compatibility + // boundary. A forced materialization of a Skia chain must retain its canonical device + // footprint; otherwise unchanged color effects lose edge coverage at fractional scales. + bool imperativeSegmentBoundary = force && !hasFilter; + + var flushTargets = new Dictionary(); + // Re-clamp against the physical runtime footprint. A retained raster can be wider than + // semantic Bounds after a custom effect moves or shrinks the target. + for (int i = 0; i < CurrentTargets.Count; i++) + { + EffectTarget target = CurrentTargets[i]; + Rect allocationBounds = hasFilter ? target.OriginalBounds : target.Bounds; + if (IsEmptyBounds(allocationBounds) || !IsAllocatableBounds(allocationBounds)) + continue; + + FlushTarget flushTarget = ResolveFlushTarget(target, hasFilter); + if (!IsAllocatableBounds(flushTarget.PhysicalBounds)) + continue; + + flushTargets.Add(target, flushTarget); + Rect budgetBounds = imperativeSegmentBoundary + ? new Rect(default, target.Bounds.Size) + : ResolveDeviceRoundingSource(target, flushTarget, hasFilter); + float fit = imperativeSegmentBoundary + ? RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + budgetBounds, + WorkingScale) + : RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + budgetBounds.Translate(target.DeviceGridOffset), + WorkingScale); + if (fit < WorkingScale) { - float fit = RenderNodeContext.ClampWorkingScaleToBufferBudget( - CurrentTargets[i].OriginalBounds, WorkingScale); - if (fit < WorkingScale) - { - s_logger.LogWarning( - "Working scale clamped {From} -> {To} to keep an effect buffer within the GPU axis limit (bounds {Bounds}).", - WorkingScale, fit, CurrentTargets[i].OriginalBounds); - WorkingScale = fit; - } + s_logger.LogWarning( + "Working scale clamped {From} -> {To} to keep an effect buffer within the GPU axis limit (bounds {Bounds}).", + WorkingScale, fit, budgetBounds); + WorkingScale = fit; } + } - for (int i = 0; i < CurrentTargets.Count; i++) + for (int i = 0; i < CurrentTargets.Count; i++) + { + EffectTarget target = CurrentTargets[i]; + Rect allocationBounds = hasFilter ? target.OriginalBounds : target.Bounds; + if (IsEmptyBounds(allocationBounds)) { - EffectTarget target = CurrentTargets[i]; - Rect originalBounds = target.OriginalBounds; - if (IsEmptyBounds(originalBounds)) - { - // An empty target has nothing to render; drop it in every mode (it is not an - // allocation failure), so degenerate glyph/GPU no-op cases do not fail delivery. - target.Dispose(); - CurrentTargets.RemoveAt(i); - i--; - continue; - } - - if (!IsAllocatableBounds(originalBounds)) - { - // Non-finite/negative bounds cannot be allocated (and would crash the native - // allocator), so never reach it: delivery fails fast, preview drops the target. - s_logger.LogWarning( - "Effect flush buffer allocation failed (non-allocatable bounds {Bounds}); preview drops this target, delivery render fails fast.", - originalBounds); - target.Dispose(); - ThrowIfDeliveryAllocationFailure( - $"Effect flush buffer allocation failed (non-allocatable bounds {originalBounds})."); - CurrentTargets.RemoveAt(i); - i--; - continue; - } + // An empty target has nothing to render; drop it in every mode (it is not an + // allocation failure), so degenerate glyph/GPU no-op cases do not fail delivery. + target.Dispose(); + CurrentTargets.RemoveAt(i); + i--; + continue; + } - float w = WorkingScale; - int bw = w == 1f ? (int)target.OriginalBounds.Width : (int)MathF.Ceiling(target.OriginalBounds.Width * w); - int bh = w == 1f ? (int)target.OriginalBounds.Height : (int)MathF.Ceiling(target.OriginalBounds.Height * w); - using RenderTarget? surface = RenderTarget.Create(bw, bh); + if (!IsAllocatableBounds(allocationBounds) + || !flushTargets.TryGetValue(target, out FlushTarget flushTarget)) + { + // Non-finite/negative bounds cannot be allocated (and would crash the native + // allocator), so never reach it: delivery fails fast, preview drops the target. + s_logger.LogWarning( + "Effect flush buffer allocation failed (non-allocatable bounds {Bounds}); preview drops this target, delivery render fails fast.", + allocationBounds); + target.Dispose(); + ThrowIfDeliveryAllocationFailure( + $"Effect flush buffer allocation failed (non-allocatable bounds {allocationBounds})."); + _renderTargetLeaseSession?.MarkContentDropped(); + CurrentTargets.RemoveAt(i); + i--; + continue; + } - if (surface != null) + float w = WorkingScale; + if (!hasFilter + && imperativeSegmentBoundary + && CanReuseLegacyTarget(target, w)) + continue; + + bool preserveLegacyRasterPlacement = imperativeSegmentBoundary; + Vector allocationGridOffset = preserveLegacyRasterPlacement + ? _deviceGridOffset ?? default + : target.DeviceGridOffset; + Rect deviceRoundingSource = imperativeSegmentBoundary + ? target.Bounds + : ResolveDeviceRoundingSource(target, flushTarget, hasFilter); + PixelRect canonicalDeviceBounds = CustomFilterEffectContext.DeviceBufferBounds( + deviceRoundingSource.Translate(allocationGridOffset), w); + PixelRect deviceBounds; + Vector outputDeviceGridOffset; + if (preserveLegacyRasterPlacement) + { + (int width, int height) = CustomFilterEffectContext.DeviceBufferSize( + target.Bounds, + w); + deviceBounds = new PixelRect( + canonicalDeviceBounds.Position, + new PixelSize(width, height)); + outputDeviceGridOffset = deviceBounds + .ToRect(w) + .Position - target.Bounds.Position; + } + else + { + deviceBounds = canonicalDeviceBounds; + outputDeviceGridOffset = target.DeviceGridOffset; + } + if (hasFilter && !preserveLegacyRasterPlacement) + VerifyFilteredDeviceBounds(target, deviceBounds, w); + Rect rasterBounds = deviceBounds + .ToRect(w) + .Translate(-outputDeviceGridOffset); + EffectTarget? newTarget = AllocateFlushTarget( + target.Bounds, + w, + deviceBounds, + outputDeviceGridOffset, + preserveLegacyRasterPlacement); + + if (newTarget != null) + { + try { - using (var canvas = new ImmediateCanvas(surface, w, MaxWorkingScale, - logicalSize: target.OriginalBounds.Size)) + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + deviceBounds, + outputDeviceGridOffset, + w); + using ImmediateCanvas canvas = CreateExecutionCanvas( + newTarget.RenderTarget!, + w, + rasterBounds.Size); + canvas.Clear(); + using (canvas.PushTransform( + Matrix.CreateTranslation( + flushTarget.InputBounds.X + rasterTranslation.X, + flushTarget.InputBounds.Y + rasterTranslation.Y))) + // The layer must be bounded by the content being filtered. Without explicit + // bounds Skia sizes the filter's layer from the clip and samples the area + // outside the drawn content, which is uninitialized device memory — a blur + // (DropShadow, Blur) then pulls those undefined values into the result as NaN. + using (paint != null + ? canvas.PushFilterLayer(paint, new Rect(default, flushTarget.InputBounds.Size)) + : default) { - canvas.Clear(); - using (canvas.PushTransform( - Matrix.CreateTranslation(-target.OriginalBounds.X, -target.OriginalBounds.Y))) - using (paint != null ? canvas.PushPaint(paint) : default) - { - target.Draw(canvas); - } + target.Draw(canvas); } - - var newTarget = new EffectTarget(surface, target.Bounds, EffectiveScale.At(w)) - { - OriginalBounds = target.OriginalBounds - }; - CurrentTargets[i] = newTarget; - target.Dispose(); } - else + catch { - // The layer would silently vanish from the output otherwise — make the failure visible. - s_logger.LogWarning( - "Effect flush buffer allocation failed ({Width}x{Height} px, w {WorkingScale}, bounds {Bounds}); preview drops this target, delivery render fails fast.", - bw, bh, w, originalBounds); - target?.Dispose(); + newTarget.Dispose(); + throw; + } - ThrowIfDeliveryAllocationFailure( - $"Effect flush buffer allocation failed ({bw}x{bh} px, w {w}, bounds {originalBounds})."); + newTarget.OriginalBounds = target.OriginalBounds; + CurrentTargets[i] = newTarget; + target.Dispose(); + } + else + { + // The layer would silently vanish from the output otherwise — make the failure visible. + s_logger.LogWarning( + "Effect flush buffer allocation failed ({Width}x{Height} px, w {WorkingScale}, bounds {Bounds}); preview drops this target, delivery render fails fast.", + deviceBounds.Width, deviceBounds.Height, w, flushTarget.PhysicalBounds); + target.Dispose(); + + ThrowIfDeliveryAllocationFailure( + $"Effect flush buffer allocation failed ({deviceBounds.Width}x{deviceBounds.Height} px, w {w}, bounds {flushTarget.PhysicalBounds})."); + _renderTargetLeaseSession?.MarkContentDropped(); + + CurrentTargets.RemoveAt(i); + i--; + } + } - CurrentTargets.RemoveAt(i); - i--; - } + _pendingSkiaTargets = null; + Builder.Clear(); + } + /// + /// Allocates one flush buffer, through the caller's lease session when there is one. + /// + /// + /// A configured is reachable only through the session, and its targets + /// may come from a context the global allocator knows nothing about, so going around it here would both + /// ignore the caller's allocation policy and mix surfaces from two contexts inside one flush. + /// + private EffectTarget? AllocateFlushTarget( + Rect bounds, + float w, + PixelRect deviceBounds, + Vector deviceGridOffset, + bool preserveLegacyRasterPlacement) + { + if (_renderTargetLeaseSession is { HasTargetFactory: true } leaseSession) + { + RenderTargetLease? lease = leaseSession.TryAcquire(deviceBounds.Size); + if (lease is null) + return null; + + try + { + return EffectTarget.FromLease( + lease, + bounds, + EffectiveScale.At(w), + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement); } + catch + { + lease.Dispose(); + throw; + } + } + + using RenderTarget? surface = RenderTarget.Create(deviceBounds.Width, deviceBounds.Height); + return surface is null + ? null + : new EffectTarget( + surface, + bounds, + EffectiveScale.At(w), + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement); + } + + internal void CompletePolicyBoundary(bool materializationRequired) + { + // A CustomEffect already consumed the policy through its forced pre-callback Flush. + // Re-forcing after the callback would discard backing that legacy code intentionally + // retained while moving or shrinking only Bounds. Pending Skia work still flushes. + Flush(materializationRequired && !_customEffectBoundaryMaterialized); + } + + private FlushTarget ResolveFlushTarget(EffectTarget target, bool hasFilter) + { + if (!hasFilter) + { + // A forced no-filter flush is the compatibility boundary for imperative CustomEffect + // callbacks. Materialize semantic input without exposing a renderer-owned apron; + // callback-created targets keep their separate legacy local-buffer contract. + return new FlushTarget(target.Bounds, target.Bounds); + } + + Rect inputBounds; + Rect physicalBounds; + if (_pendingSkiaTargets?.TryGetValue(target, out PendingSkiaTarget? pending) == true) + { + inputBounds = pending.InputBounds; + physicalBounds = pending.PhysicalBounds; + } + else + { + inputBounds = target.Bounds; + physicalBounds = target.RasterBounds.Translate( + target.OriginalBounds.Position - target.Bounds.Position); + } + + // Skia bounds callbacks are authored in OriginalBounds' local coordinate space. Keep the + // union in that space through clamping and device rounding; moving it into global logical + // coordinates first can erase the extra pixel contributed by a fractional local origin. + Rect localSemanticBounds = target.Bounds.Translate( + target.OriginalBounds.Position - target.Bounds.Position); + return new FlushTarget( + inputBounds, + physicalBounds + .Union(target.OriginalBounds) + .Union(localSemanticBounds)); + } + + // The filtered union is built in OriginalBounds' local space, but device rounding must happen once + // in global space: a locally rounded rect re-anchored by a separately rounded offset cannot reproduce + // the rounding the semantic device bounds use. The explicit Bounds union keeps containment exact + // instead of relying on the local round trip being bit-exact in float. + private static Rect ResolveDeviceRoundingSource( + EffectTarget target, + FlushTarget flushTarget, + bool hasFilter) + => hasFilter + ? flushTarget.PhysicalBounds + .Translate(target.Bounds.Position - target.OriginalBounds.Position) + .Union(target.Bounds) + : flushTarget.PhysicalBounds; + + private static void VerifyFilteredDeviceBounds( + EffectTarget target, + PixelRect deviceBounds, + float density) + { + PixelRect semanticDeviceBounds = PixelRect.FromRect( + target.Bounds.Translate(target.DeviceGridOffset), + density); + if (!Contains(deviceBounds, semanticDeviceBounds)) + { + throw new InvalidOperationException( + "A filtered physical footprint must contain its semantic device bounds."); + } + } + + private static bool CanReuseLegacyTarget(EffectTarget target, float density) + { + if (!target.PreserveLegacyRasterPlacement + || target.Scale.IsUnbounded + || target.Scale.Value != density + || target.RenderTarget is not { } renderTarget) + { + return false; + } + + (int width, int height) = CustomFilterEffectContext.DeviceBufferSize( + target.Bounds, + density); + return renderTarget.Width == width && renderTarget.Height == height; + } + + private static bool Contains(PixelRect outer, PixelRect inner) + => outer.X <= inner.X + && outer.Y <= inner.Y + && outer.Right >= inner.Right + && outer.Bottom >= inner.Bottom; - Builder.Clear(); + /// + /// Makes sure every current target has chain bookkeeping, keeping what an in-progress chain accumulated. + /// + /// + /// A Skia item runs author code that may re-enter or , both of + /// which drop this map and can replace the targets it was keyed by. Entries are therefore added rather + /// than the map rebuilt, so calling this again after author code has run restores a dropped map and covers + /// a target that appeared, without resetting a chain that survived. + /// + private void BeginSkiaChain() + { + _pendingSkiaTargets ??= new Dictionary(); + foreach (EffectTarget target in CurrentTargets) + { + if (_pendingSkiaTargets.ContainsKey(target)) + continue; + + Rect physicalBounds = target.RasterBounds.Translate( + target.OriginalBounds.Position - target.Bounds.Position); + // OriginalBounds cannot serve as the anchor frame: a stage the fallback executor allocated + // itself begins a chain with OriginalBounds == Bounds, which anchors the chain at zero. + _pendingSkiaTargets.Add( + target, + new PendingSkiaTarget( + target.Bounds, + physicalBounds, + new Rect(default, target.Bounds.Size))); } } private void ThrowIfDeliveryAllocationFailure(string message) { - if (float.IsPositiveInfinity(MaxWorkingScale)) + if (Intent == RenderIntent.Delivery) { throw new InvalidOperationException(message); } @@ -182,7 +604,9 @@ private static bool IsEmptyBounds(Rect bounds) // 最小単位である'IFEItem'の数がわからないので 'count'は'nullable' public void Apply(FilterEffectContext context) { + ArgumentNullException.ThrowIfNull(context); if (CurrentTargets.Count == 0) return; + context.PrepareStandaloneResourcesForExecution(); foreach (IFEItem item in context._items) { @@ -190,11 +614,33 @@ public void Apply(FilterEffectContext context) { case IFEItem_Skia skia: { + BeginSkiaChain(); skia.Accepts(this, Builder); + // Author code just ran and may have gone through Activate() or Flush(), either of + // which drops the bookkeeping this loop is about to read. + BeginSkiaChain(); + // A deferred-bound Skia item resolves its matrix once from the combined + // execution-time target bounds (the first TransformBounds call fixes it), + // because its origin depends on input bounds a preceding custom effect may + // only re-target at execution time. Every target then maps with that matrix. + if (skia.ResolveBoundsAtExecutionTime) + _ = item.TransformBounds(CurrentTargets.CalculateBounds()); + foreach (EffectTarget t in CurrentTargets) { + PendingSkiaTarget pending = _pendingSkiaTargets![t]; + pending.PhysicalBounds = item.TransformBounds(pending.PhysicalBounds); + pending.AnchorFrame = item.TransformBounds(pending.AnchorFrame); t.Bounds = item.TransformBounds(t.Bounds); t.OriginalBounds = item.TransformBounds(t.OriginalBounds); + // The chain's execution frame is anchored at InputBounds.Position, which + // must stay equal to the displacement this item's accumulated mapping + // gives the chain-start Bounds.Position. A translation-invariant item + // preserves that displacement, so this is a no-op there; a matrix item + // moves Bounds relative to the anchor frame and has to re-anchor with it. + pending.InputBounds = new Rect( + t.Bounds.Position - pending.AnchorFrame.Position, + pending.InputBounds.Size); } break; @@ -203,9 +649,19 @@ public void Apply(FilterEffectContext context) { Flush(); if (CurrentTargets.Count == 0) return; + _customEffectBoundaryMaterialized = true; var customContext = new CustomFilterEffectContext( - CurrentTargets, OutputScale, WorkingScale, MaxWorkingScale); + CurrentTargets, + Intent, + Purpose, + OutputScale, + WorkingScale, + MaxWorkingScale, + _deviceGridOffset, + _drawableBrushMaterializer, + _useExecutorManagedCanvas, + _renderTargetLeaseSession); custom.Accepts(customContext); foreach (EffectTarget t in CurrentTargets) @@ -215,6 +671,37 @@ public void Apply(FilterEffectContext context) break; } + case FEItem_Shader shader: + { + Flush(false); + if (CurrentTargets.Count == 0) return; + FilterEffectStageFallbackExecutor.ApplyShader( + CurrentTargets, + shader.Description, + OutputScale, + WorkingScale, + MaxWorkingScale, + Intent, + Purpose, + GetProgramAcquirer(), + _renderTargetLeaseSession); + break; + } + case FEItem_Geometry geometry: + { + Flush(false); + if (CurrentTargets.Count == 0) return; + FilterEffectStageFallbackExecutor.ApplyGeometry( + CurrentTargets, + geometry.Description, + OutputScale, + WorkingScale, + MaxWorkingScale, + Intent, + Purpose, + _renderTargetLeaseSession); + break; + } } } @@ -234,11 +721,29 @@ public void Apply(FilterEffectContext context) public SKImageFilter? Activate(FilterEffectContext context) { + // A no-op Flush still drops the pending-Skia bookkeeping, which the caller's own in-progress chain + // still needs when it authored this call from a Skia factory. + Dictionary? pendingSkiaTargets = + Builder.HasFilter() ? null : _pendingSkiaTargets; Flush(false); + _pendingSkiaTargets = pendingSkiaTargets; using EffectTargets cloned = CurrentTargets.Clone(); using var builder = new SKImageFilterBuilder(); - using var activator = new FilterEffectActivator(cloned, builder, OutputScale, WorkingScale, MaxWorkingScale); + using var activator = new FilterEffectActivator( + cloned, + builder, + Intent, + Purpose, + OutputScale, + WorkingScale, + MaxWorkingScale, + _deviceGridOffset + ?? (cloned.Count > 0 ? cloned[0].DeviceGridOffset : default), + GetProgramAcquirer(), + _drawableBrushMaterializer, + _useExecutorManagedCanvas, + _renderTargetLeaseSession); activator.Apply(context); activator.Flush(false); @@ -253,31 +758,69 @@ public void Apply(FilterEffectContext context) SKSurface innerSurface = t.RenderTarget.Value; using SKImage skImage = innerSurface.Snapshot(); - // Dest size from buffer footprint (pixels / density), not from Bounds — Bounds may be - // inflated by downstream effects. - SKImageFilter image; - if (t.Scale.IsUnbounded || t.Scale.Value == 1f) - { - image = SKImageFilter.CreateImage(skImage); - } - else - { - float density = t.Scale.Value; - var dst = new SKRect( - (float)t.Bounds.X, - (float)t.Bounds.Y, - (float)t.Bounds.X + skImage.Width / density, - (float)t.Bounds.Y + skImage.Height / density); - image = SKImageFilter.CreateImage( - skImage, - new SKRect(0, 0, skImage.Width, skImage.Height), - dst, - new SKSamplingOptions(SKCubicResampler.Mitchell)); - } + Rect rasterBounds = t.RasterBounds; + SKImageFilter image = SKImageFilter.CreateImage( + skImage, + new SKRect(0, 0, skImage.Width, skImage.Height), + rasterBounds.ToSKRect(), + SKSamplingOptions.Default); filter = filter == null ? image : SKImageFilter.CreateCompose(filter, image); } return filter; } + + private sealed class PendingSkiaTarget( + Rect inputBounds, + Rect physicalBounds, + Rect anchorFrame) + { + public Rect InputBounds { get; set; } = inputBounds; + + public Rect PhysicalBounds { get; set; } = physicalBounds; + + /// + /// The chain-start frame: the origin at the chain-start size, + /// mapped by every item alongside them. Subtracting its position from the mapped Bounds position + /// leaves the chain-start Bounds position under the accumulated linear part, which is the anchor + /// the flush frame needs. The matching size is what makes that subtraction cancel: a bounds map + /// displaces two rects by the same amount only while their sizes agree. + /// + public Rect AnchorFrame { get; set; } = anchorFrame; + } + + private readonly record struct FlushTarget( + Rect InputBounds, + Rect PhysicalBounds); + + private ImmediateCanvas CreateExecutionCanvas( + RenderTarget target, + float density, + Size logicalSize) + { + ImmediateCanvas canvas; + if (_useExecutorManagedCanvas) + { + canvas = ImmediateCanvas.CreateExecutorManaged( + target, + density, + MaxWorkingScale, + logicalSize, + Intent); + canvas.ConfigureCustomEffectExecution(); + } + else + { + canvas = new ImmediateCanvas( + target, + density, + MaxWorkingScale, + logicalSize, + Intent); + } + + canvas.DrawableBrushMaterializer = _drawableBrushMaterializer; + return canvas; + } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs index e4e76b54f7..3bc1aeb1e8 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectContext.cs @@ -1,6 +1,8 @@ using System.ComponentModel; using System.Reactive; +using System.Runtime.ExceptionServices; using Beutl.Collections.Pooled; +using Beutl.Graphics.Rendering; using Beutl.Media; using Microsoft.Extensions.ObjectPool; using SkiaSharp; @@ -26,6 +28,10 @@ public sealed class FilterEffectContext : IDisposable { internal readonly PooledList _items; internal readonly PooledList _renderTimeItems; + private readonly FilterEffectResourceState _resourceState; + private readonly Lazy _workingScale; + private readonly bool _hasResolvedWorkingScale; + private bool _disposed; internal static readonly ObjectPool s_colorMatPool; @@ -35,10 +41,57 @@ static FilterEffectContext() } public FilterEffectContext(Rect bounds, float outputScale = 1f, float workingScale = 1f) - { - Bounds = OriginalBounds = bounds; + : this( + bounds, + outputScale, + CreateResolvedWorkingScale(workingScale), + hasResolvedWorkingScale: true, + new FilterEffectResourceState(renderContext: null)) + { + } + + internal FilterEffectContext( + Rect bounds, + float outputScale, + float workingScale, + RenderNodeContext renderContext, + bool hasResolvedWorkingScale = true) + : this( + bounds, + outputScale, + CreateResolvedWorkingScale(workingScale), + hasResolvedWorkingScale, + new FilterEffectResourceState(renderContext)) + { + } + + internal FilterEffectContext( + Rect bounds, + float outputScale, + Func resolveWorkingScale, + RenderNodeContext renderContext, + bool hasResolvedWorkingScale = true) + : this( + bounds, + outputScale, + new Lazy(resolveWorkingScale ?? throw new ArgumentNullException(nameof(resolveWorkingScale))), + hasResolvedWorkingScale, + new FilterEffectResourceState(renderContext)) + { + } + + private FilterEffectContext( + Rect bounds, + float outputScale, + Lazy workingScale, + bool hasResolvedWorkingScale, + FilterEffectResourceState resourceState) + { + _bounds = OriginalBounds = bounds; OutputScale = outputScale; - WorkingScale = workingScale; + _workingScale = workingScale; + _hasResolvedWorkingScale = hasResolvedWorkingScale; + _resourceState = resourceState; _renderTimeItems = []; _items = []; } @@ -46,14 +99,31 @@ public FilterEffectContext(Rect bounds, float outputScale = 1f, float workingSca private FilterEffectContext(FilterEffectContext obj) { OriginalBounds = obj.OriginalBounds; - Bounds = obj.Bounds; + _bounds = obj._bounds; OutputScale = obj.OutputScale; - WorkingScale = obj.WorkingScale; + _workingScale = obj._workingScale; + _hasResolvedWorkingScale = obj._hasResolvedWorkingScale; + _resourceState = obj._resourceState.AddReference(); _renderTimeItems = new PooledList(obj._renderTimeItems); _items = new PooledList(obj._items); } - public Rect Bounds { get; internal set; } + private FilterEffectContext( + FilterEffectContext obj, + Rect bounds) + { + OriginalBounds = _bounds = bounds; + OutputScale = obj.OutputScale; + _workingScale = obj._workingScale; + _hasResolvedWorkingScale = obj._hasResolvedWorkingScale; + _resourceState = obj._resourceState.AddReference(); + _renderTimeItems = []; + _items = []; + } + + private Rect _bounds; + + internal Rect Bounds => _bounds; public Rect OriginalBounds { get; } @@ -63,25 +133,65 @@ private FilterEffectContext(FilterEffectContext obj) public float OutputScale { get; } /// - /// The density w at which intermediate buffers are allocated (ceil(bounds * w)). - /// Resolved per-effect via . + /// The nominal effect-input density w from which authored operations negotiate their buffers using the + /// canonical near-edge/far-edge composition-device footprint. + /// Resolved per-effect via . /// - public float WorkingScale { get; } + /// An expanding operation may run below this value after its own per-buffer dimension clamp. + /// + /// The effect is being authored against unresolved or branch-dependent input metadata, so one final working + /// scale is not available. Use to probe availability and defer device-pixel + /// math to execution-time shader, geometry, or custom-effect callbacks. + /// + public float WorkingScale + => TryGetWorkingScale(out float workingScale) + ? workingScale + : throw new InvalidOperationException( + "The filter-effect working scale is unavailable because its input metadata is unresolved or " + + "different branches may lower at different densities. Use TryGetWorkingScale during ApplyTo " + + "and perform device-pixel math in an " + + "execution-time shader, geometry, or custom-effect callback."); + + /// Tries to get the nominal effect-input working density available while authoring this effect. + /// + /// Receives the positive finite working density, or when input metadata is unresolved + /// or multiple input branches may lower at different densities. + /// + /// + /// when one concrete effect-input density is available; + /// otherwise because the inputs are unresolved or branch-dependent. + /// + /// + /// A later bounds-expanding operation may apply the per-buffer dimension clamp and run below this nominal + /// density. Use this value only for scale-independent recording decisions; read the operation-specific density + /// or actual target scale from the execution-time shader, geometry, or custom-effect context for device math. + /// A result requires scale-independent recording. + /// + public bool TryGetWorkingScale(out float workingScale) + { + workingScale = _hasResolvedWorkingScale ? _workingScale.Value : default; + return _hasResolvedWorkingScale; + } + + private static Lazy CreateResolvedWorkingScale(float workingScale) + => new(() => workingScale); public FilterEffectContext Clone() { + ThrowIfDisposed(); return new FilterEffectContext(this); } public FilterEffectContext CreateChildContext() { - // 今はnewしているが、キャッシュする予定 - return new FilterEffectContext(Bounds, OutputScale, WorkingScale); + ThrowIfDisposed(); + return new FilterEffectContext(this, _bounds); } private void AddItem(IFEItem item) { - if (!Bounds.IsInvalid) + ThrowIfDisposed(); + if (!_bounds.IsInvalid) { _items.Add(item); } @@ -91,13 +201,100 @@ private void AddItem(IFEItem item) } } + internal void Shader(ShaderDescription description) + { + ArgumentNullException.ThrowIfNull(description); + _resourceState.ValidateResources( + description.Resources.Select(static binding => binding.Resource), + nameof(description)); + AppendDescription(new FEItem_Shader(description)); + } + + /// Appends one shader definition call to this filter-effect stream. + public void Shader(ShaderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + Shader(call.Description); + } + + internal void Geometry(GeometryDescription description) + { + ArgumentNullException.ThrowIfNull(description); + _resourceState.ValidateResources( + description.Resources.Select(static binding => binding.Resource), + nameof(description)); + AppendDescription(new FEItem_Geometry(description)); + } + + /// Appends one geometry definition call to this filter-effect stream. + public void Geometry(GeometryCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + Geometry(call.Description); + } + + public RenderResource Own(T resource) + where T : class, IDisposable + { + ThrowIfDisposed(); + return _resourceState.Own(resource); + } + + public RenderResource Borrow(T resource) + where T : class + { + ThrowIfDisposed(); + return _resourceState.Borrow(resource); + } + + private void AppendDescription(IFEItem item) + { + ThrowIfDisposed(); + if (_bounds.IsInvalid) + { + _renderTimeItems.Add(item); + return; + } + + Rect nextBounds = item.TransformBounds(_bounds); + _items.Add(item); + _bounds = nextBounds; + } + + /// + /// Maps a requested output region to the input region the filter reads while producing it. Omit it when + /// the footprint is not proven; the region analyzer then materializes the complete input instead of + /// inferring a footprint from , which may be narrower than what the + /// filter reads. + /// [EditorBrowsable(EditorBrowsableState.Never)] public void AppendSkiaFilter(T data, Func factory, - Func transformBounds) + Func transformBounds, Func? transformSamplingBounds = null) where T : IEquatable { - AddItem(new FEItem_Skia(data, factory, transformBounds)); - Bounds = transformBounds.Invoke(data, Bounds); + AppendDescription(new FEItem_Skia(data, factory, transformBounds) + { + TransformSamplingBounds = transformSamplingBounds, + }); + } + + private void AppendDirectSkiaFilter( + T data, + Func factory, + Func transformBounds, + Func? transformSamplingBounds = null) + where T : IEquatable + { + AppendDescription(new FEItem_Skia( + data, + (value, input, _) => factory(value, input), + transformBounds) + { + DirectFactory = factory, + TransformSamplingBounds = transformSamplingBounds, + }); } [EditorBrowsable(EditorBrowsableState.Never)] @@ -109,23 +306,29 @@ public void AppendSKColorFilter(T data, Func SKImageFilter.CreateDropShadowOnly(t.position.X, t.position.Y, + factory: static (t, input) => SKImageFilter.CreateDropShadowOnly(t.position.X, t.position.Y, t.sigma.Width, t.sigma.Height, t.color.ToSKColor(), input), transformBounds: static (t, bounds) => bounds .Translate(t.position) + .Inflate(new Thickness(t.sigma.Width * 3, t.sigma.Height * 3)), + transformSamplingBounds: static (t, region) => region + .Translate(-t.position) .Inflate(new Thickness(t.sigma.Width * 3, t.sigma.Height * 3))); } public void DropShadow(Point position, Size sigma, Color color) { - AppendSkiaFilter( + AppendDirectSkiaFilter( data: (position, sigma, color), - factory: static (t, input, _) => SKImageFilter.CreateDropShadow(t.position.X, t.position.Y, t.sigma.Width, + factory: static (t, input) => SKImageFilter.CreateDropShadow(t.position.X, t.position.Y, t.sigma.Width, t.sigma.Height, t.color.ToSKColor(), input), transformBounds: static (t, bounds) => bounds.Union(bounds .Translate(t.position) + .Inflate(new Thickness(t.sigma.Width * 3, t.sigma.Height * 3))), + transformSamplingBounds: static (t, region) => region.Union(region + .Translate(-t.position) .Inflate(new Thickness(t.sigma.Width * 3, t.sigma.Height * 3)))); } @@ -136,9 +339,9 @@ public void Blur(Size sigma) if (sigma.Height < 0) sigma = sigma.WithHeight(0); - AppendSkiaFilter( + AppendDirectSkiaFilter( data: sigma, - factory: static (sigma, input, _) => + factory: static (sigma, input) => { if (sigma.Width == 0 && sigma.Height == 0) return null; @@ -146,7 +349,9 @@ public void Blur(Size sigma) return SKImageFilter.CreateBlur(sigma.Width, sigma.Height, input); }, transformBounds: static (sigma, bounds) => - bounds.Inflate(new Thickness(sigma.Width * 3, sigma.Height * 3))); + bounds.Inflate(new Thickness(sigma.Width * 3, sigma.Height * 3)), + transformSamplingBounds: static (sigma, region) => + region.Inflate(new Thickness(sigma.Width * 3, sigma.Height * 3))); } // https://github.com/Shopify/react-native-skia/blob/c7740e30234e6b0a49721ab954c4a848e42d7edb/package/src/dom/nodes/paint/ImageFilters.ts#L25 @@ -168,6 +373,12 @@ private void InnerShadowCore(Point position, Size sigma, Color color, Graphics.B if (target.RenderTarget is not null) { EffectTarget newTarget = context.CreateTarget(target.Bounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + continue; + } + using (ImmediateCanvas canvas = context.Open(newTarget)) // Source point-blits and sigma/offset are device-px; composite in device space. using (canvas.PushDeviceSpace()) @@ -201,13 +412,62 @@ private void InnerShadowCore(Point position, Size sigma, Color color, Graphics.B public void Transform(Matrix matrix, BitmapInterpolationMode bitmapInterpolationMode) { - AppendSkiaFilter( + // No sampling footprint: the resampling apron is a device-pixel quantity, and the density the + // segment finally runs at is unknown here, so no logical margin can bound it. + AppendDirectSkiaFilter( (matrix, bitmapInterpolationMode), - (data, input, _) => SKImageFilter.CreateMatrix(data.matrix.ToSKMatrix(), + (data, input) => SKImageFilter.CreateMatrix(data.matrix.ToSKMatrix(), data.bitmapInterpolationMode.ToSKSamplingOptions(), input), (data, rect) => rect.TransformToAABB(data.matrix)); } + /// + /// Appends a Skia matrix image filter whose matrix is resolved from the execution-time target + /// bounds via when the input bounds are symbolic. + /// + /// + /// When is concrete the matrix is resolved from it immediately, matching + /// . When it is + /// (symbolic owning-domain input) the matrix is resolved once from + /// the combined execution-time target bounds and reused for every target. + /// + public void Transform(T data, Func matrixFactory, + BitmapInterpolationMode bitmapInterpolationMode) + where T : IEquatable + { + if (!_bounds.IsInvalid) + { + Transform(matrixFactory(data, _bounds), bitmapInterpolationMode); + return; + } + + // The matrix is resolved from the first bounds observation (the combined execution-time + // target bounds) and then fixed, so every target transforms with the same matrix. + Matrix resolved = default; + bool resolvedSet = false; + Func<(T Data, Func MatrixFactory, BitmapInterpolationMode Mode), Rect, Rect> transformBounds = + (d, rect) => + { + if (rect.IsInvalid) + return Rect.Invalid; + if (!resolvedSet) + { + resolved = d.MatrixFactory(d.Data, rect); + resolvedSet = true; + } + return rect.TransformToAABB(resolved); + }; + AppendDescription(new FEItem_Skia<(T Data, Func MatrixFactory, BitmapInterpolationMode Mode)>( + (data, matrixFactory, bitmapInterpolationMode), + (d, input, activator) => SKImageFilter.CreateMatrix( + d.MatrixFactory(d.Data, activator.CurrentTargets.CalculateBounds()).ToSKMatrix(), + d.Mode.ToSKSamplingOptions(), input), + transformBounds) + { + ResolveBoundsAtExecutionTime = true, + }); + } + public void MatrixConvolution( PixelSize kernelSize, float[] kernel, @@ -217,9 +477,11 @@ public void MatrixConvolution( GradientSpreadMethod spreadMethod, bool convolveAlpha) { - AppendSkiaFilter( + // No sampling footprint: the spread method resolves against the extent of whatever input it is + // given, so a cropped input would change the result inside the requested region. + AppendDirectSkiaFilter( (kernelSize, kernel, gain, bias, kernelOffset, spreadMethod, convolveAlpha), - (data, input, _) => SKImageFilter.CreateMatrixConvolution( + (data, input) => SKImageFilter.CreateMatrixConvolution( data.kernelSize.ToSKSizeI(), data.kernel, data.gain, @@ -244,22 +506,44 @@ public void MatrixConvolution( public void Erode(float radiusX, float radiusY) { - AppendSkiaFilter( + if (!TryClampMorphologyRadius(ref radiusX, ref radiusY)) + return; + + AppendDirectSkiaFilter( (radiusX, radiusY), - (data, input, _) => SKImageFilter.CreateErode(data.radiusX, data.radiusY, input), - (data, rect) => rect); + (data, input) => SKImageFilter.CreateErode(data.radiusX, data.radiusY, input), + (data, rect) => rect, + // Erode shrinks its declared output but still reads the whole radius neighbourhood. + (data, region) => region.Inflate(new Thickness(data.radiusX, data.radiusY))); } public void Dilate(float radiusX, float radiusY) { - AppendSkiaFilter( + if (!TryClampMorphologyRadius(ref radiusX, ref radiusY)) + return; + + AppendDirectSkiaFilter( (radiusX, radiusY), - (data, input, _) => SKImageFilter.CreateDilate(data.radiusX, data.radiusY, input), - (data, rect) => rect.Inflate(new Thickness(data.radiusX, data.radiusY))); + (data, input) => SKImageFilter.CreateDilate(data.radiusX, data.radiusY, input), + (data, rect) => rect.Inflate(new Thickness(data.radiusX, data.radiusY)), + (data, region) => region.Inflate(new Thickness(data.radiusX, data.radiusY))); + } + + // Skia rejects a negative morphology radius, so it degrades to a pass-through. The all-zero + // case records no stage rather than an identity one because a degenerate stage still re-grids + // the content through an intermediate and shifts antialiased edges. + private static bool TryClampMorphologyRadius(ref float radiusX, ref float radiusY) + { + radiusX = MathF.Max(radiusX, 0); + radiusY = MathF.Max(radiusY, 0); + return radiusX != 0 || radiusY != 0; } public void ColorMatrix(in ColorMatrix matrix) { + if (matrix.IsIdentity) + return; + AppendSKColorFilter(matrix, (m, _) => { float[] array = s_colorMatPool.Get(); @@ -278,61 +562,42 @@ public void ColorMatrix(in ColorMatrix matrix) public void ColorMatrix(T data, Func factory) where T : IEquatable { - AppendSKColorFilter( - (data, factory), - (t, _) => - { - float[] array = s_colorMatPool.Get(); - try - { - t.factory.Invoke(t.data).ToArrayForSkia(array); - return SKColorFilter.CreateColorMatrix(array); - } - finally - { - s_colorMatPool.Return(array); - } - }); + ArgumentNullException.ThrowIfNull(factory); + ColorMatrix(factory(data)); } public void Saturate(float amount) { - AppendSKColorFilter(amount, (s, _) => + float[] array = s_colorMatPool.Get(); + try { - float[] array = s_colorMatPool.Get(); - try - { - Graphics.ColorMatrix.CreateSaturateMatrix(s, array); - //M15,M25,M35,M45がゼロなので意味がない - //Graphics.ColorMatrix.ToSkiaColorMatrix(array); + Graphics.ColorMatrix.CreateSaturateMatrix(amount, array); + //M15,M25,M35,M45がゼロなので意味がない + //Graphics.ColorMatrix.ToSkiaColorMatrix(array); - return SKColorFilter.CreateColorMatrix(array); - } - finally - { - s_colorMatPool.Return(array); - } - }); + ShaderColorMatrix(array); + } + finally + { + s_colorMatPool.Return(array); + } } public void HueRotate(float degrees) { - AppendSKColorFilter(degrees, (s, _) => + float[] array = s_colorMatPool.Get(); + try { - float[] array = s_colorMatPool.Get(); - try - { - Graphics.ColorMatrix.CreateHueRotateMatrix(degrees, array); - //M15,M25,M35,M45がゼロなので意味がない - //Graphics.ColorMatrix.ToSkiaColorMatrix(array); + Graphics.ColorMatrix.CreateHueRotateMatrix(degrees, array); + //M15,M25,M35,M45がゼロなので意味がない + //Graphics.ColorMatrix.ToSkiaColorMatrix(array); - return SKColorFilter.CreateColorMatrix(array); - } - finally - { - s_colorMatPool.Return(array); - } - }); + ShaderColorMatrix(array); + } + finally + { + s_colorMatPool.Return(array); + } } public void LuminanceToAlpha() @@ -357,66 +622,68 @@ public void LuminanceToAlpha() public void Brightness(float amount) { - AppendSKColorFilter(amount, (s, _) => + // Recorded as a CurrentPixel shader stage rather than a Skia color filter so that an adjacent shader + // stage can fuse with it instead of splitting the chain at a legacy segment. + float[] array = s_colorMatPool.Get(); + try { - float[] array = s_colorMatPool.Get(); - try - { - Graphics.ColorMatrix.CreateBrightness(amount, array); - //M15,M25,M35,M45がゼロなので意味がない - //Graphics.ColorMatrix.ToSkiaColorMatrix(array); + Graphics.ColorMatrix.CreateBrightness(amount, array); + //M15,M25,M35,M45がゼロなので意味がない + //Graphics.ColorMatrix.ToSkiaColorMatrix(array); - return SKColorFilter.CreateColorMatrix(array); - } - finally - { - s_colorMatPool.Return(array); - } - }); + ShaderColorMatrix(array); + } + finally + { + s_colorMatPool.Return(array); + } } public void HighContrast(bool grayscale, HighContrastInvertStyle invertStyle, float contrast) { - AppendSKColorFilter( - (grayscale, invertStyle, contrast), - (data, _) => SKColorFilter.CreateHighContrast(data.grayscale, - (SKHighContrastConfigInvertStyle)data.invertStyle, data.contrast)); + // SKColorFilter.CreateHighContrast returns null for an invalid configuration, which made the old path a + // no-op. Preserve that behavior instead of recording a shader with undefined parameters. + if (!Enum.IsDefined(invertStyle) || float.IsNaN(contrast) || contrast is < -1f or > 1f) + return; + + Shader(BuiltInColorFilterShader.HighContrast(grayscale, invertStyle, contrast)); } public void Lighting(Color multiply, Color add) { // CreateLightingはsRGBガンマ値でマトリックスを作成するため、 // リニア色空間では不正確。リニアに変換したカラーマトリックスを使用する。 - AppendSKColorFilter( - (multiply, add), - (data, _) => - { - var mulLinear = data.multiply.ToLinear(); - var addLinear = data.add.ToLinear(); + var mulLinear = multiply.ToLinear(); + var addLinear = add.ToLinear(); - float[] array = s_colorMatPool.Get(); - try - { - array.AsSpan().Clear(); - array[0] = mulLinear.X; - array[6] = mulLinear.Y; - array[12] = mulLinear.Z; - array[18] = 1; - array[4] = addLinear.X; - array[9] = addLinear.Y; - array[14] = addLinear.Z; - return SKColorFilter.CreateColorMatrix(array); - } - finally - { - s_colorMatPool.Return(array); - } - }); + float[] array = s_colorMatPool.Get(); + try + { + array.AsSpan().Clear(); + array[0] = mulLinear.X; + array[6] = mulLinear.Y; + array[12] = mulLinear.Z; + array[18] = 1; + array[4] = addLinear.X; + array[9] = addLinear.Y; + array[14] = addLinear.Z; + ShaderColorMatrix(array); + } + finally + { + s_colorMatPool.Return(array); + } } public void LumaColor() { - AppendSKColorFilter(Unit.Default, (_, _) => SKColorFilter.CreateLumaColor()); + Shader(BuiltInColorFilterShader.LumaColor()); + } + + private void ShaderColorMatrix(ReadOnlySpan matrix) + { + if (!Graphics.ColorMatrix.CreateFromSpan(matrix).IsIdentity) + Shader(ColorMatrixShader.CurrentPixel(matrix)); } public void BlendMode(Color color, BlendMode blendMode) @@ -437,11 +704,20 @@ static void ApplyCore((Brush.Resource? Brush, BlendMode BlendMode) data, CustomF { Size size = target.Bounds.Size; EffectTarget newTarget = context.CreateTarget(target.Bounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + continue; + } + // Read density from the target (may be clamped), not context.WorkingScale. float w = newTarget.Scale.Value; - var c = new BrushConstructor(new(size), data.Brush, data.BlendMode, w, context.MaxWorkingScale); using var brushPaint = new SKPaint(); - c.ConfigurePaint(brushPaint); + context.CreateBrushConstructor( + new Rect(size), + data.Brush, + data.BlendMode, + w).ConfigurePaint(brushPaint); using (ImmediateCanvas newCanvas = context.Open(newTarget)) { @@ -468,24 +744,280 @@ public void CustomEffect(T data, Action action, Func transformBounds) where T : IEquatable { - AddItem(new FEItem_CustomEffect(data, action, transformBounds)); - Bounds = transformBounds.Invoke(data, Bounds); + AppendDescription(new FEItem_CustomEffect(data, action, transformBounds)); } + /// + /// Appends an opaque custom effect whose output bounds cannot be determined during recording. + /// + /// + /// The unknown bounds remain symbolic through later effects and are resolved to the complete finite local + /// domain of the owning destination or target scope after enclosing transforms and clips are known. A + /// target-less root request requires an explicit target domain. + /// public void CustomEffect(T data, Action action) { AddItem(new FEItem_CustomEffect(data, action, null)); - Bounds = Rect.Invalid; + _bounds = Rect.Invalid; } public int CountItems() { - return _items.Count; + return _items.Count + _renderTimeItems.Count; + } + + internal IReadOnlyList GetOrderedItems() + { + ThrowIfDisposed(); + return _renderTimeItems.Count == 0 + ? _items.ToArray() + : [.. _items, .. _renderTimeItems]; + } + + internal void ApplyTransactional(FilterEffect effect, FilterEffect.Resource resource) + { + ArgumentNullException.ThrowIfNull(effect); + ArgumentNullException.ThrowIfNull(resource); + ApplyTransactional(() => effect.ApplyTo(this, resource)); + } + + internal void ApplyTransactional(Action apply) + { + ArgumentNullException.ThrowIfNull(apply); + ThrowIfDisposed(); + + int itemCount = _items.Count; + int renderTimeItemCount = _renderTimeItems.Count; + int resourceCount = _resourceState.Count; + Rect bounds = _bounds; + try + { + apply(); + } + catch (Exception ex) + { + ExceptionDispatchInfo primary = ExceptionDispatchInfo.Capture(ex); + while (_items.Count > itemCount) + _items.RemoveAt(_items.Count - 1); + while (_renderTimeItems.Count > renderTimeItemCount) + _renderTimeItems.RemoveAt(_renderTimeItems.Count - 1); + _bounds = bounds; + try + { + _resourceState.RollbackTo(resourceCount, ex); + } + catch (Exception cleanupFailure) + { + const string key = "FilterEffectResourceRollbackFailure"; + ex.Data[key] = ex.Data[key] is Exception previousFailure + ? new AggregateException( + "Multiple filter-effect resource rollback failures occurred.", + previousFailure, + cleanupFailure) + : cleanupFailure; + } + + primary.Throw(); + } + } + + internal void TransferResources() => _resourceState.Transfer(); + + internal void PrepareStandaloneResourcesForExecution() + => _resourceState.CommitStandaloneResources(); + + internal static FilterEffectContext CreateLegacySegment( + Rect bounds, + float outputScale, + float workingScale, + IEnumerable items) + { + ArgumentNullException.ThrowIfNull(items); + var context = new FilterEffectContext(bounds, outputScale, workingScale); + bool hasDeferredBounds = false; + foreach (IFEItem item in items) + { + context.AddItem(item); + if (item is IFEItem_Skia { ResolveBoundsAtExecutionTime: true }) + { + // A deferred-bound item resolves its bounds at execution time; authoring it + // here against the provisional segment input would freeze the wrong matrix. + hasDeferredBounds = true; + continue; + } + + if (!context._bounds.IsInvalid) + context._bounds = item.TransformBounds(context._bounds); + } + + // The segment output is only known after the deferred item resolves at execution time. + if (hasDeferredBounds) + context._bounds = Rect.Invalid; + + return context; } public void Dispose() { + if (_disposed) + return; + + _disposed = true; _items.Dispose(); _renderTimeItems.Dispose(); + _resourceState.ReleaseReference(); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} + +internal sealed class FilterEffectResourceState +{ + private readonly RenderNodeContext? _renderContext; + private readonly RenderRequestResourceRegistry? _standaloneRegistry; + private readonly List _resources = []; + private int _references = 1; + private bool _transferred; + + public FilterEffectResourceState(RenderNodeContext? renderContext) + { + _renderContext = renderContext; + if (renderContext is null) + _standaloneRegistry = new RenderRequestResourceRegistry(); + } + + public int Count => _resources.Count; + + public FilterEffectResourceState AddReference() + { + if (_references <= 0) + throw new ObjectDisposedException(nameof(FilterEffectResourceState)); + _references++; + return this; + } + + public RenderResource Own(T resource) + where T : class, IDisposable + { + ThrowIfTransferred(); + RenderResource token = _renderContext is not null + ? _renderContext.Own(resource) + : _standaloneRegistry!.RegisterOwned(resource); + _resources.Add(token); + return token; + } + + public RenderResource Borrow(T resource) + where T : class + { + ThrowIfTransferred(); + RenderResource token = _renderContext is not null + ? _renderContext.Borrow(resource) + : _standaloneRegistry!.RegisterBorrowed(resource); + _resources.Add(token); + return token; + } + + public void ValidateResources(IEnumerable resources, string parameterName) + { + ArgumentNullException.ThrowIfNull(resources); + foreach (RenderResource resource in resources) + { + if (!_resources.Any(item => ReferenceEquals(item.SlotIdentity, resource.SlotIdentity)) + || resource.RegistrationState == RenderResourceRegistrationState.Released) + { + throw new ArgumentException( + "Every declared resource must be registered by this FilterEffectContext family.", + parameterName); + } + } + } + + public void RollbackTo(int count, Exception? primaryFailure = null) + { + if (count < 0 || count > _resources.Count) + throw new ArgumentOutOfRangeException(nameof(count)); + if (count == _resources.Count) + return; + + RenderResource[] removed = _resources.Skip(count).ToArray(); + _resources.RemoveRange(count, _resources.Count - count); + Rollback(removed, primaryFailure); + } + + private void Rollback(RenderResource[] removed, Exception? primaryFailure) + { + if (_renderContext is not null) + { + if (primaryFailure is null) + _renderContext.RollbackResources(removed); + else + { + Exception? cleanupFailure = + _renderContext.RollbackResourcesAndCapture(removed, primaryFailure); + if (cleanupFailure is not null) + throw cleanupFailure; + } + + return; + } + + List? failures = null; + for (int index = removed.Length - 1; index >= 0; index--) + { + try + { + _standaloneRegistry!.Rollback(removed[index]); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is not null) + throw new AggregateException("Filter-effect resource rollback failed.", failures); + } + + public void Transfer() + { + ThrowIfTransferred(); + _transferred = true; + } + + public void CommitStandaloneResources() + { + if (_standaloneRegistry is null) + return; + + foreach (RenderResource resource in _resources) + { + if (resource.RegistrationState == RenderResourceRegistrationState.Pending) + _standaloneRegistry.Commit(resource); + } + } + + public void ReleaseReference() + { + if (_references <= 0) + return; + _references--; + if (_references != 0) + return; + + if (_standaloneRegistry is not null) + { + _standaloneRegistry.Dispose(); + return; + } + + if (!_transferred) + RollbackTo(0); + } + + private void ThrowIfTransferred() + { + if (_transferred) + throw new InvalidOperationException("Filter-effect resources were already transferred to the render request."); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectGroup.cs b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectGroup.cs index 4ac4d332c1..790ae92c98 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectGroup.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectGroup.cs @@ -17,9 +17,12 @@ public FilterEffectGroup() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - foreach (FilterEffect.Resource item in r.Children) + context.ApplyTransactional(() => { - item.GetOriginal().ApplyTo(context, item); - } + foreach (FilterEffect.Resource item in r.Children) + { + context.ApplyTransactional(item.GetOriginal()!, item); + } + }); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectPresenter.cs b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectPresenter.cs index 8742994de6..213815eb64 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectPresenter.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectPresenter.cs @@ -20,6 +20,7 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource { var r = (Resource)resource; - r.Target?.GetOriginal().ApplyTo(context, r.Target); + if (r.Target is { } target) + context.ApplyTransactional(target.GetOriginal()!, target); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectStageFallbackExecutor.cs b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectStageFallbackExecutor.cs new file mode 100644 index 0000000000..ab2d5b4dc2 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/FilterEffectStageFallbackExecutor.cs @@ -0,0 +1,640 @@ +using Beutl.Graphics.Rendering; +using Beutl.Logging; +using Beutl.Media; +using Microsoft.Extensions.Logging; +using SkiaSharp; + +namespace Beutl.Graphics.Effects; + +internal static class FilterEffectStageFallbackExecutor +{ + private static readonly ILogger s_logger = Log.CreateLogger("FilterEffectStageFallbackExecutor"); + + public static void ApplyShader( + EffectTargets targets, + ShaderDescription description, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + SkRuntimeEffectProgramAcquirer acquireProgram, + RenderTargetLeaseSession? leaseSession) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(acquireProgram); + ReplaceTargets( + targets, + target => ExecuteShader( + target, + description, + outputScale, + workingScale, + maxWorkingScale, + intent, + purpose, + acquireProgram, + leaseSession)); + } + + public static void ApplyGeometry( + EffectTargets targets, + GeometryDescription description, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + RenderTargetLeaseSession? leaseSession) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(description); + ReplaceTargets( + targets, + target => ExecuteGeometry( + target, + description, + outputScale, + workingScale, + maxWorkingScale, + intent, + purpose, + leaseSession)); + } + + private static EffectTarget? ExecuteShader( + EffectTarget source, + ShaderDescription description, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + SkRuntimeEffectProgramAcquirer acquireProgram, + RenderTargetLeaseSession? leaseSession) + { + using EffectTarget? input = NormalizeInput( + source, + workingScale, + maxWorkingScale, + intent, + leaseSession); + if (input?.RenderTarget is not { } inputTarget) + return null; + + Rect outputBounds = description.Bounds.TransformBounds(input.Bounds); + if (IsEmpty(outputBounds)) + return null; + + float density = description.Kind == ShaderDescriptionKind.CurrentPixel + ? input.Scale.Value + : RenderScaleUtilities.ResolveWorkingScale( + [input.Scale], + outputScale, + maxWorkingScale); + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Translate(input.DeviceGridOffset), + density); + EffectTarget? output = AllocateTarget( + outputBounds, + density, + maxWorkingScale, + intent, + leaseSession, + deviceGridOffset: input.DeviceGridOffset); + if (output?.RenderTarget is not { } outputTarget) + { + output?.Dispose(); + return null; + } + + try + { + using SKImage inputImage = inputTarget.Value.Snapshot(); + string childName; + string programSource; + SKShaderTileMode tileMode; + if (description.Kind == ShaderDescriptionKind.CurrentPixel) + { + childName = "__beutl_src"; + tileMode = SKShaderTileMode.Decal; + programSource = $"uniform shader {childName};\n{description.Source.Text}\n" + + $"half4 main(float2 __beutl_coord) {{ return apply({childName}.eval(__beutl_coord)); }}\n"; + } + else + { + childName = "src"; + tileMode = description.SourceTileMode; + programSource = description.Source.Text; + } + + using ProgramCacheLease lease = acquireProgram(output, programSource); + using var uniforms = new SKRuntimeEffectUniforms(lease.Program.Effect); + using var runtimeChildren = new SKRuntimeEffectChildren(lease.Program.Effect); + var children = new List(); + var bindingToken = new RenderExecutionSessionToken(); + try + { + bindingToken.RunAndComplete( + () => + { + var context = new ShaderExecutionContext( + bindingToken, + input.Bounds, + outputBounds, + outputBounds, + output.DeviceBounds, + output.RasterBounds, + input.Scale, + outputScale, + output.Scale.Value, + maxWorkingScale, + intent, + purpose); + foreach (ShaderUniformBinding binding in description.Uniforms) + { + if (!description.Source.Uniforms.TryGetValue( + binding.Name, + out SkslUniformDeclaration declaration)) + { + throw new InvalidOperationException( + $"Shader uniform '{binding.Name}' was not declared."); + } + + SetUniform(uniforms, binding.Name, declaration, binding.Bind(declaration, context)); + } + + SKShader inputShader = RasterShaderMapping.CreateSemanticImageShader( + inputImage, + inputTarget.RawValue.Context, + input.Bounds, + input.Scale.Value, + input.DeviceBounds, + input.RasterBounds, + output.Scale.Value, + output.RasterBounds, + tileMode); + children.Add(inputShader); + runtimeChildren[childName] = inputShader; + + foreach (ShaderResourceBinding binding in description.Resources) + { + SKShader child = binding.Bind(context); + children.Add(child); + runtimeChildren[binding.Name] = child; + } + }); + + using SKShader shader = lease.Program.Effect.ToShader(uniforms, runtimeChildren); + using var paint = new SKPaint { Shader = shader }; + using var canvas = ImmediateCanvas.CreateExecutorManaged( + outputTarget, + output.Scale.Value, + maxWorkingScale, + output.RasterBounds.Size, + intent); + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.Canvas.DrawRect( + SKRect.Create(outputTarget.Width, outputTarget.Height), + paint); + } + } + finally + { + foreach (SKShader child in children.AsEnumerable().Reverse()) + child.Dispose(); + } + + EffectTarget result = output; + output = null; + return result; + } + finally + { + output?.Dispose(); + } + } + + private static EffectTarget? ExecuteGeometry( + EffectTarget source, + GeometryDescription description, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + RenderTargetLeaseSession? leaseSession) + { + using EffectTarget? input = NormalizeInput( + source, + workingScale, + maxWorkingScale, + intent, + leaseSession); + if (input?.RenderTarget is not { } inputTarget) + return null; + + Rect outputBounds = description.Bounds.TransformBounds(input.Bounds); + if (IsEmpty(outputBounds)) + return null; + + float density = RenderScaleUtilities.ResolveWorkingScale( + [input.Scale], + outputScale, + maxWorkingScale); + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Translate(input.DeviceGridOffset), + density); + EffectTarget? output = AllocateTarget( + outputBounds, + density, + maxWorkingScale, + intent, + leaseSession, + deviceGridOffset: input.DeviceGridOffset); + if (output?.RenderTarget is not { } outputTarget) + { + output?.Dispose(); + return null; + } + + try + { + using SKImage inputImage = inputTarget.Value.Snapshot(); + var token = new RenderExecutionSessionToken(); + Rect? selectedBounds = token.RunAndComplete( + () => + { + Func? createSnapshot = description.RequiresReadback + ? inputTarget.Snapshot + : null; + var executionInput = new RenderExecutionInput( + token, + input.Bounds, + input.Scale, + input.DeviceBounds, + input.RasterBounds, + inputImage, + createSnapshot, + description.RequiresReadback); + var callbackCanvas = new RenderCallbackCanvas( + token, + output.Scale.Value, + outputBounds, + output.DeviceBounds, + () => ImmediateCanvas.CreateExecutorManaged( + outputTarget, + output.Scale.Value, + maxWorkingScale, + output.RasterBounds.Size, + intent, + output.DeviceBounds.Position), + CallbackCanvasCapability.Draw, + rasterBounds: output.RasterBounds); + var session = new GeometrySession( + token, + executionInput, + outputBounds, + outputBounds, + output.DeviceBounds, + outputScale, + output.Scale.Value, + maxWorkingScale, + intent, + purpose, + callbackCanvas, + description.Resources); + description.Render(session); + return session.IsOutputDiscarded + ? null + : session.OutputBounds.Intersect(outputBounds); + }); + + if (selectedBounds is not { Width: > 0, Height: > 0 } selected) + return null; + + if (selected == outputBounds) + { + EffectTarget result = output; + output = null; + return result; + } + + return CropTarget(output, selected, maxWorkingScale, intent, leaseSession); + } + finally + { + output?.Dispose(); + } + } + + private static EffectTarget? NormalizeInput( + EffectTarget source, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderTargetLeaseSession? leaseSession) + { + if (source.RenderTarget is not { } sourceTarget) + return null; + + float density = source.Scale.IsUnbounded ? workingScale : source.Scale.Value; + PixelRect semanticDeviceBounds = PixelRect.FromRect( + source.Bounds.Translate(source.DeviceGridOffset), + density); + if (source.RasterBounds + == source.DeviceBounds + .ToRect(density) + .Translate(-source.DeviceGridOffset) + && Contains(source.DeviceBounds, semanticDeviceBounds)) + { + return source.Clone(); + } + + Rect physicalBounds = source.RasterBounds.Union(source.Bounds); + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + physicalBounds.Translate(source.DeviceGridOffset), + density); + PixelRect physicalDeviceBounds = PixelRect.FromRect(physicalBounds, density); + EffectTarget? normalized = AllocateTarget( + source.Bounds, + density, + maxWorkingScale, + intent, + leaseSession, + physicalDeviceBounds, + source.DeviceGridOffset); + if (normalized?.RenderTarget is not { } normalizedTarget) + { + normalized?.Dispose(); + return null; + } + + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + normalized.DeviceBounds, + normalized.DeviceGridOffset, + normalized.Scale.Value); + using var canvas = ImmediateCanvas.CreateExecutorManaged( + normalizedTarget, + normalized.Scale.Value, + maxWorkingScale, + normalized.RasterBounds.Size, + intent); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.DrawRenderTargetScaledWithoutFlush(sourceTarget, source.RasterBounds); + } + + normalized.OriginalBounds = source.Bounds; + return normalized; + } + catch + { + normalized.Dispose(); + throw; + } + } + + private static EffectTarget? CropTarget( + EffectTarget source, + Rect selectedBounds, + float maxWorkingScale, + RenderIntent intent, + RenderTargetLeaseSession? leaseSession) + { + if (source.RenderTarget is not { } sourceTarget) + return null; + + EffectTarget? cropped = AllocateTarget( + selectedBounds, + source.Scale.Value, + maxWorkingScale, + intent, + leaseSession, + deviceGridOffset: source.DeviceGridOffset); + if (cropped?.RenderTarget is not { } croppedTarget) + { + cropped?.Dispose(); + return null; + } + + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + cropped.DeviceBounds, + cropped.DeviceGridOffset, + cropped.Scale.Value); + using var canvas = ImmediateCanvas.CreateExecutorManaged( + croppedTarget, + cropped.Scale.Value, + maxWorkingScale, + cropped.RasterBounds.Size, + intent); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.ClipRect(selectedBounds); + canvas.DrawRenderTargetScaledWithoutFlush(sourceTarget, source.RasterBounds); + } + + return cropped; + } + catch + { + cropped.Dispose(); + throw; + } + } + + private static EffectTarget? AllocateTarget( + Rect bounds, + float density, + float maxWorkingScale, + RenderIntent intent, + RenderTargetLeaseSession? leaseSession, + PixelRect? physicalDeviceBounds = null, + Vector deviceGridOffset = default) + { + if (IsEmpty(bounds)) + return null; + + if (physicalDeviceBounds is null) + { + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + bounds.Translate(deviceGridOffset), + density); + } + PixelRect semanticDeviceBounds = PixelRect.FromRect( + bounds.Translate(deviceGridOffset), + density); + PixelRect deviceBounds; + if (physicalDeviceBounds is not { } requestedPhysicalBounds) + { + deviceBounds = semanticDeviceBounds; + } + else if (deviceGridOffset == default) + { + deviceBounds = requestedPhysicalBounds; + } + else + { + PixelRect localSemanticBounds = PixelRect.FromRect(bounds, density); + int leftApron = localSemanticBounds.X - requestedPhysicalBounds.X; + int topApron = localSemanticBounds.Y - requestedPhysicalBounds.Y; + int rightApron = requestedPhysicalBounds.Right - localSemanticBounds.Right; + int bottomApron = requestedPhysicalBounds.Bottom - localSemanticBounds.Bottom; + deviceBounds = new PixelRect( + semanticDeviceBounds.X - leftApron, + semanticDeviceBounds.Y - topApron, + semanticDeviceBounds.Width + leftApron + rightApron, + semanticDeviceBounds.Height + topApron + bottomApron); + } + EffectTarget? result = Allocate( + leaseSession, + bounds, + density, + deviceBounds, + deviceGridOffset); + if (result is null) + { + string message = + $"Legacy typed-effect target allocation failed ({deviceBounds.Width}x{deviceBounds.Height} px, " + + $"w {density}, bounds {bounds})."; + s_logger.LogWarning( + "{Message} Preview drops this target; delivery render fails fast.", + message); + if (intent == RenderIntent.Delivery) + throw new InvalidOperationException(message); + leaseSession?.MarkContentDropped(); + return null; + } + + try + { + using var canvas = ImmediateCanvas.CreateExecutorManaged( + result.RenderTarget!, + density, + maxWorkingScale, + result.RasterBounds.Size, + intent, + result.DeviceBounds.Position); + canvas.Clear(); + return result; + } + catch + { + result.Dispose(); + throw; + } + } + + /// + /// Allocates one legacy stage target, through the caller's lease session when there is one. + /// + /// + /// A configured is reachable only through the session, and its targets + /// may come from a context the global allocator knows nothing about. Going around it here would both ignore + /// the caller's allocation policy and mix surfaces from two contexts inside one stage. + /// + private static EffectTarget? Allocate( + RenderTargetLeaseSession? leaseSession, + Rect bounds, + float density, + PixelRect deviceBounds, + Vector deviceGridOffset) + { + if (leaseSession is { HasTargetFactory: true }) + { + RenderTargetLease? lease = leaseSession.TryAcquire(deviceBounds.Size); + if (lease is null) + return null; + + try + { + return EffectTarget.FromLease( + lease, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset); + } + catch + { + lease.Dispose(); + throw; + } + } + + using RenderTarget? renderTarget = RenderTarget.Create(deviceBounds.Width, deviceBounds.Height); + return renderTarget is null + ? null + : new EffectTarget( + renderTarget, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset); + } + + private static void ReplaceTargets( + EffectTargets targets, + Func execute) + { + using var replacements = new EffectTargets(); + foreach (EffectTarget target in targets) + { + EffectTarget? replacement = execute(target); + if (replacement is not null) + replacements.Add(replacement); + } + + foreach (EffectTarget target in targets) + target.Dispose(); + targets.Clear(); + while (replacements.Count > 0) + { + EffectTarget replacement = replacements[0]; + replacements.RemoveAt(0); + targets.Add(replacement); + } + } + + private static void SetUniform( + SKRuntimeEffectUniforms uniforms, + string name, + SkslUniformDeclaration declaration, + ShaderUniformValue value) + { + if (value.IsInteger) + { + uniforms[name] = declaration.ArrayExtent is null + && declaration.Type is "int" or "bool" + ? value.Integers![0] + : value.Integers!; + } + else + { + uniforms[name] = declaration.ArrayExtent is null + && declaration.Type is "float" or "half" + ? value.Floats![0] + : value.Floats!; + } + } + + private static bool Contains(PixelRect outer, PixelRect inner) + => outer.X <= inner.X + && outer.Y <= inner.Y + && outer.Right >= inner.Right + && outer.Bottom >= inner.Bottom; + + private static bool IsEmpty(Rect bounds) + => bounds.Width == 0 || bounds.Height == 0; +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/FlatShadow.cs b/src/Beutl.Engine/Graphics/FilterEffects/FlatShadow.cs index 1ee906080d..6cf1fa5a43 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/FlatShadow.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/FlatShadow.cs @@ -81,7 +81,7 @@ static SKPath CreatePath(Bitmap src) for (int ii = 0; ii < context.Targets.Count; ii++) { var target = context.Targets[ii]; - using var srcBitmap = target.RenderTarget!.Snapshot(); + using Bitmap srcBitmap = target.RenderTarget!.SnapshotAlpha(); float x1 = MathF.Cos(radian); float y1 = MathF.Sin(radian); @@ -97,6 +97,12 @@ static SKPath CreatePath(Bitmap src) target.Bounds.Y - (y2Abs - y2) / 2, (size.Width + x2Abs), (size.Height + y2Abs))); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + continue; + } + using (var paint = new SKPaint { Color = SKColors.White, IsAntialias = true, Style = SKPaintStyle.Fill }) using (var brushPaint = new SKPaint()) using (SKPath path = CreatePath(srcBitmap)) @@ -122,9 +128,11 @@ static SKPath CreatePath(Bitmap src) } // SrcIn brush at the buffer's real density (wOut). - var c = new BrushConstructor(new(newTarget.Bounds.Size), brush, BlendMode.SrcIn, wOut, - context.MaxWorkingScale); - c.ConfigurePaint(brushPaint); + context.CreateBrushConstructor( + new Rect(newTarget.Bounds.Size), + brush, + BlendMode.SrcIn, + wOut).ConfigurePaint(brushPaint); newCanvas.Canvas.DrawRect(SKRect.Create(newTarget.Bounds.Width, newTarget.Bounds.Height), brushPaint); if (!data.ShadowOnly) diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GLSLFilterPipeline.cs b/src/Beutl.Engine/Graphics/FilterEffects/GLSLFilterPipeline.cs index 2949d0b082..41b4e16adc 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/GLSLFilterPipeline.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/GLSLFilterPipeline.cs @@ -1,5 +1,8 @@ -using System.Runtime.InteropServices; +using System.Collections.Immutable; +using System.Runtime.InteropServices; using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Composite; +using Beutl.Graphics.Backend.Vulkan; using Beutl.Logging; using Microsoft.Extensions.Logging; @@ -41,12 +44,15 @@ void main() { private readonly ISampler _sampler; private readonly byte[] _vertexShaderSpirv; private readonly byte[] _fragmentShaderSpirv; + private readonly ShaderOutputCoverage _outputCoverage; private bool _disposed; private readonly bool _hasMaskTexture; internal bool HasMaskTexture => _hasMaskTexture; + internal long RetainedByteSize => Math.Max(1, _vertexShaderSpirv.Length + _fragmentShaderSpirv.Length); + private GLSLFilterPipeline( IGraphicsContext context, IRenderPass3D renderPass, @@ -54,6 +60,7 @@ private GLSLFilterPipeline( ISampler sampler, byte[] vertexShaderSpirv, byte[] fragmentShaderSpirv, + ShaderOutputCoverage outputCoverage, bool hasMaskTexture = false) { _context = context; @@ -62,10 +69,30 @@ private GLSLFilterPipeline( _sampler = sampler; _vertexShaderSpirv = vertexShaderSpirv; _fragmentShaderSpirv = fragmentShaderSpirv; + _outputCoverage = outputCoverage; _hasMaskTexture = hasMaskTexture; } - public static GLSLFilterPipeline? Create(IGraphicsContext context, string fragmentShaderSource, bool hasMaskTexture = false) + /// + /// Compiles a fragment shader and creates its fullscreen filter pipeline. + /// + /// The graphics context that owns the pipeline. + /// The GLSL fragment shader source. + /// + /// The proven fragment-output coverage contract. + /// transparently initializes the destination before the pass and clears the render-pass attachment. + /// may be selected only for an engine-owned shader whose + /// every control-flow path writes the fragment output and which never uses discard; a false claim can + /// expose stale pixels from an unrelated frame when a pooled target is reused. + /// + /// Immutable values applied when the pipeline is created. + /// Whether the shader reads a second texture at binding 1. + public static GLSLFilterPipeline? Create( + IGraphicsContext context, + string fragmentShaderSource, + ShaderOutputCoverage outputCoverage, + ImmutableArray specializationConstants = default, + bool hasMaskTexture = false) { if (!context.Supports3DRendering) { @@ -83,12 +110,13 @@ private GLSLFilterPipeline( // Compile fragment shader byte[] fragmentShaderSpirv = compiler.CompileToSpirv(fragmentShaderSource, ShaderStage.Fragment); - // Create render pass for BGRA8 format (matching RenderTarget format) + // Create a color-only render pass matching the RenderTarget format. IRenderPass3D renderPass = context.CreateRenderPass3D( [TextureFormat.RGBA16Float], - TextureFormat.Depth32Float, - AttachmentLoadOp.DontCare, - AttachmentLoadOp.DontCare); + depthFormat: null, + colorLoadOp: outputCoverage == ShaderOutputCoverage.ProvablyFull + ? AttachmentLoadOp.DontCare + : AttachmentLoadOp.Clear); // Create sampler ISampler sampler = context.CreateSampler( @@ -106,13 +134,15 @@ private GLSLFilterPipeline( : [new(0, DescriptorType.CombinedImageSampler, 1, ShaderStage.Fragment)]; // Create pipeline with fullscreen options + PipelineOptions pipelineOptions = PipelineOptions.Fullscreen; + pipelineOptions.SpecializationConstants = specializationConstants; IPipeline3D pipeline = context.CreatePipeline3D( renderPass, vertexShaderSpirv, fragmentShaderSpirv, descriptorBindings, VertexInputDescription.Empty, - PipelineOptions.Fullscreen); + pipelineOptions); return new GLSLFilterPipeline( context, @@ -121,6 +151,7 @@ private GLSLFilterPipeline( sampler, vertexShaderSpirv, fragmentShaderSpirv, + outputCoverage, hasMaskTexture); } catch (Exception ex) @@ -133,7 +164,6 @@ private GLSLFilterPipeline( public void Execute( ITexture2D sourceTexture, ITexture2D destinationTexture, - ITexture2D depthTexture, T pushConstants) where T : unmanaged { ObjectDisposedException.ThrowIf(_disposed, this); @@ -143,13 +173,13 @@ public void Execute( // Prepare textures for their respective operations sourceTexture.PrepareForSampling(); - destinationTexture.PrepareForRender(); + PrepareDestination(destinationTexture); // Create framebuffer using IFramebuffer3D framebuffer = _context.CreateFramebuffer3D( _renderPass, [destinationTexture], - depthTexture); + depthTexture: null); // Create descriptor set and bind source texture using IDescriptorSet descriptorSet = _context.CreateDescriptorSet( @@ -157,13 +187,20 @@ public void Execute( [new DescriptorPoolSize(DescriptorType.CombinedImageSampler, 1)]); descriptorSet.UpdateTexture(0, sourceTexture, _sampler); - // Execute render pass - _renderPass.Begin(framebuffer, [default], 1.0f); - _renderPass.BindPipeline(_pipeline); - _renderPass.BindDescriptorSet(_pipeline, descriptorSet); - _renderPass.SetPushConstants(pushConstants, ShaderStage.Fragment); - _renderPass.Draw(3); // Fullscreen triangle - _renderPass.End(); + // Execute render pass. The pass holds a render-pass scope on the context-wide recording batch, + // so a body that throws has to release it or every later transfer in the process is diverted. + _renderPass.Begin(framebuffer, [default]); + try + { + _renderPass.BindPipeline(_pipeline); + _renderPass.BindDescriptorSet(_pipeline, descriptorSet); + _renderPass.SetPushConstants(pushConstants); + _renderPass.Draw(3); // Fullscreen triangle + } + finally + { + _renderPass.End(); + } // Prepare destination for sampling (next stage) destinationTexture.PrepareForSampling(); @@ -174,7 +211,6 @@ public void Execute( ITexture2D sourceTexture, ITexture2D maskTexture, ITexture2D destinationTexture, - ITexture2D depthTexture, T pushConstants) where T : unmanaged { ObjectDisposedException.ThrowIf(_disposed, this); @@ -185,13 +221,13 @@ public void Execute( // Prepare textures for their respective operations sourceTexture.PrepareForSampling(); maskTexture.PrepareForSampling(); - destinationTexture.PrepareForRender(); + PrepareDestination(destinationTexture); // Create framebuffer using IFramebuffer3D framebuffer = _context.CreateFramebuffer3D( _renderPass, [destinationTexture], - depthTexture); + depthTexture: null); // Create descriptor set and bind both textures using IDescriptorSet descriptorSet = _context.CreateDescriptorSet( @@ -200,18 +236,54 @@ public void Execute( descriptorSet.UpdateTexture(0, sourceTexture, _sampler); descriptorSet.UpdateTexture(1, maskTexture, _sampler); - // Execute render pass - _renderPass.Begin(framebuffer, [default], 1.0f); - _renderPass.BindPipeline(_pipeline); - _renderPass.BindDescriptorSet(_pipeline, descriptorSet); - _renderPass.SetPushConstants(pushConstants, ShaderStage.Fragment); - _renderPass.Draw(3); // Fullscreen triangle - _renderPass.End(); + // Execute render pass. The pass holds a render-pass scope on the context-wide recording batch, + // so a body that throws has to release it or every later transfer in the process is diverted. + _renderPass.Begin(framebuffer, [default]); + try + { + _renderPass.BindPipeline(_pipeline); + _renderPass.BindDescriptorSet(_pipeline, descriptorSet); + _renderPass.SetPushConstants(pushConstants); + _renderPass.Draw(3); // Fullscreen triangle + } + finally + { + _renderPass.End(); + } // Prepare destination for sampling (next stage) destinationTexture.PrepareForSampling(); } + private void PrepareDestination(ITexture2D destinationTexture) + { + if (_outputCoverage == ShaderOutputCoverage.MayLeavePixelsUnwritten) + { + if (destinationTexture is not ITransparentClearableTexture clearableTexture) + { + throw new InvalidOperationException( + "A conservative native shader requires an ordered transparent-clear texture."); + } + + clearableTexture.ClearToTransparent(); + } + + destinationTexture.PrepareForRender(); + } + + internal void SubmitPendingCommands() + { + // A subsequent effect may consume this output through another backend immediately. Submit the recorded + // clears and draws so their queue order is established before the caller releases the source target. + VulkanContext context = _context switch + { + VulkanContext vulkan => vulkan, + CompositeContext composite => composite.Vulkan, + _ => throw new InvalidOperationException("The GLSL pipeline requires a Vulkan recording context."), + }; + context.FlushCommands(waitForCompletion: false); + } + public void Dispose() { if (_disposed) return; @@ -222,3 +294,30 @@ public void Dispose() _disposed = true; } } + +/// +/// Declares whether a fragment shader is proven to write every output pixel, allowing a filter render pass to +/// load a transparently initialized destination or discard its previous contents safely. +/// +/// +/// Only engine-owned built-in shaders may claim , and only after proving that every +/// control-flow path writes the fragment output and that the shader contains no discard. A false claim +/// leaves unwritten pixels unchanged, so a reused pooled target can reveal stale pixels from a previous, +/// unrelated frame; this failure may not reproduce while the pool is cold. +/// +internal enum ShaderOutputCoverage : byte +{ + /// + /// The shader may leave fragments unwritten, so the destination is transparently initialized and the + /// render-pass attachment is cleared before drawing. Public or user-authored shaders always use this + /// conservative contract. + /// + MayLeavePixelsUnwritten, + + /// + /// Every control-flow path is proven to write the fragment output and no path uses discard. Only an + /// audited engine-owned built-in shader may claim this; an incorrect claim exposes stale pooled-target pixels + /// from an unrelated frame and can remain hidden until a warm-pool reuse. + /// + ProvablyFull, +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GLSLScriptEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/GLSLScriptEffect.cs index 31e9adfbbc..da95a3340e 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/GLSLScriptEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/GLSLScriptEffect.cs @@ -92,8 +92,11 @@ private static void OnApplyTo( // Push constants report device px at the clamped buffer density. data.shader.Apply(c, target => { - float w = c.ResolveTargetDensity(target.Bounds); - (int devW, int devH) = CustomFilterEffectContext.DeviceBufferSize(target.Bounds, w); + float w = target.Scale.IsUnbounded ? c.ResolveTargetDensity(target.Bounds) : target.Scale.Value; + int devW = target.RenderTarget?.Width + ?? CustomFilterEffectContext.DeviceBufferSize(target.Bounds, w).Width; + int devH = target.RenderTarget?.Height + ?? CustomFilterEffectContext.DeviceBufferSize(target.Bounds, w).Height; return new PushConstants { Progress = data.progress, diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GLSLShader.cs b/src/Beutl.Engine/Graphics/FilterEffects/GLSLShader.cs index 88916727c0..6387ecdda8 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/GLSLShader.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/GLSLShader.cs @@ -1,4 +1,5 @@ -using Beutl.Graphics.Backend; +using System.Collections.Immutable; +using Beutl.Graphics.Backend; using Beutl.Graphics.Rendering; namespace Beutl.Graphics.Effects; @@ -21,7 +22,10 @@ public static GLSLShader Create(string fragmentShaderSource) throw new InvalidOperationException("Vulkan 3D rendering is not supported on this platform."); } - GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create(context, fragmentShaderSource); + GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create( + context, + fragmentShaderSource, + ShaderOutputCoverage.MayLeavePixelsUnwritten); if (pipeline == null) { throw new InvalidOperationException("Failed to compile GLSL shader."); @@ -39,7 +43,11 @@ public static GLSLShader CreateDualTexture(string fragmentShaderSource) throw new InvalidOperationException("Vulkan 3D rendering is not supported on this platform."); } - GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create(context, fragmentShaderSource, hasMaskTexture: true); + GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create( + context, + fragmentShaderSource, + ShaderOutputCoverage.MayLeavePixelsUnwritten, + hasMaskTexture: true); if (pipeline == null) { throw new InvalidOperationException("Failed to compile GLSL dual-texture shader."); @@ -68,7 +76,10 @@ public static bool TryCreate(string fragmentShaderSource, out GLSLShader? shader try { - GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create(context, fragmentShaderSource); + GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create( + context, + fragmentShaderSource, + ShaderOutputCoverage.MayLeavePixelsUnwritten); if (pipeline == null) { errorText = "Failed to compile GLSL shader."; @@ -85,6 +96,41 @@ public static bool TryCreate(string fragmentShaderSource, out GLSLShader? shader } } + /// + /// Creates an engine-owned shader whose output is allowed to skip pooled-target initialization. + /// + /// + /// An audited built-in fragment shader. Every control-flow path must write the fragment output and the shader + /// must contain no discard. If this proof is false, unwritten pixels can expose stale data from a + /// previous unrelated frame when a pooled target is warm. + /// + /// Immutable values fixed for the lifetime of the created pipeline. + /// Whether the shader reads a second texture at binding 1. + internal static GLSLShader CreateBuiltIn( + string fragmentShaderSource, + ImmutableArray specializationConstants = default, + bool hasMaskTexture = false) + { + IGraphicsContext? context = GraphicsContextFactory.SharedContext; + if (context == null || !context.Supports3DRendering) + { + throw new InvalidOperationException("Vulkan 3D rendering is not supported on this platform."); + } + + GLSLFilterPipeline? pipeline = GLSLFilterPipeline.Create( + context, + fragmentShaderSource, + ShaderOutputCoverage.ProvablyFull, + specializationConstants, + hasMaskTexture); + if (pipeline == null) + { + throw new InvalidOperationException("Failed to compile built-in GLSL shader."); + } + + return new GLSLShader(pipeline); + } + internal GLSLFilterPipeline Pipeline { get @@ -117,9 +163,9 @@ public void Apply(CustomFilterEffectContext context, T pushConstants) where T if (sourceTexture == null) continue; - renderTarget.PrepareForSampling(); + renderTarget.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); - EffectTarget newTarget = context.CreateTarget(target.Bounds); + EffectTarget newTarget = context.CreateNativeTargetLike(target); RenderTarget? newRenderTarget = newTarget.RenderTarget; if (newRenderTarget?.Texture == null) @@ -131,12 +177,8 @@ public void Apply(CustomFilterEffectContext context, T pushConstants) where T ITexture2D destinationTexture = newRenderTarget.Texture; try { - using ITexture2D depthTexture = graphicsContext.CreateTexture2D( - destinationTexture.Width, - destinationTexture.Height, - TextureFormat.Depth32Float); - - _pipeline.Execute(sourceTexture, destinationTexture, depthTexture, pushConstants); + _pipeline.Execute(sourceTexture, destinationTexture, pushConstants); + _pipeline.SubmitPendingCommands(); target.Dispose(); context.Targets[i] = newTarget; @@ -153,11 +195,10 @@ public void Apply(CustomFilterEffectContext context, T pushConstants) where T internal void ExecuteSingleTarget( ITexture2D source, ITexture2D destination, - ITexture2D depth, T pushConstants) where T : unmanaged { ObjectDisposedException.ThrowIf(_disposed, this); - _pipeline.Execute(source, destination, depth, pushConstants); + _pipeline.Execute(source, destination, pushConstants); } // Execute a single pass with mask texture (for use by multi-pass effects) @@ -165,11 +206,16 @@ internal void ExecuteSingleTargetWithMask( ITexture2D source, ITexture2D mask, ITexture2D destination, - ITexture2D depth, T pushConstants) where T : unmanaged { ObjectDisposedException.ThrowIf(_disposed, this); - _pipeline.Execute(source, mask, destination, depth, pushConstants); + _pipeline.Execute(source, mask, destination, pushConstants); + } + + internal void SubmitPendingCommands() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _pipeline.SubmitPendingCommands(); } // Multi-pass apply with ping-pong intermediate textures @@ -196,21 +242,18 @@ public void ApplyMultiPass( if (sourceTexture == null) continue; + renderTarget.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + int width = sourceTexture.Width; int height = sourceTexture.Height; - // Create ping-pong textures - using ITexture2D pingTexture = graphicsContext.CreateTexture2D(width, height, TextureFormat.RGBA16Float); - using ITexture2D pongTexture = graphicsContext.CreateTexture2D(width, height, TextureFormat.RGBA16Float); - using ITexture2D depthTexture = graphicsContext.CreateTexture2D(width, height, TextureFormat.Depth32Float); - // Run first shader pass (pass 0) from source into ping buffer as the initial state sourceTexture.PrepareForSampling(); if (passCount == 1) { // Single pass: write directly to the new EffectTarget - EffectTarget newTarget = context.CreateTarget(target.Bounds); + EffectTarget newTarget = context.CreateNativeTargetLike(target); RenderTarget? newRenderTarget = newTarget.RenderTarget; if (newRenderTarget?.Texture == null) @@ -221,7 +264,8 @@ public void ApplyMultiPass( try { - _pipeline.Execute(sourceTexture, newRenderTarget.Texture, depthTexture, createPushConstants(0, target)); + _pipeline.Execute(sourceTexture, newRenderTarget.Texture, createPushConstants(0, target)); + _pipeline.SubmitPendingCommands(); target.Dispose(); context.Targets[i] = newTarget; @@ -235,7 +279,18 @@ public void ApplyMultiPass( continue; } - _pipeline.Execute(sourceTexture, pingTexture, depthTexture, createPushConstants(0, target)); + using NativeFilterTextureLease pingLease = context.AcquireNativeScratchTexture( + graphicsContext, + width, + height); + using NativeFilterTextureLease pongLease = context.AcquireNativeScratchTexture( + graphicsContext, + width, + height); + ITexture2D pingTexture = pingLease.Texture; + ITexture2D pongTexture = pongLease.Texture; + + _pipeline.Execute(sourceTexture, pingTexture, createPushConstants(0, target)); ITexture2D current = pingTexture; ITexture2D next = pongTexture; @@ -243,13 +298,13 @@ public void ApplyMultiPass( // Run intermediate passes with ping-pong (passes 1 to passCount-2) for (int pass = 1; pass < passCount - 1; pass++) { - _pipeline.Execute(current, next, depthTexture, createPushConstants(pass, target)); + _pipeline.Execute(current, next, createPushConstants(pass, target)); (current, next) = (next, current); } // Final pass: write directly to the new EffectTarget { - EffectTarget newTarget = context.CreateTarget(target.Bounds); + EffectTarget newTarget = context.CreateNativeTargetLike(target); RenderTarget? newRenderTarget = newTarget.RenderTarget; if (newRenderTarget?.Texture == null) @@ -260,7 +315,8 @@ public void ApplyMultiPass( try { - _pipeline.Execute(current, newRenderTarget.Texture, depthTexture, createPushConstants(passCount - 1, target)); + _pipeline.Execute(current, newRenderTarget.Texture, createPushConstants(passCount - 1, target)); + _pipeline.SubmitPendingCommands(); target.Dispose(); context.Targets[i] = newTarget; @@ -296,9 +352,9 @@ public void Apply( if (sourceTexture == null) continue; - renderTarget.PrepareForSampling(); + renderTarget.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); - EffectTarget newTarget = context.CreateTarget(target.Bounds); + EffectTarget newTarget = context.CreateNativeTargetLike(target); RenderTarget? newRenderTarget = newTarget.RenderTarget; if (newRenderTarget?.Texture == null) @@ -311,13 +367,9 @@ public void Apply( try { - using ITexture2D depthTexture = graphicsContext.CreateTexture2D( - destinationTexture.Width, - destinationTexture.Height, - TextureFormat.Depth32Float); - T pushConstants = createPushConstants(target); - _pipeline.Execute(sourceTexture, destinationTexture, depthTexture, pushConstants); + _pipeline.Execute(sourceTexture, destinationTexture, pushConstants); + _pipeline.SubmitPendingCommands(); target.Dispose(); context.Targets[i] = newTarget; diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Gamma.cs b/src/Beutl.Engine/Graphics/FilterEffects/Gamma.cs index 233966dab9..51b207c773 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Gamma.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Gamma.cs @@ -1,46 +1,36 @@ using System.ComponentModel.DataAnnotations; -using System.Reactive; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.Gamma), ResourceType = typeof(GraphicsStrings))] public sealed partial class Gamma : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static Gamma() - { - string sksl = - """ - uniform shader src; - uniform float gamma; - uniform float strength; - - half4 main(float2 coord) { - half4 c = src.eval(coord); - float alpha = c.a; - if (alpha <= 0.0001) return half4(0.0); - float3 rgb = c.rgb / alpha; - - float3 corrected = pow(max(rgb, float3(0.0)), float3(1.0 / gamma)); - float3 result = mix(rgb, corrected, strength); - - return half4(half3(result * alpha), half(alpha)); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile gamma shader: {ErrorText}", errorText); + private const string ShaderSource = + """ + uniform float gamma; + uniform float strength; + const float HALF_MAX = 65504.0; + + half4 apply(half4 color) { + float alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + float3 rgb = color.rgb / alpha; + + float3 corrected = min( + pow(max(rgb, float3(0.0)), float3(1.0 / gamma)), + float3(HALF_MAX)); + float3 result = mix(rgb, corrected, strength); + + float3 boundedResult = clamp(result * alpha, float3(-HALF_MAX), float3(HALF_MAX)); + return half4(half3(boundedResult), half(alpha)); } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public Gamma() { @@ -57,39 +47,13 @@ public Gamma() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - var r = (Resource)resource; - context.CustomEffect( - (r, Unit.Default), - (t, c) => OnApply(t.r, c), - static (_, rect) => rect); - } - - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - float gamma = Math.Clamp(data.Amount / 100f, 0.01f, 3f); - float strength = data.Strength / 100f; - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["gamma"] = gamma; - builder.Uniforms["strength"] = strength; - - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform("gamma", Math.Clamp(r.Amount / 100f, 0.01f, 3f)); + bindings.Uniform("strength", r.Strength / 100f); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GeometryDefinitionCalls.cs b/src/Beutl.Engine/Graphics/FilterEffects/GeometryDefinitionCalls.cs new file mode 100644 index 0000000000..0d8951561d --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/GeometryDefinitionCalls.cs @@ -0,0 +1,107 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.Graphics.Effects; + +/// Defines the fixed shape of a deferred geometry operation. +/// The per-recording state supplied by a . +public sealed class GeometryDefinition + where TState : notnull +{ + private readonly Action _render; + private readonly RenderBoundsContract _bounds; + private readonly RenderHitTestContract _hitTest; + private readonly bool _requiresReadback; + private readonly RenderInputDemandContract _inputDemand; + private readonly IReadOnlyList _resourceSlots; + + private GeometryDefinition( + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback, + RenderInputDemandContract inputDemand, + IReadOnlyList resourceSlots) + { + _render = render; + _bounds = bounds; + _hitTest = hitTest; + _requiresReadback = requiresReadback; + _inputDemand = inputDemand; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable deferred geometry definition. + /// + /// The fixed mapping from this operation's resolved output demand to the demand it places on its input. + /// An operation that enlarges what it draws must declare it; the default leaves demand unchanged, which + /// is only correct for one that draws its input at the density its own consumer asked for. + /// + public static GeometryDefinition Create( + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback = false, + IEnumerable? resources = null, + RenderInputDemandContract inputDemand = default) + { + ArgumentNullException.ThrowIfNull(render); + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + return new GeometryDefinition( + render, + bounds, + hitTest, + requiresReadback, + inputDemand, + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources))); + } + + /// Binds this operation shape to the state and resources for one recording. + public GeometryCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal GeometryDescription CreateDescription( + TState state, + IEnumerable? bindings) + => GeometryDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _render, + nameof(state), + nameof(_render)), + _bounds, + _hitTest, + definitionFingerprint: _render.Method, + requiresReadback: _requiresReadback, + inputDemand: _inputDemand, + resources: RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings))); +} + +/// Binds one geometry definition to one recording's state and resource tokens. +public sealed class GeometryCall + where TState : notnull +{ + internal GeometryCall( + GeometryDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public GeometryDefinition Definition { get; } + + /// Gets the state supplied for this recording. + public TState State { get; } + + internal GeometryDescription Description { get; } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GeometryDescription.cs b/src/Beutl.Engine/Graphics/FilterEffects/GeometryDescription.cs new file mode 100644 index 0000000000..00a8c5d885 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/GeometryDescription.cs @@ -0,0 +1,207 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.Graphics.Effects; + +/// Declares an immutable deferred geometry transformation recorded into a render graph. +/// +/// Geometry is an order-preserving zero-or-one map over each input value and is a materialization boundary. +/// The renderer derives plan shape from the callback and declared contracts. The render callback receives a +/// borrowed execution-scoped that must not be retained. +/// +internal sealed class GeometryDescription +{ + private readonly RenderExecutionChannel _execution; + + private GeometryDescription( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + object definitionFingerprint, + bool requiresReadback, + RenderInputDemandContract inputDemand, + IReadOnlyList resources) + { + _execution = execution; + Bounds = bounds; + HitTest = hitTest; + DefinitionFingerprint = definitionFingerprint; + RequiresReadback = requiresReadback; + InputDemand = inputDemand; + Resources = resources; + StructuralIdentity = new GeometryStructuralIdentity( + definitionFingerprint, + bounds.StructuralIdentity, + hitTest.StructuralIdentity, + requiresReadback, + inputDemand.StructuralIdentity, + resources.Select(static binding => binding.Slot.ValueType).ToArray()); + } + + /// Gets the pure mapping from complete input bounds to conservative complete output bounds. + public RenderBoundsContract Bounds { get; } + + /// Gets the mapping from this operation's output demand to the demand it places on its input. + /// + /// A geometry operation that enlarges what it draws has to declare it, or the source it draws is + /// rasterized at the density the consumer asked for and then stretched. + /// + public RenderInputDemandContract InputDemand { get; } + + /// Gets the CPU-only hit-test contract for the conservative produced geometry. + public RenderHitTestContract HitTest { get; } + + internal object DefinitionFingerprint { get; } + + /// Gets whether the callback is permitted to request declared input readback. + public bool RequiresReadback { get; } + + /// Gets the non-null immutable list of non-null resources declared for the deferred callback. + /// + /// Every resource must belong to the active request family when this description is recorded through + /// . + /// + public IReadOnlyList Resources { get; } + + internal void Render(GeometrySession session) => _execution.Invoke(session); + + internal object StructuralIdentity { get; } + + /// Creates an immutable deferred geometry description. + /// + /// Every pixel-affecting value the callback reads. It belongs in the call state; when it changes, the owning + /// node reports the change through . + /// + /// + /// A non-capturing callback invoked only during execution. Declare it : a capture + /// would let a per-frame value shape the geometry without reaching , and is + /// rejected. The borrowed session and facades are valid only for that invocation and must not be retained. + /// + /// An initialized pure input-to-output bounds contract. + /// An initialized pure CPU output hit-test contract. + /// Whether the callback may request declared readback of its input. + /// + /// An optional sequence of non-null declared resources. means no resources; otherwise + /// the sequence is copied immediately and no caller collection is retained. + /// + /// An immutable deferred geometry description. + /// + /// or is . + /// + /// + /// A contract is uninitialized, captures, or contains + /// a null or released resource. + /// + internal static GeometryDescription Create( + TState state, + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback = false, + RenderInputDemandContract inputDemand = default, + IEnumerable? resources = null) + where TState : notnull + => CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + render, + nameof(state), + nameof(render)), + bounds, + hitTest, + render.Method, + requiresReadback, + inputDemand, + resources); + + /// + /// Creates a geometry description whose value can never satisfy a later request's cache lookup. + /// + /// + /// The opt-out for a callback whose pixel-affecting state cannot be expressed as a lightweight immutable + /// key. The callback may capture, and the recorded value takes a fresh request-local identity every time. + /// + internal static GeometryDescription CreateRequestLocal( + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback = false, + RenderInputDemandContract inputDemand = default, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(render, nameof(render)), + bounds, + hitTest, + render.Method, + requiresReadback, + inputDemand, + resources); + + internal static GeometryDescription CreateCore( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + object definitionFingerprint, + bool requiresReadback, + RenderInputDemandContract inputDemand, + IEnumerable? resources) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + ArgumentNullException.ThrowIfNull(definitionFingerprint); + IReadOnlyList resourceCopy = RenderDescriptionValidation.CopyResourceBindings( + resources, + nameof(resources)); + + return new GeometryDescription( + execution, + bounds, + hitTest, + definitionFingerprint, + requiresReadback, + inputDemand, + resourceCopy); + } +} + +internal sealed class GeometryStructuralIdentity( + object key, + object bounds, + object hitTest, + bool requiresReadback, + object inputDemand, + Type[] resourceTypes) + : IEquatable +{ + public bool Equals(GeometryStructuralIdentity? other) + => other is not null + && Equals(key, other.Key) + && Equals(bounds, other.Bounds) + && Equals(hitTest, other.HitTest) + && requiresReadback == other.RequiresReadback + && Equals(inputDemand, other.InputDemand) + && resourceTypes.AsSpan().SequenceEqual(other.ResourceTypes); + + public override bool Equals(object? obj) => obj is GeometryStructuralIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(key); + hash.Add(bounds); + hash.Add(hitTest); + hash.Add(requiresReadback); + hash.Add(inputDemand); + foreach (Type resourceType in resourceTypes) + { + hash.Add(resourceType); + } + return hash.ToHashCode(); + } + + private object Key => key; + private object Bounds => bounds; + private object HitTest => hitTest; + private bool RequiresReadback => requiresReadback; + private object InputDemand => inputDemand; + private Type[] ResourceTypes => resourceTypes; +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/GeometrySession.cs b/src/Beutl.Engine/Graphics/FilterEffects/GeometrySession.cs new file mode 100644 index 0000000000..2d07f09ce6 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/GeometrySession.cs @@ -0,0 +1,140 @@ +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.Graphics.Effects; + +public sealed class GeometrySession +{ + private readonly RenderExecutionSessionToken _token; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + private readonly Rect _allocatedOutputBounds; + private Rect _outputBounds; + private bool _discarded; + + internal GeometrySession( + RenderExecutionSessionToken token, + RenderExecutionInput input, + Rect outputBounds, + Rect requiredRegion, + PixelRect deviceBounds, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + RenderCallbackCanvas canvas, + IReadOnlyList resources) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(resources); + RenderRectValidation.ThrowIfInvalidInput(outputBounds, nameof(outputBounds)); + RenderRectValidation.ThrowIfInvalidInput(requiredRegion, nameof(requiredRegion)); + if (!float.IsFinite(outputScale) || outputScale <= 0) + throw new ArgumentOutOfRangeException(nameof(outputScale)); + if (!float.IsFinite(workingScale) || workingScale <= 0) + throw new ArgumentOutOfRangeException(nameof(workingScale)); + maxWorkingScale = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + + _token = token; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + _allocatedOutputBounds = outputBounds; + _outputBounds = outputBounds; + Input = input; + RequiredRegion = requiredRegion; + DeviceBounds = deviceBounds; + OutputScale = outputScale; + WorkingScale = workingScale; + MaxWorkingScale = maxWorkingScale; + Intent = intent; + Purpose = purpose; + Canvas = canvas; + } + + public RenderExecutionInput Input + { + get { _token.ThrowIfInactive(); return field; } + } + + public Rect OutputBounds + { + get { _token.ThrowIfInactive(); return _outputBounds; } + } + + public Rect RequiredRegion + { + get { _token.ThrowIfInactive(); return field; } + } + + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return field; } + } + + public PixelSize DeviceSize + { + get { _token.ThrowIfInactive(); return DeviceBounds.Size; } + } + + public float OutputScale + { + get { _token.ThrowIfInactive(); return field; } + } + + public float WorkingScale + { + get { _token.ThrowIfInactive(); return field; } + } + + public float MaxWorkingScale + { + get { _token.ThrowIfInactive(); return field; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return field; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return field; } + } + + public RenderCallbackCanvas Canvas + { + get { _token.ThrowIfInactive(); return field; } + } + + /// Uses the resource bound to a definition-declared slot. + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + public void SetOutputBounds(Rect logicalBounds) + { + _token.ThrowIfInactive(); + RenderRectValidation.ThrowIfInvalidInput(logicalBounds, nameof(logicalBounds)); + if (!RenderDescriptionValidation.Contains(_allocatedOutputBounds, logicalBounds)) + { + throw new ArgumentException( + "Geometry output bounds may only shrink within the allocated output bounds.", + nameof(logicalBounds)); + } + + _outputBounds = logicalBounds; + } + + public void DiscardOutput() + { + _token.ThrowIfInactive(); + _discarded = true; + } + + internal bool IsOutputDiscarded => _discarded; +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Invert.cs b/src/Beutl.Engine/Graphics/FilterEffects/Invert.cs index 83cdac32a0..380080e86f 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Invert.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Invert.cs @@ -1,50 +1,36 @@ using System.ComponentModel.DataAnnotations; -using System.Reactive; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.Invert), ResourceType = typeof(GraphicsStrings))] public sealed partial class Invert : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static Invert() - { - string sksl = - """ - uniform shader src; - uniform float amount; - uniform int excludeAlpha; - - half4 main(float2 coord) { - half4 c = src.eval(coord); - float alpha = c.a; - if (alpha <= 0.0001) return half4(0.0); - float3 rgb = c.rgb / alpha; - - float3 inverted = 1.0 - rgb; - float3 result = mix(rgb, inverted, amount); - - if (excludeAlpha == 0) { - float newAlpha = mix(alpha, 1.0 - alpha, amount); - return half4(half3(result * newAlpha), half(newAlpha)); - } - return half4(half3(result * alpha), half(alpha)); + private const string ShaderSource = + """ + uniform float amount; + uniform int excludeAlpha; + + half4 apply(half4 color) { + float alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + float3 rgb = color.rgb / alpha; + + float3 inverted = 1.0 - rgb; + float3 result = mix(rgb, inverted, amount); + + if (excludeAlpha == 0) { + float newAlpha = mix(alpha, 1.0 - alpha, amount); + return half4(half3(result * newAlpha), half(newAlpha)); } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile invert shader: {ErrorText}", errorText); + return half4(half3(result * alpha), half(alpha)); } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public Invert() { @@ -60,36 +46,13 @@ public Invert() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - var r = (Resource)resource; - context.CustomEffect( - (r, Unit.Default), - (t, c) => OnApply(t.r, c), - static (_, rect) => rect); - } - - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["amount"] = data.Amount / 100f; - builder.Uniforms["excludeAlpha"] = data.ExcludeAlphaChannel ? 1 : 0; - - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform("amount", r.Amount / 100f); + bindings.Uniform("excludeAlpha", r.ExcludeAlphaChannel ? 1 : 0); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/LayerEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/LayerEffect.cs index d4fcec9744..f91d679321 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/LayerEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/LayerEffect.cs @@ -14,6 +14,12 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource { var bounds = ctx.Targets.CalculateBounds(); var newTarget = ctx.CreateTarget(bounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + return; + } + // ctx.Open bakes the base CTM scale from the target's density. using (var canvas = ctx.Open(newTarget)) { @@ -34,6 +40,8 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource } ctx.Targets.Add(newTarget); - }); + }, + // Flattening the targets into their own union never leaves the incoming extent. + static (_, bounds) => bounds); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/LutEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/LutEffect.cs index a09727e543..431fecd488 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/LutEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/LutEffect.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Language; -using Beutl.Logging; using Beutl.Media.Source; -using Microsoft.Extensions.Logging; using SkiaSharp; namespace Beutl.Graphics.Effects; @@ -12,18 +13,10 @@ namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.LutEffect), ResourceType = typeof(GraphicsStrings))] public sealed partial class LutEffect : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); + private static readonly ConditionalWeakTable s_lutSnapshots = new(); - private static readonly SKSLShader? s_shader; - private static readonly SKSLShader? s_1dShader; - - static LutEffect() - { - // https://shizenkarasuzon.hatenablog.com/entry/2020/08/13/185223 - string sksl = - """ - uniform shader src; - // 横に長いシェーダー指定 + private const string ShaderSource3D = + """ uniform shader lut; uniform int lutSize; uniform float strength; @@ -32,114 +25,93 @@ int modInt(int a, int b) { return a - b * (a / b); } - float3 trilinear_interpolate(float3 color) + float3 sampleLut(int index) { + return float3(lut.eval(float2(float(index) + 0.5, 0.5)).rgb); + } + + float3 trilinear_interpolate(float3 inputColor) { - int3 pos; // 0~33 - float3 delta; // int lutSize2 = lutSize * lutSize; + float3 boundedColor = clamp(inputColor, float3(0.0), float3(1.0)); + float3 lutPosition = (boundedColor * 255.0) * float(lutSize) / 256.0; + int posX = int(lutPosition.r); + int posY = int(lutPosition.g); + int posZ = int(lutPosition.b); - pos.x = int(clamp((color.r * 255.0) * float(lutSize) / 256.0, 0, 255)); - pos.y = int(clamp((color.g * 255.0) * float(lutSize) / 256.0, 0, 255)); - pos.z = int(clamp((color.b * 255.0) * float(lutSize) / 256.0, 0, 255)); - - // 小数点部分 - delta.x = ((color.r * 255.0) * float(lutSize) / 256.0) - float(pos.x); - delta.y = ((color.g * 255.0) * float(lutSize) / 256.0) - float(pos.y); - delta.z = ((color.b * 255.0) * float(lutSize) / 256.0) - float(pos.z); - - float3 vertex_color_0, vertex_color_1, vertex_color_2, vertex_color_3, vertex_color_4, vertex_color_5, vertex_color_6, vertex_color_7; - float3 surf_color_0, surf_color_1, surf_color_2, surf_color_3; - float3 line_color_0, line_color_1; - float3 out_color; + float deltaX = lutPosition.r - float(posX); + float deltaY = lutPosition.g - float(posY); + float deltaZ = lutPosition.b - float(posZ); - int index = pos.x + pos.y * lutSize + pos.z * lutSize2; - - int next_index_0 = 1; - int next_index_1 = lutSize; - int next_index_2 = lutSize2; + int index = posX + posY * lutSize + posZ * lutSize2; + int nextIndex0 = 1; + int nextIndex1 = lutSize; + int nextIndex2 = lutSize2; if (modInt(index, lutSize) == lutSize - 1) { - next_index_0 = 0; + nextIndex0 = 0; } if (modInt(index / lutSize, lutSize) == lutSize - 1) { - next_index_1 = 0; + nextIndex1 = 0; } if (modInt(index / lutSize2, lutSize) == lutSize - 1) { - next_index_2 = 0; + nextIndex2 = 0; } - // https://en.wikipedia.org/wiki/Trilinear_interpolation - vertex_color_0 = float3(lut.eval(float2(index, 0)).rgb); - vertex_color_1 = float3(lut.eval(float2(index + next_index_0, 0)).rgb); - vertex_color_2 = float3(lut.eval(float2(index + next_index_0 + next_index_1, 0)).rgb); - vertex_color_3 = float3(lut.eval(float2(index + next_index_1, 0)).rgb); - vertex_color_4 = float3(lut.eval(float2(index + next_index_2, 0)).rgb); - vertex_color_5 = float3(lut.eval(float2(index + next_index_0 + next_index_2, 0)).rgb); - vertex_color_6 = float3(lut.eval(float2(index + next_index_0 + next_index_1 + next_index_2, 0)).rgb); - vertex_color_7 = float3(lut.eval(float2(index + next_index_1 + next_index_2, 0)).rgb); - - surf_color_0 = vertex_color_0 * (1.0 - delta.z) + vertex_color_4 * delta.z; - surf_color_1 = vertex_color_1 * (1.0 - delta.z) + vertex_color_5 * delta.z; - surf_color_2 = vertex_color_2 * (1.0 - delta.z) + vertex_color_6 * delta.z; - surf_color_3 = vertex_color_3 * (1.0 - delta.z) + vertex_color_7 * delta.z; - - line_color_0 = surf_color_0 * (1.0 - delta.x) + surf_color_1 * delta.x; - line_color_1 = surf_color_3 * (1.0 - delta.x) + surf_color_2 * delta.x; - - out_color = line_color_0 * (1.0 - delta.y) + line_color_1 * delta.y; - - return out_color; + float3 vertexColor0 = sampleLut(index); + float3 vertexColor1 = sampleLut(index + nextIndex0); + float3 vertexColor2 = sampleLut(index + nextIndex0 + nextIndex1); + float3 vertexColor3 = sampleLut(index + nextIndex1); + float3 vertexColor4 = sampleLut(index + nextIndex2); + float3 vertexColor5 = sampleLut(index + nextIndex0 + nextIndex2); + float3 vertexColor6 = sampleLut(index + nextIndex0 + nextIndex1 + nextIndex2); + float3 vertexColor7 = sampleLut(index + nextIndex1 + nextIndex2); + + float3 surfaceColor0 = vertexColor0 * (1.0 - deltaZ) + vertexColor4 * deltaZ; + float3 surfaceColor1 = vertexColor1 * (1.0 - deltaZ) + vertexColor5 * deltaZ; + float3 surfaceColor2 = vertexColor2 * (1.0 - deltaZ) + vertexColor6 * deltaZ; + float3 surfaceColor3 = vertexColor3 * (1.0 - deltaZ) + vertexColor7 * deltaZ; + + float3 lineColor0 = surfaceColor0 * (1.0 - deltaX) + surfaceColor1 * deltaX; + float3 lineColor1 = surfaceColor3 * (1.0 - deltaX) + surfaceColor2 * deltaX; + float3 outputColor = lineColor0 * (1.0 - deltaY) + lineColor1 * deltaY; + + return outputColor; } - // リニアsRGB → sRGBガンマ変換 float3 linearToSrgb(float3 c) { float3 lo = c * 12.92; - float3 hi = 1.055 * pow(c, float3(1.0/2.4)) - 0.055; + float3 hi = 1.055 * pow(max(c, float3(0.0)), float3(1.0/2.4)) - 0.055; return mix(lo, hi, step(float3(0.0031308), c)); } - // sRGBガンマ → リニアsRGB変換 float3 srgbToLinear(float3 c) { float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4)); + float3 hi = pow(max((c + 0.055) / 1.055, float3(0.0)), float3(2.4)); return mix(lo, hi, step(float3(0.04045), c)); } - half4 main(float2 fragCoord) { - float4 c = float4(src.eval(fragCoord)); - - // プリマルチプライドアルファを解除 + half4 apply(half4 color) { + float4 c = float4(color); float alpha = c.a; if (alpha <= 0.0001) return half4(0.0); float3 rgb = c.rgb / alpha; - // リニア→sRGBに変換してからLUT適用(LUTはsRGB前提) float3 srgbColor = linearToSrgb(rgb); float3 lutResult = trilinear_interpolate(srgbColor); - - // LUT結果をsRGB→リニアに戻す lutResult = srgbToLinear(lutResult); - - // strengthで混合(リニア空間で) float3 result = mix(rgb, lutResult, strength); - // プリマルチプライドアルファに戻す - return half4(half3(result * alpha), half(alpha)); + const float HALF_MAX = 65504.0; + float3 boundedResult = clamp(result * alpha, float3(-HALF_MAX), float3(HALF_MAX)); + return half4(half3(boundedResult), half(alpha)); } """; - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); - } - - // 1D LUT SkSLシェーダー(バイトテーブルの代替) - string sksl1d = - """ - uniform shader src; + private const string ShaderSource1D = + """ uniform shader lut; uniform int lutSize; uniform float strength; @@ -152,12 +124,12 @@ float3 linearToSrgb(float3 c) { float3 srgbToLinear(float3 c) { float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4)); + float3 hi = pow(max((c + 0.055) / 1.055, float3(0.0)), float3(2.4)); return mix(lo, hi, step(float3(0.04045), c)); } - half4 main(float2 fragCoord) { - float4 c = float4(src.eval(fragCoord)); + half4 apply(half4 color) { + float4 c = float4(color); float alpha = c.a; if (alpha <= 0.0001) return half4(0.0); @@ -171,30 +143,32 @@ half4 main(float2 fragCoord) { float bIdx = clamp(srgbColor.b, 0.0, 1.0) * maxIdx; float rResult = mix( - lut.eval(float2(floor(rIdx), 0.0)).r, - lut.eval(float2(min(floor(rIdx) + 1.0, maxIdx), 0.0)).r, + lut.eval(float2(floor(rIdx) + 0.5, 0.5)).r, + lut.eval(float2(min(floor(rIdx) + 1.0, maxIdx) + 0.5, 0.5)).r, fract(rIdx)); float gResult = mix( - lut.eval(float2(floor(gIdx), 0.0)).g, - lut.eval(float2(min(floor(gIdx) + 1.0, maxIdx), 0.0)).g, + lut.eval(float2(floor(gIdx) + 0.5, 0.5)).g, + lut.eval(float2(min(floor(gIdx) + 1.0, maxIdx) + 0.5, 0.5)).g, fract(gIdx)); float bResult = mix( - lut.eval(float2(floor(bIdx), 0.0)).b, - lut.eval(float2(min(floor(bIdx) + 1.0, maxIdx), 0.0)).b, + lut.eval(float2(floor(bIdx) + 0.5, 0.5)).b, + lut.eval(float2(min(floor(bIdx) + 1.0, maxIdx) + 0.5, 0.5)).b, fract(bIdx)); float3 lutResult = srgbToLinear(float3(rResult, gResult, bResult)); float3 result = mix(rgb, lutResult, strength); - return half4(half3(result * alpha), half(alpha)); + const float HALF_MAX = 65504.0; + float3 boundedResult = clamp(result * alpha, float3(-HALF_MAX), float3(HALF_MAX)); + return half4(half3(boundedResult), half(alpha)); } """; - if (!SKSLShader.TryCreate(sksl1d, out s_1dShader, out string? error1d)) - { - s_logger.LogError("Failed to compile 1D LUT SKSL: {ErrorText}", error1d); - } - } + private static readonly SkslSource s_shaderSource3D = + new(ShaderSource3D, ShaderDescriptionKind.CurrentPixel); + + private static readonly SkslSource s_shaderSource1D = + new(ShaderSource1D, ShaderDescriptionKind.CurrentPixel); public LutEffect() { @@ -211,90 +185,88 @@ public LutEffect() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - var cube = r.Source?.Cube; - if (cube != null) - { - float strength = r.Strength / 100f; - - if (cube.Dimention == CubeFileDimension.OneDimension) - { - context.CustomEffect((cube, strength), OnApply1DLUT_GPU, static (_, r) => r); - } - else + CubeSource.Resource? source = r.Source; + CubeFile? cube = source?.Cube; + if (source is null || cube is null) + return; + + LutShaderResource lutSnapshot = s_lutSnapshots + .GetValue(cube, static _ => new LutSnapshotState()) + .GetOrCreate(cube.Data); + RenderResource lut = context.Borrow(lutSnapshot); + SkslSource shaderSource = cube.Dimention == CubeFileDimension.OneDimension + ? s_shaderSource1D + : s_shaderSource3D; + + context.Shader(ShaderDescription.CurrentPixel( + shaderSource, + bindings => { - context.CustomEffect((cube, strength), OnApply3DLUT_GPU, static (_, r) => r); - } - } + bindings.Uniform("lutSize", cube.Size); + bindings.Uniform("strength", r.Strength / 100f); + bindings.Resource( + "lut", + lut, + ShaderResourceCoordinateSpace.Value, + static (writer, value, _) => writer.Set(value.CreateShader())); + })); } - private void OnApply1DLUT_GPU((CubeFile cube, float strength) data, CustomFilterEffectContext c) + private sealed class LutSnapshotState { - if (s_1dShader is null) return; + private readonly object _gate = new(); + private LutShaderResource? _current; - for (int i = 0; i < c.Targets.Count; i++) + public LutShaderResource GetOrCreate(ReadOnlySpan data) { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); - - var builder = s_1dShader.CreateBuilder(); - - using var lutImage = SKImage.Create(new SKImageInfo(data.cube.Data.Length, 1, SKColorType.RgbaF32)); - using (var pixmap = lutImage.PeekPixels()) + lock (_gate) { - var span = pixmap.GetPixelSpan(); - for (int j = 0; j < data.cube.Data.Length; j++) - { - var color = data.cube.Data[j]; - span[j] = new Vector4(color, 1); - } - } - using var lutShader = lutImage.ToShader(); - - builder.Children["src"] = baseShader; - builder.Children["lut"] = lutShader; - builder.Uniforms["lutSize"] = data.cube.Size; - builder.Uniforms["strength"] = data.strength; + if (_current is not null && _current.HasSameContent(data)) + return _current; - c.Targets[i] = s_1dShader.ApplyToNewTarget(c, builder, effectTarget.Bounds); + _current = LutShaderResource.Create(data); + return _current; + } } } - private void OnApply3DLUT_GPU((CubeFile cube, float strength) data, CustomFilterEffectContext c) + private sealed class LutShaderResource { - if (s_shader is null) return; + private readonly Vector3[] _data; - for (int i = 0; i < c.Targets.Count; i++) + private LutShaderResource(Vector3[] data) { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; + _data = data; + ContentIdentity = new LutContentIdentity(); + } + + public LutContentIdentity ContentIdentity { get; } - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); + public static LutShaderResource Create(ReadOnlySpan data) + => new(data.ToArray()); - var builder = s_shader.CreateBuilder(); + public bool HasSameContent(ReadOnlySpan data) + => MemoryMarshal.AsBytes(_data.AsSpan()) + .SequenceEqual(MemoryMarshal.AsBytes(data)); - using var lutImage = SKImage.Create(new SKImageInfo(data.cube.Data.Length, 1, SKColorType.RgbaF32)); - using (var pixmap = lutImage.PeekPixels()) + public SKShader CreateShader() + { + using SKImage image = SKImage.Create( + new SKImageInfo(_data.Length, 1, SKColorType.RgbaF32)); + using (SKPixmap pixmap = image.PeekPixels()) { - var span = pixmap.GetPixelSpan(); - for (int j = 0; j < data.cube.Data.Length; j++) + Span pixels = pixmap.GetPixelSpan(); + for (int i = 0; i < _data.Length; i++) { - var color = data.cube.Data[j]; - span[j] = new Vector4(color, 1); + pixels[i] = new Vector4(_data[i], 1); } } - using var lutShader = lutImage.ToShader(); - - builder.Children["src"] = baseShader; - builder.Children["lut"] = lutShader; - builder.Uniforms["lutSize"] = data.cube.Size; - builder.Uniforms["strength"] = data.strength; - // 新しいターゲットに適用 - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); + return image.ToShader(); } } + + private sealed class LutContentIdentity + { + } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/MosaicEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/MosaicEffect.cs index 06768c163b..abe1cebff2 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/MosaicEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/MosaicEffect.cs @@ -1,41 +1,28 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Language; -using Beutl.Logging; -using Microsoft.Extensions.Logging; +using Beutl.Media; +using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.MosaicEffect), ResourceType = typeof(GraphicsStrings))] public partial class MosaicEffect : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; - - static MosaicEffect() - { - string sksl = - """ - uniform shader src; - uniform float2 origin; - uniform float2 tileSize; - - half4 main(float2 fragCoord) { - float2 blockIndex = floor((fragCoord - origin) / tileSize); - - // タイルの中心位置を求める - float2 sampleCoord = (blockIndex * tileSize + tileSize * 0.5) + origin; - - // 中心位置の色をサンプリングして返す - return src.eval(sampleCoord); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile SKSL: {ErrorText}", errorText); + private const string ShaderSource = + """ + uniform shader src; + uniform float2 origin; + uniform float2 tileSize; + + half4 main(float2 fragCoord) { + float2 blockIndex = floor((fragCoord - origin) / tileSize); + float2 sampleCoord = (blockIndex * tileSize + tileSize * 0.5) + origin; + return src.eval(sampleCoord); } - } + """; public MosaicEffect() { @@ -52,40 +39,72 @@ public MosaicEffect() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { var r = (Resource)resource; - context.CustomEffect( - (r.TileSize, r.Origin), - OnApplyTo, - static (_, r) => r); + var tileSize = new Vector2(r.TileSize.Width, r.TileSize.Height); + var origin = new Vector2(r.Origin.Point.X, r.Origin.Point.Y); + context.Shader(ShaderDescription.WholeSource( + ShaderSource, + RenderBoundsContract.FullInput, + bindings => + { + bindings.Uniform( + "tileSize", + tileSize, + BindScaledVector); + if (r.Origin.Unit == RelativeUnit.Relative) + { + bindings.Uniform( + "origin", + origin, + BindRelativeOrigin); + } + else + { + bindings.Uniform( + "origin", + origin, + BindAbsoluteOrigin); + } + }, + SKShaderTileMode.Clamp)); } - private static void OnApplyTo((Size tileSize, RelativePoint origin) data, CustomFilterEffectContext c) - { - if (s_shader is null) return; + private static void BindScaledVector( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + => writer.Set(value * context.WorkingScale); - for (int i = 0; i < c.Targets.Count; i++) - { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); - - // SKRuntimeShaderBuilderを作成して、child shaderとuniformを設定 - var builder = s_shader.CreateBuilder(); - - // child shaderとしてテクスチャ用のシェーダーを設定 - builder.Children["src"] = baseShader; - // Scale tile size by working density so uniforms match the device-px buffer. - float w = c.ResolveTargetDensity(effectTarget.Bounds); - var (bufW, bufH) = CustomFilterEffectContext.DeviceBufferSize(effectTarget.Bounds, w); - builder.Uniforms["tileSize"] = new Size(data.tileSize.Width * w, data.tileSize.Height * w).ToSKSize(); - Point origin = data.origin.Unit == RelativeUnit.Relative - ? data.origin.ToPixels(new(bufW, bufH)) - : data.origin.Point * w; - builder.Uniforms["origin"] = origin.ToSKPoint(); + private static void BindRelativeOrigin( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + { + Rect outputBounds = context.OutputBounds; + Point logicalOrigin = context.LogicalOrigin; + PixelRect destinationDeviceBounds = context.DeviceBounds; + var deviceGridOffset = new Vector( + (destinationDeviceBounds.X / context.WorkingScale) - logicalOrigin.X, + (destinationDeviceBounds.Y / context.WorkingScale) - logicalOrigin.Y); + PixelRect completeDeviceBounds = PixelRect.FromRect( + outputBounds.Translate(deviceGridOffset), + context.WorkingScale); + writer.Set(new Vector2( + completeDeviceBounds.X + - destinationDeviceBounds.X + + (value.X * completeDeviceBounds.Width), + completeDeviceBounds.Y + - destinationDeviceBounds.Y + + (value.Y * completeDeviceBounds.Height))); + } - // 新しいターゲットに適用 - c.Targets[i] = s_shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); - } + private static void BindAbsoluteOrigin( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + { + var semanticOrigin = context.OutputBounds.Position - context.LogicalOrigin; + writer.Set(new Vector2( + (value.X + semanticOrigin.X) * context.WorkingScale, + (value.Y + semanticOrigin.Y) * context.WorkingScale)); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Negaposi.cs b/src/Beutl.Engine/Graphics/FilterEffects/Negaposi.cs index 58af9cc273..1ef112f641 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Negaposi.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Negaposi.cs @@ -1,47 +1,34 @@ using System.ComponentModel.DataAnnotations; -using System.Reactive; +using System.Numerics; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.Negaposi), ResourceType = typeof(GraphicsStrings))] public partial class Negaposi : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; + private const string ShaderSource = + """ + uniform float3 negaColor; + uniform float strength; - static Negaposi() - { - string sksl = - """ - uniform shader src; - uniform float3 negaColor; - uniform float strength; - - half4 main(float2 coord) { - half4 c = src.eval(coord); - float alpha = c.a; - if (alpha <= 0.0001) return half4(0.0); - float3 rgb = c.rgb / alpha; + half4 apply(half4 color) { + float alpha = color.a; + if (alpha <= 0.0001) return half4(0.0); + float3 rgb = color.rgb / alpha; - float3 negated = negaColor - rgb; - float3 result = mix(rgb, negated, strength); + float3 negated = negaColor - rgb; + float3 result = mix(rgb, negated, strength); - return half4(half3(result * alpha), half(alpha)); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile negaposi shader: {ErrorText}", errorText); + return half4(half3(result * alpha), half(alpha)); } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public Negaposi() { @@ -63,41 +50,18 @@ public Negaposi() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - var r = (Resource)resource; - context.CustomEffect( - (r, Unit.Default), - (t, c) => OnApply(t.r, c), - static (_, rect) => rect); - } - - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - float negR = Color.SrgbToLinear(data.Red / 255f); - float negG = Color.SrgbToLinear(data.Green / 255f); - float negB = Color.SrgbToLinear(data.Blue / 255f); - float strength = data.Strength / 100f; - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["negaColor"] = new SKColorF(negR, negG, negB); - builder.Uniforms["strength"] = strength; - - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform( + "negaColor", + new Vector3( + Color.SrgbToLinear(r.Red / 255f), + Color.SrgbToLinear(r.Green / 255f), + Color.SrgbToLinear(r.Blue / 255f))); + bindings.Uniform("strength", r.Strength / 100f); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/PartsSplitEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/PartsSplitEffect.cs index 2b9ff36c7f..b8ed4b9556 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/PartsSplitEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/PartsSplitEffect.cs @@ -12,7 +12,8 @@ public partial class PartsSplitEffect : FilterEffect { public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - context.CustomEffect(Unit.Default, ApplyCore); + // Every traced contour comes from the source's own alpha, so no part leaves the incoming extent. + context.CustomEffect(Unit.Default, ApplyCore, static (_, bounds) => bounds); } private void ApplyCore(Unit unit, CustomFilterEffectContext context) @@ -21,7 +22,7 @@ private void ApplyCore(Unit unit, CustomFilterEffectContext context) { EffectTarget target = context.Targets[i]; RenderTarget srcRenderTarget = target.RenderTarget!; - using var src = srcRenderTarget.Snapshot(); + using Bitmap src = srcRenderTarget.SnapshotAlpha(); // 輪郭検出(階層付き) ContourTracer.FindContoursWithHierarchy(src, out var points, out var parentIndices); @@ -78,6 +79,8 @@ private void ApplyCore(Unit unit, CustomFilterEffectContext context) // Contours are device px; convert path bounds to logical (/ w). float w = context.WorkingScale; + int completedPathCount = 0; + bool allocationFailed = false; foreach ((SKPath skpath, _, _) in pathes) { SKRect pathBounds = skpath.TightBounds; @@ -87,6 +90,13 @@ private void ApplyCore(Unit unit, CustomFilterEffectContext context) pathBounds.Width / w, pathBounds.Height / w); EffectTarget newTarget = context.CreateTarget(bounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + allocationFailed = true; + break; + } + // Clip path and source blit are device px; enter device space. using (ImmediateCanvas newCanvas = context.Open(newTarget)) using (newCanvas.PushDeviceSpace()) @@ -101,6 +111,18 @@ private void ApplyCore(Unit unit, CustomFilterEffectContext context) newTargets.Add(newTarget); skpath.Dispose(); + completedPathCount++; + } + + if (allocationFailed) + { + for (int j = completedPathCount; j < pathes.Count; j++) + { + pathes[j].Path.Dispose(); + } + + newTargets.Dispose(); + continue; } srcRenderTarget.Dispose(); diff --git a/src/Beutl.Engine/Graphics/FilterEffects/PathFollowEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/PathFollowEffect.cs index c2f0f965f0..b69dcf7580 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/PathFollowEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/PathFollowEffect.cs @@ -82,6 +82,12 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource var newBounds = target.Bounds.TransformToAABB(m1); var newTarget = effectContext.CreateTarget(newBounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + return target; + } + // Open bakes the base CTM from the target's density. using (var canvas = effectContext.Open(newTarget)) using (canvas.PushTransform(Matrix.CreateTranslation(target.Bounds.Position - newTarget.Bounds.Position))) diff --git a/src/Beutl.Engine/Graphics/FilterEffects/PixelSortEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/PixelSortEffect.cs index bcfc8d368d..c1d6f4951b 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/PixelSortEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/PixelSortEffect.cs @@ -26,12 +26,11 @@ public sealed partial class PixelSortEffect : FilterEffect layout(location = 0) out vec4 outColor; layout(set = 0, binding = 0) uniform sampler2D srcTexture; + layout(constant_id = 0) const int sortKeyType = 0; layout(push_constant) uniform PushConstants { float thresholdMin; float thresholdMax; - int sortKeyType; - int sortDir; float width; float height; } pc; @@ -55,11 +54,11 @@ float saturation(vec4 c) { } float computeKey(vec4 c) { - if (pc.sortKeyType == 1) return hue(c); - else if (pc.sortKeyType == 2) return saturation(c); - else if (pc.sortKeyType == 3) return c.r; - else if (pc.sortKeyType == 4) return c.g; - else if (pc.sortKeyType == 5) return c.b; + if (sortKeyType == 1) return hue(c); + else if (sortKeyType == 2) return saturation(c); + else if (sortKeyType == 3) return c.r; + else if (sortKeyType == 4) return c.g; + else if (sortKeyType == 5) return c.b; return dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); } @@ -80,18 +79,18 @@ void main() { layout(location = 0) out vec4 outColor; layout(set = 0, binding = 0) uniform sampler2D srcTexture; + layout(constant_id = 0) const int sortDir = 0; layout(push_constant) uniform PushConstants { - int sortDir; float width; float height; } pc; void main() { ivec2 coord = ivec2(fragCoord * vec2(pc.width, pc.height)); - int idx = (pc.sortDir == 0) ? coord.x : coord.y; - int lineIdx = (pc.sortDir == 0) ? coord.y : coord.x; - int maxIdx = (pc.sortDir == 0) ? int(pc.width) : int(pc.height); + int idx = (sortDir == 0) ? coord.x : coord.y; + int lineIdx = (sortDir == 0) ? coord.y : coord.x; + int maxIdx = (sortDir == 0) ? int(pc.width) : int(pc.height); float myKey = texelFetch(srcTexture, coord, 0).a; @@ -104,7 +103,7 @@ void main() { // Find segment start int segStart = idx; for (int s = idx - 1; s >= 0; s--) { - ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + ivec2 c = (sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); if (texelFetch(srcTexture, c, 0).a < 0.0005) break; segStart = s; } @@ -112,7 +111,7 @@ void main() { // Find segment end int segEnd = idx; for (int s = idx + 1; s < maxIdx; s++) { - ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + ivec2 c = (sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); if (texelFetch(srcTexture, c, 0).a < 0.0005) break; segEnd = s; } @@ -122,7 +121,7 @@ void main() { int rank = 0; for (int j = segStart; j <= segEnd; j++) { if (j == idx) continue; - ivec2 c = (pc.sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); + ivec2 c = (sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); float otherKey = texelFetch(srcTexture, c, 0).a; if (otherKey < myKey || (otherKey == myKey && j < idx)) { rank++; @@ -147,19 +146,19 @@ void main() { layout(set = 0, binding = 0) uniform sampler2D rankTexture; layout(set = 0, binding = 1) uniform sampler2D originalTexture; + layout(constant_id = 0) const int sortDir = 0; + layout(constant_id = 1) const int ascending = 1; layout(push_constant) uniform PushConstants { - int sortDir; - int ascending; float width; float height; } pc; void main() { ivec2 coord = ivec2(fragCoord * vec2(pc.width, pc.height)); - int idx = (pc.sortDir == 0) ? coord.x : coord.y; - int lineIdx = (pc.sortDir == 0) ? coord.y : coord.x; - int maxIdx = (pc.sortDir == 0) ? int(pc.width) : int(pc.height); + int idx = (sortDir == 0) ? coord.x : coord.y; + int lineIdx = (sortDir == 0) ? coord.y : coord.x; + int maxIdx = (sortDir == 0) ? int(pc.width) : int(pc.height); vec4 rankData = texelFetch(rankTexture, coord, 0); @@ -172,20 +171,20 @@ void main() { // Find segment boundaries using B channel int segStart = idx; for (int s = idx - 1; s >= 0; s--) { - ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + ivec2 c = (sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); if (texelFetch(rankTexture, c, 0).b < 0.5) break; segStart = s; } int segEnd = idx; for (int s = idx + 1; s < maxIdx; s++) { - ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + ivec2 c = (sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); if (texelFetch(rankTexture, c, 0).b < 0.5) break; segEnd = s; } // Target rank for this output position - int targetRank = (pc.ascending == 1) + int targetRank = (ascending == 1) ? (idx - segStart) : (segEnd - idx); @@ -193,7 +192,7 @@ void main() { vec4 originalAtIdx = texelFetch(originalTexture, coord, 0); for (int j = segStart; j <= segEnd; j++) { - ivec2 cj = (pc.sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); + ivec2 cj = (sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); vec4 rd = texelFetch(rankTexture, cj, 0); int rank = int(rd.r * 255.0 + 0.5) + int(rd.g * 255.0 + 0.5) * 256; @@ -209,10 +208,22 @@ void main() { } """; - private static GLSLShader? s_prepareShader; - private static GLSLShader? s_rankShader; - private static GLSLShader? s_gatherShader; - private static bool s_shadersInitialized; + // These fixed slots cover the complete finite specialization domain: six prepare, two rank, + // and four gather pipelines. They are retained for the process lifetime and never grow or evict. + private static readonly PixelSortPipelineCache s_shaderCache = new( + static sortKey => GLSLShader.CreateBuiltIn( + PrepareShaderSource, + [SpecializationConstant.Create(0, (int)sortKey, ShaderStage.Fragment)]), + static direction => GLSLShader.CreateBuiltIn( + RankShaderSource, + [SpecializationConstant.Create(0, (int)direction, ShaderStage.Fragment)]), + static (direction, ascending) => GLSLShader.CreateBuiltIn( + GatherRestoreShaderSource, + [ + SpecializationConstant.Create(0, (int)direction, ShaderStage.Fragment), + SpecializationConstant.Create(1, ascending ? 1 : 0, ShaderStage.Fragment), + ], + hasMaskTexture: true)); public PixelSortEffect() { @@ -236,47 +247,40 @@ public PixelSortEffect() [Display(Name = nameof(GraphicsStrings.PixelSortEffect_Ascending), ResourceType = typeof(GraphicsStrings))] public IProperty Ascending { get; } = Property.Create(true); - private static void EnsureShadersInitialized() + private static PixelSortPipelines? GetOrCreateShaders( + PixelSortDirection direction, + PixelSortKey sortKey, + bool ascending) { - if (s_shadersInitialized) return; - IGraphicsContext? context = GraphicsContextFactory.SharedContext; if (context == null || !context.Supports3DRendering) { s_logger.LogWarning("Vulkan 3D rendering is not available; PixelSort effect will be inactive."); - return; + return null; } try { - s_prepareShader = GLSLShader.Create(PrepareShaderSource); - s_rankShader = GLSLShader.Create(RankShaderSource); - s_gatherShader = GLSLShader.CreateDualTexture(GatherRestoreShaderSource); - s_shadersInitialized = true; + return s_shaderCache.GetOrCreate(sortKey, direction, ascending); } catch (Exception ex) { - s_logger.LogError(ex, "Failed to initialize PixelSort GLSL shaders."); - s_prepareShader = null; - s_rankShader = null; - s_gatherShader = null; - s_shadersInitialized = true; + s_logger.LogError(ex, "Failed to initialize a PixelSort GLSL shader variant."); + return null; } } - // Delivery (MaxWorkingScale == +inf) must not ship silently unsorted frames; preview keeps the - // source pixels and logs. Cancellation always propagates. - internal static bool ShouldRethrowPassFailure(Exception exception, float maxWorkingScale) - => exception is OperationCanceledException || float.IsPositiveInfinity(maxWorkingScale); + // Delivery must not ship silently unsorted frames; preview keeps the source pixels and logs. + // Cancellation always propagates. + internal static bool ShouldRethrowPassFailure(Exception exception, RenderIntent intent) + => exception is OperationCanceledException || intent == RenderIntent.Delivery; - // Same delivery contract for the non-exception failure: an output target without a texture - // (allocation failure) must fail a delivery render instead of shipping the frame unsorted. - internal static void ThrowIfDeliveryAllocationFailure(float maxWorkingScale, int targetIndex) + internal static void ThrowIfDeliveryAllocationFailure(RenderIntent intent, int targetIndex) { - if (float.IsPositiveInfinity(maxWorkingScale)) + if (intent == RenderIntent.Delivery) { throw new InvalidOperationException( - $"PixelSort could not allocate an output target for target {targetIndex}; the delivery render fails instead of shipping unsorted pixels."); + $"PixelSort output target {targetIndex} has no GPU texture; the delivery render fails instead of shipping unsorted pixels."); } } @@ -296,9 +300,11 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource private static void OnApplyTo(EffectData r, CustomFilterEffectContext ctx) { - EnsureShadersInitialized(); - - if (s_prepareShader == null || s_rankShader == null || s_gatherShader == null) + PixelSortPipelines? shaderPipelines = GetOrCreateShaders( + r.Direction, + r.SortKey, + r.Ascending); + if (shaderPipelines is not { } shaders) return; IGraphicsContext? gfx = GraphicsContextFactory.SharedContext; @@ -311,63 +317,76 @@ private static void OnApplyTo(EffectData r, CustomFilterEffectContext ctx) RenderTarget? renderTarget = target.RenderTarget; if (renderTarget?.Texture == null) continue; + // These passes read the backing texture from a separate Vulkan submission, which Skia's + // own ordering does not cover: an unsubmitted source reads back empty, and an empty + // source makes every pixel an anchor, so the gather pass returns the unsorted image. + renderTarget.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + ITexture2D originalTexture = renderTarget.Texture; int width = originalTexture.Width; int height = originalTexture.Height; try { - using ITexture2D prepTexture = gfx.CreateTexture2D(width, height, TextureFormat.RGBA16Float); - using ITexture2D rankTexture = gfx.CreateTexture2D(width, height, TextureFormat.RGBA16Float); - using ITexture2D depth = gfx.CreateTexture2D(width, height, TextureFormat.Depth32Float); + using NativeFilterTextureLease prepLease = ctx.AcquireNativeScratchTexture( + gfx, + width, + height); + using NativeFilterTextureLease rankLease = ctx.AcquireNativeScratchTexture( + gfx, + width, + height); + ITexture2D prepTexture = prepLease.Texture; + ITexture2D rankTexture = rankLease.Texture; // Pass 1: Prepare - encode sort key into alpha - s_prepareShader.ExecuteSingleTarget( - originalTexture, prepTexture, depth, + shaders.Prepare.ExecuteSingleTarget( + originalTexture, prepTexture, new PreparePushConstants { ThresholdMin = r.ThresholdMin, ThresholdMax = r.ThresholdMax, - SortKeyType = (int)r.SortKey, - SortDir = (int)r.Direction, Width = width, Height = height, }); // Pass 2: Rank - compute each pixel's rank within its segment - s_rankShader.ExecuteSingleTarget( - prepTexture, rankTexture, depth, + shaders.Rank.ExecuteSingleTarget( + prepTexture, rankTexture, new RankPushConstants { - SortDir = (int)r.Direction, Width = width, Height = height, }); // Pass 3: Gather + Restore - place pixels by rank, restore anchors - EffectTarget newTarget = ctx.CreateTarget(target.Bounds); + EffectTarget newTarget = ctx.CreateNativeTargetLike(target); RenderTarget? newRenderTarget = newTarget.RenderTarget; - if (newRenderTarget?.Texture == null) + if (newRenderTarget is null) { newTarget.Dispose(); - ThrowIfDeliveryAllocationFailure(ctx.MaxWorkingScale, i); continue; } - try + if (newRenderTarget.Texture is null) { - using ITexture2D gatherDepth = gfx.CreateTexture2D(width, height, TextureFormat.Depth32Float); + newTarget.Dispose(); + ThrowIfDeliveryAllocationFailure(ctx.Intent, i); + ctx.RenderTargetLeaseSession?.MarkContentDropped(); + continue; + } - s_gatherShader.ExecuteSingleTargetWithMask( - rankTexture, originalTexture, newRenderTarget.Texture, gatherDepth, + try + { + shaders.Gather.ExecuteSingleTargetWithMask( + rankTexture, originalTexture, newRenderTarget.Texture, new GatherPushConstants { - SortDir = (int)r.Direction, - Ascending = r.Ascending ? 1 : 0, Width = width, Height = height, }); + shaders.Gather.SubmitPendingCommands(); target.Dispose(); ctx.Targets[i] = newTarget; @@ -375,7 +394,7 @@ private static void OnApplyTo(EffectData r, CustomFilterEffectContext ctx) catch (Exception ex) { newTarget.Dispose(); - if (ShouldRethrowPassFailure(ex, ctx.MaxWorkingScale)) + if (ShouldRethrowPassFailure(ex, ctx.Intent)) { throw; } @@ -385,7 +404,7 @@ private static void OnApplyTo(EffectData r, CustomFilterEffectContext ctx) } catch (Exception ex) { - if (ShouldRethrowPassFailure(ex, ctx.MaxWorkingScale)) + if (ShouldRethrowPassFailure(ex, ctx.Intent)) { throw; } @@ -401,8 +420,6 @@ private struct PreparePushConstants { public float ThresholdMin; public float ThresholdMax; - public int SortKeyType; - public int SortDir; public float Width; public float Height; } @@ -410,7 +427,6 @@ private struct PreparePushConstants [StructLayout(LayoutKind.Sequential)] private struct RankPushConstants { - public int SortDir; public float Width; public float Height; } @@ -418,9 +434,95 @@ private struct RankPushConstants [StructLayout(LayoutKind.Sequential)] private struct GatherPushConstants { - public int SortDir; - public int Ascending; public float Width; public float Height; } } + +internal readonly record struct PixelSortPipelines( + TPipeline Prepare, + TPipeline Rank, + TPipeline Gather) + where TPipeline : class; + +internal sealed class PixelSortPipelineCache + where TPipeline : class +{ + private readonly object _sync = new(); + private readonly Func _createPrepare; + private readonly Func _createRank; + private readonly Func _createGather; + private readonly Slot[] _prepareSlots = new Slot[6]; + private readonly Slot[] _rankSlots = new Slot[2]; + private readonly Slot[] _gatherSlots = new Slot[4]; + + public PixelSortPipelineCache( + Func createPrepare, + Func createRank, + Func createGather) + { + _createPrepare = createPrepare; + _createRank = createRank; + _createGather = createGather; + } + + public PixelSortPipelines? GetOrCreate( + PixelSortKey sortKey, + PixelSortDirection direction, + bool ascending) + { + int prepareIndex = GetSortKeyIndex(sortKey); + int rankIndex = GetDirectionIndex(direction); + int gatherIndex = (rankIndex * 2) + (ascending ? 1 : 0); + + lock (_sync) + { + ref Slot prepareSlot = ref _prepareSlots[prepareIndex]; + // Publish a slot only after its factory succeeds. Pipeline creation can fail for transient + // device or resource reasons that are indistinguishable here from deterministic validation + // failures, so an exception deliberately leaves the slot empty for the next invocation to retry. + prepareSlot.Value ??= _createPrepare(sortKey); + + if (prepareSlot.Value is not { } prepare) + return null; + + ref Slot rankSlot = ref _rankSlots[rankIndex]; + rankSlot.Value ??= _createRank(direction); + + if (rankSlot.Value is not { } rank) + return null; + + ref Slot gatherSlot = ref _gatherSlots[gatherIndex]; + gatherSlot.Value ??= _createGather(direction, ascending); + + return gatherSlot.Value is { } gather + ? new PixelSortPipelines(prepare, rank, gather) + : null; + } + } + + private static int GetDirectionIndex(PixelSortDirection direction) + => direction switch + { + PixelSortDirection.Horizontal => 0, + PixelSortDirection.Vertical => 1, + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null), + }; + + private static int GetSortKeyIndex(PixelSortKey sortKey) + => sortKey switch + { + PixelSortKey.Luminance => 0, + PixelSortKey.Hue => 1, + PixelSortKey.Saturation => 2, + PixelSortKey.Red => 3, + PixelSortKey.Green => 4, + PixelSortKey.Blue => 5, + _ => throw new ArgumentOutOfRangeException(nameof(sortKey), sortKey, null), + }; + + private struct Slot + { + public TPipeline? Value; + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SKImageFilterBuilder.cs b/src/Beutl.Engine/Graphics/FilterEffects/SKImageFilterBuilder.cs index 4a734592b6..19a36f2ac8 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/SKImageFilterBuilder.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/SKImageFilterBuilder.cs @@ -18,6 +18,17 @@ public void AppendSkiaFilter(T data, FilterEffectActivator activator, Func(T data, Func factory) + { + SKImageFilter? inner = GetFilter(); + SKImageFilter? outer = factory(data, inner); + if (outer != null) + { + _filter = outer; + inner?.Dispose(); + } + } + public void AppendSKColorFilter(T data, FilterEffectActivator activator, Func factory) { SKColorFilter? inner = _colorFilter; @@ -44,6 +55,11 @@ public void AppendSKColorFilter(T data, FilterEffectActivator activator, Func SKImageFilter? inner = _filter; _filter = SKImageFilter.CreateColorFilter(_colorFilter, inner); inner?.Dispose(); + // AppendSkiaFilter calls this mid-chain to take the filter built so far as its input, so a color + // filter left pending here would be folded in again by the next call and applied twice. Skia + // holds its own reference to what it folded. + _colorFilter.Dispose(); + _colorFilter = null; } return _filter; @@ -62,4 +78,3 @@ public void Dispose() Clear(); } } - diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SKSLScriptEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/SKSLScriptEffect.cs index 8c15cc7961..504756c958 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/SKSLScriptEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/SKSLScriptEffect.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Numerics; using Beutl.Composition; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Language; using Beutl.Logging; using Microsoft.Extensions.Logging; @@ -49,16 +51,38 @@ public ScriptCompilationResult ValidateScript(string script) if (string.IsNullOrWhiteSpace(script)) return ScriptCompilationResult.Compiled; + string? declarativeError = null; + if (SkslSource.HasCurrentPixelEntryPoint(script)) + { + try + { + var source = new SkslSource(script, ShaderDescriptionKind.CurrentPixel); + ValidateDeclarativeUniforms(source, ShaderDescriptionKind.CurrentPixel); + using SKRuntimeEffect? currentPixelEffect = SKRuntimeEffect.CreateShader( + CreateCurrentPixelProgram(source), + out declarativeError); + if (currentPixelEffect is not null && string.IsNullOrEmpty(declarativeError)) + return ScriptCompilationResult.Compiled; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + declarativeError = ex.Message; + } + } + try { using var effect = SKRuntimeEffect.CreateShader(script, out string? errorText); - return string.IsNullOrEmpty(errorText) - ? ScriptCompilationResult.Compiled - : ScriptCompilationResult.Fail(errorText); + if (effect is not null && string.IsNullOrEmpty(errorText)) + return ScriptCompilationResult.Compiled; + + return string.IsNullOrEmpty(declarativeError) + ? ScriptCompilationResult.Fail(errorText ?? "Failed to compile SKSL script.") + : ScriptCompilationResult.Fail(declarativeError); } catch (Exception ex) { - return ScriptCompilationResult.Fail(ex.Message); + return ScriptCompilationResult.Fail(declarativeError ?? ex.Message); } } @@ -66,14 +90,23 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource { var r = (Resource)resource; - if (r._shader == null) + if (r._definition is not null) + { + context.Shader(r._definition.Call(new Resource.ScriptUniformState( + r.Progress, + r.Duration, + r.Time))); return; + } - context.CustomEffect( - (Resource: r.Progress, duration: r.Duration, time: r.Time, shader: r._shader, - compileError: r._compileError), - OnApplyTo, - static (_, r) => r); + if (r._shader is not null) + { + context.CustomEffect( + (Resource: r.Progress, duration: r.Duration, time: r.Time, shader: r._shader, + compileError: r._compileError), + OnApplyTo, + static (_, r) => r); + } } private static void OnApplyTo( @@ -82,45 +115,79 @@ private static void OnApplyTo( { for (int i = 0; i < c.Targets.Count; i++) { - using var effectTarget = c.Targets[i]; - var renderTarget = effectTarget.RenderTarget!; - - using var image = renderTarget.Value.Snapshot(); - using var baseShader = image.ToShader(); - - var builder = data.shader.CreateBuilder(); - var effect = data.shader.Effect; - - if (effect.Children.Contains("src")) - builder.Children["src"] = baseShader; - if (effect.Uniforms.Contains("progress")) - builder.Uniforms["progress"] = data.progress; - if (effect.Uniforms.Contains("duration")) - builder.Uniforms["duration"] = data.duration; - if (effect.Uniforms.Contains("time")) - builder.Uniforms["time"] = data.time; - // Resolution uniforms report device px at the clamped buffer density. - float w = c.ResolveTargetDensity(effectTarget.Bounds); - (int devW, int devH) = CustomFilterEffectContext.DeviceBufferSize(effectTarget.Bounds, w); - if (effect.Uniforms.Contains("width")) - builder.Uniforms["width"] = (float)devW; - if (effect.Uniforms.Contains("height")) - builder.Uniforms["height"] = (float)devH; - if (effect.Uniforms.Contains("iResolution")) - builder.Uniforms["iResolution"] = new SKPoint(devW, devH); - if (effect.Uniforms.Contains("iScale")) - builder.Uniforms["iScale"] = w; - if (effect.Uniforms.Contains("iTime")) - builder.Uniforms["iTime"] = data.time; - - // 新しいターゲットに適用 - c.Targets[i] = data.shader.ApplyToNewTarget(c, builder, effectTarget.Bounds); + EffectTarget effectTarget = c.Targets[i]; + EffectTarget output = c.CreateTargetLike(effectTarget); + try + { + RenderTarget? outputRenderTarget = output.RenderTarget; + if (outputRenderTarget is null || output.Scale.IsUnbounded) + { + output.Dispose(); + continue; + } + + using SKSLShaderBuilder builder = data.shader.CreateBuilder(); + + if (builder.Uniforms.Contains("progress")) + builder.Uniforms["progress"] = data.progress; + if (builder.Uniforms.Contains("duration")) + builder.Uniforms["duration"] = data.duration; + if (builder.Uniforms.Contains("time")) + builder.Uniforms["time"] = data.time; + + float w = output.Scale.Value; + int deviceWidth = outputRenderTarget.Width; + int deviceHeight = outputRenderTarget.Height; + if (builder.Uniforms.Contains("width")) + builder.Uniforms["width"] = (float)deviceWidth; + if (builder.Uniforms.Contains("height")) + builder.Uniforms["height"] = (float)deviceHeight; + if (builder.Uniforms.Contains("iResolution")) + builder.Uniforms["iResolution"] = new SKPoint(deviceWidth, deviceHeight); + if (builder.Uniforms.Contains("iScale")) + builder.Uniforms["iScale"] = w; + if (builder.Uniforms.Contains("iTime")) + builder.Uniforms["iTime"] = data.time; + + if (builder.Children.Contains("src")) + { + bool rendered = c.UseMappedInputShader( + effectTarget, + output, + (Builder: builder, Shader: data.shader, Context: c, Output: output), + static (state, mappedSource) => + { + state.Builder.Children["src"] = mappedSource; + state.Shader.RenderToTarget(state.Context, state.Builder, state.Output); + }, + SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp); + if (!rendered) + { + output.Dispose(); + continue; + } + } + else + { + data.shader.RenderToTarget(c, builder, output); + } + + effectTarget.Dispose(); + c.Targets[i] = output; + } + catch + { + output.Dispose(); + throw; + } } } public new partial class Resource { internal SKSLShader? _shader; + internal ShaderDefinition? _definition; internal string? _compiledScript; internal string? _compileError; @@ -156,6 +223,7 @@ private void CompileScript(string script) _shader?.Dispose(); _shader = null; + _definition = null; var prevError = _compileError; _compileError = null; _compiledScript = script; @@ -163,21 +231,247 @@ private void CompileScript(string script) if (string.IsNullOrWhiteSpace(script)) return; - if (!SKSLShader.TryCreate(script, out _shader, out string? errorText)) + string? declarativeError = null; + bool hasCurrentPixelEntryPoint = SkslSource.HasCurrentPixelEntryPoint(script); + if (hasCurrentPixelEntryPoint) + { + try + { + ShaderDefinition definition = CreateCurrentPixelDefinition(script); + ShaderDescription description = definition.Call(default).Description; + if (TryCompileProgram(CreateCurrentPixelProgram(description.Source), out declarativeError)) + { + _definition = definition; + return; + } + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + declarativeError = ex.Message; + } + } + else + { + try + { + ShaderDefinition definition = CreateWholeSourceDefinition(script); + ShaderDescription description = definition.Call(default).Description; + if (TryCompileProgram(description.Source.Text, out declarativeError)) + { + _definition = definition; + return; + } + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + declarativeError = ex.Message; + } + } + + if (!SKSLShader.TryCreate(script, out _shader, out string? legacyError)) + { + SetCompileError(declarativeError ?? legacyError, prevError); + return; + } + + // A valid legacy program can remain here only when the stricter declarative source or binding contract + // could not represent it. + } + + private static ShaderDefinition CreateCurrentPixelDefinition(string script) + { + var source = new SkslSource(script, ShaderDescriptionKind.CurrentPixel); + ValidateDeclarativeUniforms(source, ShaderDescriptionKind.CurrentPixel); + return ShaderDefinition.CurrentPixel( + source, + builder => BindUniforms(builder, source, isWholeSource: false)); + } + + private static ShaderDefinition CreateWholeSourceDefinition(string script) + { + string declarativeSource = SkslSource.HasUniformDeclaration(script, "src") + ? script + : "uniform shader src;\n" + script; + var source = new SkslSource(declarativeSource, ShaderDescriptionKind.WholeSource); + + ValidateDeclarativeUniforms(source, ShaderDescriptionKind.WholeSource); + return ShaderDefinition.WholeSource( + source, + RenderBoundsContract.FullInput, + builder => BindUniforms(builder, source, isWholeSource: true), + SKShaderTileMode.Clamp); + } + + private static void BindUniforms( + ShaderDefinitionBuilder builder, + SkslSource source, + bool isWholeSource) + { + foreach ((string name, SkslUniformDeclaration declaration) in source.Uniforms) { - _compileError = errorText; - if (prevError != _compileError) + if (isWholeSource && name == "src" && declaration.IsShader) + continue; + + switch (name) { - s_logger.LogError("Failed to compile SKSL script: {Error}", errorText); + case "progress": + builder.Uniform(name, static state => state.Progress); + break; + case "duration": + builder.Uniform(name, static state => state.Duration); + break; + case "time": + case "iTime": + builder.Uniform(name, static state => state.Time); + break; + case "width": + builder.Uniform(name, static _ => 0f, BindWidth); + break; + case "height": + builder.Uniform(name, static _ => 0f, BindHeight); + break; + case "iResolution": + builder.Uniform(name, static _ => default(Vector2), BindResolution); + break; + case "iScale": + builder.Uniform(name, static _ => 0f, BindScale); + break; + default: + BindZero(builder, name, declaration); + break; } } } + private static void BindWidth(ShaderUniformWriter writer, float _, ShaderExecutionContext context) + => writer.Set((float)context.SemanticOutputSize.Width); + + private static void BindHeight(ShaderUniformWriter writer, float _, ShaderExecutionContext context) + => writer.Set((float)context.SemanticOutputSize.Height); + + private static void BindResolution(ShaderUniformWriter writer, Vector2 _, ShaderExecutionContext context) + => writer.Set(new Vector2(context.SemanticOutputSize.Width, context.SemanticOutputSize.Height)); + + private static void BindScale(ShaderUniformWriter writer, float _, ShaderExecutionContext context) + => writer.Set(context.WorkingScale); + + private void SetCompileError(string? error, string? previousError) + { + _compileError = error ?? "Failed to compile SKSL script."; + if (previousError != _compileError) + s_logger.LogError("Failed to compile SKSL script: {Error}", _compileError); + } + partial void PostDispose(bool disposing) { _shader?.Dispose(); _shader = null; + _definition = null; _compileError = null; } + + private static void BindZero( + ShaderDefinitionBuilder builder, + string name, + SkslUniformDeclaration declaration) + { + (ZeroBindingKind kind, int componentCount) = GetZeroBindingKind(declaration); + switch (kind) + { + case ZeroBindingKind.FloatingPoint: + builder.ConstantUniform(name, new float[componentCount]); + break; + case ZeroBindingKind.Integer: + builder.ConstantUniform(name, 0); + break; + case ZeroBindingKind.Boolean: + builder.ConstantUniform(name, false); + break; + default: + throw new InvalidOperationException("The zero-binding kind is invalid."); + } + } + + internal readonly record struct ScriptUniformState(float Progress, float Duration, float Time); + } + + private static void ValidateDeclarativeUniforms(SkslSource source, ShaderDescriptionKind kind) + { + foreach ((string name, SkslUniformDeclaration declaration) in source.Uniforms) + { + if (kind == ShaderDescriptionKind.WholeSource && name == "src" && declaration.IsShader) + continue; + + switch (name) + { + case "progress": + case "duration": + case "time": + case "width": + case "height": + case "iScale": + case "iTime": + if (declaration.ArrayExtent is not null || declaration.Type is not ("float" or "half")) + throw new InvalidOperationException($"Uniform '{name}' must be a floating-point scalar."); + break; + case "iResolution": + if (declaration.ArrayExtent is not null || declaration.Type is not ("float2" or "half2")) + throw new InvalidOperationException("Uniform 'iResolution' must be a floating-point vector2."); + break; + default: + _ = GetZeroBindingKind(declaration); + break; + } + } + } + + private static (ZeroBindingKind Kind, int ComponentCount) GetZeroBindingKind( + SkslUniformDeclaration declaration) + { + int componentCount = declaration.Type switch + { + "float" or "half" => 1, + "float2" or "half2" => 2, + "float3" or "half3" => 3, + "float4" or "half4" => 4, + "float2x2" or "half2x2" or "mat2" => 4, + "float3x3" or "half3x3" or "mat3" => 9, + "float4x4" or "half4x4" or "mat4" => 16, + _ => 0, + }; + if (componentCount > 0) + { + return ( + ZeroBindingKind.FloatingPoint, + checked(componentCount * (declaration.ArrayExtent ?? 1))); + } + + if (declaration.ArrayExtent is null && declaration.Type == "int") + return (ZeroBindingKind.Integer, 1); + if (declaration.ArrayExtent is null && declaration.Type == "bool") + return (ZeroBindingKind.Boolean, 1); + + throw new InvalidOperationException( + $"Uniform type '{declaration.Type}' does not have a canonical declarative zero value."); + } + + private static bool TryCompileProgram(string source, out string? errorText) + { + if (!SKSLShader.TryCreate(source, out SKSLShader? shader, out errorText)) + return false; + + shader!.Dispose(); + return true; + } + + private static string CreateCurrentPixelProgram(SkslSource source) + => $"uniform shader __beutl_src;\n{source.Text}\n" + + "half4 main(float2 __beutl_coord) { return apply(__beutl_src.eval(__beutl_coord)); }\n"; + + private enum ZeroBindingKind : byte + { + FloatingPoint, + Integer, + Boolean, } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SKSLShader.cs b/src/Beutl.Engine/Graphics/FilterEffects/SKSLShader.cs index 8cb3f995d5..f8ec677232 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/SKSLShader.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/SKSLShader.cs @@ -53,54 +53,95 @@ public static bool TryCreate(string sksl, out SKSLShader? shader, out string? er } } - public SKRuntimeEffect Effect + public SKSLShaderBuilder CreateBuilder() { - get + ObjectDisposedException.ThrowIf(_disposed, this); + return new SKSLShaderBuilder(this, _effect); + } + + /// + /// Renders a configured runtime shader over the complete backing buffer of an existing target. + /// The caller retains ownership of , including when rendering fails. + /// + public void RenderToTarget( + CustomFilterEffectContext context, + SKSLShaderBuilder builder, + EffectTarget target) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(target); + if (!builder.IsOwnedBy(this)) { - ObjectDisposedException.ThrowIf(_disposed, this); - return _effect; + throw new ArgumentException( + "The builder must be created by the shader used for rendering.", + nameof(builder)); + } + if (target.RenderTarget is null || target.Scale.IsUnbounded) + throw new ArgumentException("The target must be materialized with a concrete scale.", nameof(target)); + + using SKShader finalShader = builder.Build(); + using var paint = new SKPaint { Shader = finalShader }; + using ImmediateCanvas canvas = context.Open(target); + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.Canvas.DrawRect( + SKRect.Create(target.RenderTarget.Width, target.RenderTarget.Height), + paint); } } - public SKRuntimeShaderBuilder CreateBuilder() + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _effect.Dispose(); + } + + internal SKShader Build( + SKRuntimeEffectUniforms uniforms, + SKRuntimeEffectChildren children) { ObjectDisposedException.ThrowIf(_disposed, this); - return new SKRuntimeShaderBuilder(_effect); + return _effect.ToShader(uniforms, children); } +} - public EffectTarget ApplyToNewTarget(CustomFilterEffectContext context, SKRuntimeShaderBuilder builder, Rect bounds) +public sealed class SKSLShaderBuilder : IDisposable +{ + private readonly SKSLShader _owner; + private bool _disposed; + + internal SKSLShaderBuilder(SKSLShader owner, SKRuntimeEffect effect) { - var newTarget = context.CreateTarget(bounds); - try - { - using (SKShader finalShader = builder.Build()) - using (var paint = new SKPaint()) - using (var canvas = context.Open(newTarget)) - { - paint.Shader = finalShader; - canvas.Clear(); - // Cover the full device buffer in device space. - float dw = context.WorkingScale == 1f ? (float)bounds.Width : newTarget.RenderTarget!.Width; - float dh = context.WorkingScale == 1f ? (float)bounds.Height : newTarget.RenderTarget!.Height; - using (canvas.PushDeviceSpace()) - { - canvas.Canvas.DrawRect(new SKRect(0, 0, dw, dh), paint); - } - } + _owner = owner; + Uniforms = new SKRuntimeEffectUniforms(effect); + Children = new SKRuntimeEffectChildren(effect); + } - return newTarget; - } - catch - { - newTarget.Dispose(); - throw; - } + public SKRuntimeEffectUniforms Uniforms { get; } + + public SKRuntimeEffectChildren Children { get; } + + /// + /// Builds the configured runtime shader. The caller owns the returned shader. + /// + public SKShader Build() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _owner.Build(Uniforms, Children); } + internal bool IsOwnedBy(SKSLShader shader) + => ReferenceEquals(_owner, shader); + public void Dispose() { if (_disposed) return; _disposed = true; - _effect.Dispose(); + Uniforms.Dispose(); + Children.Dispose(); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ShaderBindings.cs b/src/Beutl.Engine/Graphics/FilterEffects/ShaderBindings.cs new file mode 100644 index 0000000000..482cdfd04f --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/ShaderBindings.cs @@ -0,0 +1,796 @@ +using System.Collections.ObjectModel; +using System.Numerics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Effects; + +/// Declares how coordinates passed to a child shader are interpreted. +/// +/// The resource binder uses its to create a shader or local matrix that matches +/// the declared space. The binder must not retain its writer, context, or callback-provided raw resource and must not +/// dispose the raw resource; disposal ownership remains defined by the original owned or borrowed registration. +/// +public enum ShaderResourceCoordinateSpace +{ + /// Interprets coordinates as author-defined value coordinates without an output-space conversion. + /// This is the only coordinate space accepted by . + Value, + + /// + /// Interprets coordinates in local output-device pixels, matching the coord argument of a whole-source + /// shader. + /// + /// + /// For a coordinate coord, the corresponding logical point is + /// LogicalOrigin + coord / WorkingScale. + /// + OutputDevice, +} + +/// Describes one immutable uniform binding declared for a shader. +/// Instances are created through . +internal sealed class ShaderUniformBinding +{ + private readonly Action _bind; + private readonly Action _validate; + internal ShaderUniformBinding( + string name, + object definitionFingerprint, + Action bind, + Action validate) + { + Name = name; + DefinitionFingerprint = definitionFingerprint; + _bind = bind; + _validate = validate; + } + + /// Gets the non-null SkSL uniform declaration name. + public string Name { get; } + + internal object DefinitionFingerprint { get; } + + internal void ValidateDeclaration(SkslUniformDeclaration declaration) => _validate(declaration); + + internal ShaderUniformValue Bind(SkslUniformDeclaration declaration, ShaderExecutionContext context) + { + var writer = new ShaderUniformWriter(declaration); + try + { + _bind(writer, context); + return writer.Complete(); + } + finally + { + writer.Deactivate(); + } + } +} + +/// Describes one immutable child-shader resource binding declared for a shader. +/// Instances are created through . +internal sealed class ShaderResourceBinding +{ + private readonly Action _bind; + private readonly Func, bool> _useResource; + + internal ShaderResourceBinding( + string name, + RenderResource resource, + ShaderResourceCoordinateSpace coordinateSpace, + object definitionFingerprint, + Action bind, + Func, bool> useResource) + { + Name = name; + Resource = resource; + CoordinateSpace = coordinateSpace; + DefinitionFingerprint = definitionFingerprint; + _bind = bind; + _useResource = useResource; + } + + /// Gets the non-null SkSL child-shader declaration name. + public string Name { get; } + + /// Gets how coordinates passed to the child shader are interpreted. + public ShaderResourceCoordinateSpace CoordinateSpace { get; } + + /// Gets the request-scoped resource token used by the execution-time binder. + /// + /// The token scopes access to the raw resource without changing whether the request or the caller owns it. + /// + public RenderResource Resource { get; } + + internal object DefinitionFingerprint { get; } + + internal SKShader Bind(ShaderExecutionContext context) + { + SKShader? result = null; + bool invoked = _useResource(value => + { + var writer = new ShaderResourceWriter(); + bool completed = false; + try + { + _bind(writer, value, context); + result = writer.Complete(); + completed = true; + } + finally + { + writer.Deactivate(); + if (!completed) + writer.DisposePending(); + } + }); + if (!invoked || result is null) + throw new InvalidOperationException($"Shader resource binder '{Name}' did not produce a shader."); + return result; + } +} + +/// Declares uniform and child-shader bindings while a is created. +/// +/// The description invokes its builder callback synchronously and snapshots the declared bindings before returning. +/// Registered execution binders run later. Their writers, contexts, and callback-provided raw resources must not be +/// retained, and binders must not dispose raw resources. Disposal ownership continues to follow each resource's owned +/// or borrowed registration. Every binding name must be a unique SkSL identifier matching a declaration in the +/// source. +/// +internal sealed class ShaderBindingBuilder +{ + private readonly List _uniforms = []; + private readonly List _resources = []; + private readonly HashSet _names = new(StringComparer.Ordinal); + + internal ShaderBindingBuilder() + { + } + + /// Declares a direct uniform whose canonical value is written without an execution callback. + /// An unmanaged type in the supported canonical scalar, vector, or matrix allowlist. + /// The unique non-null SkSL uniform declaration name. + /// The value copied into the immutable description for execution. + /// is . + /// + /// is invalid or duplicated, or is not a supported canonical + /// uniform type. + /// + /// An unsigned value cannot be represented by its SkSL type. + public void Uniform(string name, T value) + where T : unmanaged + { + ValidateName(name); + ShaderCanonicalValue canonical = ShaderCanonicalValue.Create(value); + _uniforms.Add(new ShaderUniformBinding( + name, + new DirectUniformStructuralKey(typeof(T)), + (writer, _) => writer.Set(value), + canonical.ThrowIfIncompatible)); + } + + /// Declares a direct floating-point uniform from a sequence copied during description creation. + /// The unique non-null SkSL uniform declaration name. + /// A non-empty sequence whose contents are copied immediately and are never retained. + /// is . + /// + /// is invalid or duplicated, or is empty. + /// + public void Uniform(string name, ReadOnlySpan values) + { + ValidateName(name); + float[] copy = values.ToArray(); + if (copy.Length == 0) + throw new ArgumentException("A direct uniform span cannot be empty.", nameof(values)); + _uniforms.Add(new ShaderUniformBinding( + name, + typeof(FloatSequenceIdentity), + (writer, _) => writer.Set(copy), + declaration => ShaderCanonicalValue.ThrowIfFloatSequenceIncompatible(copy, declaration))); + } + + /// Declares a uniform whose value is produced by an execution-time binder. + /// An unmanaged type in the supported canonical scalar, vector, or matrix allowlist. + /// The unique non-null SkSL uniform declaration name. + /// + /// The author value passed to during execution. + /// + /// + /// The non-null execution callback. It must call or + /// exactly once and must not retain the writer or + /// context. The unmanaged is passed by value. + /// + /// + /// or is . + /// + /// + /// is invalid or duplicated, an identity is invalid, or is not + /// a supported canonical uniform type. + /// + /// An unsigned value cannot be represented by its SkSL type. + public void Uniform( + string name, + T value, + Action bind) + where T : unmanaged + { + ValidateName(name); + ArgumentNullException.ThrowIfNull(bind); + _uniforms.Add(new ShaderUniformBinding( + name, + new CustomUniformStructuralKey(typeof(T), bind.Method), + (writer, context) => bind(writer, value, context), + static _ => { })); + } + + /// Declares a child-shader resource produced by an execution-time binder. + /// The raw request-scoped resource type. + /// The unique non-null SkSL child-shader declaration name. + /// A non-null resource token registered with the request family. + /// How the returned child shader interprets coordinates passed to its eval. + /// + /// The non-null execution callback. It must call exactly once with a newly + /// created shader. It must not retain the writer, context, or callback-provided resource and must not dispose the + /// resource. A borrowed resource remains caller-owned and its pixel-affecting state must remain read-only + /// throughout the executing request; an owned resource remains request-owned. + /// + /// + /// , , or is . + /// + /// + /// is invalid or duplicated, or an identity is invalid. + /// + /// + /// is not a defined value. + /// + public void Resource( + string name, + RenderResource resource, + ShaderResourceCoordinateSpace coordinateSpace, + Action bind) + where T : class + { + ValidateName(name); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(bind); + if (!Enum.IsDefined(coordinateSpace)) + throw new ArgumentOutOfRangeException(nameof(coordinateSpace), coordinateSpace, "The coordinate space is invalid."); + _resources.Add(new ShaderResourceBinding( + name, + resource, + coordinateSpace, + new ResourceBindingStructuralKey(typeof(T), bind.Method), + (writer, value, context) => bind(writer, (T)value, context), + use => resource.Registry.Use(resource, value => + { + use(value); + return true; + }))); + } + + internal IReadOnlyList Uniforms => new ReadOnlyCollection(_uniforms); + + internal IReadOnlyList Resources => new ReadOnlyCollection(_resources); + + private void ValidateName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (!IsIdentifier(name)) + throw new ArgumentException("A shader binding name must be a valid identifier.", nameof(name)); + if (!_names.Add(name)) + throw new ArgumentException($"Duplicate shader binding name '{name}'.", nameof(name)); + } + + private static bool IsIdentifier(string name) + { + if (!(char.IsLetter(name[0]) || name[0] == '_')) + return false; + for (int i = 1; i < name.Length; i++) + { + if (!(char.IsLetterOrDigit(name[i]) || name[i] == '_')) + return false; + } + return true; + } + +} + +/// Writes the single value produced by an execution-time uniform binder. +/// +/// A binder must call one Set overload exactly once. The writer is valid only during that binder invocation +/// and must not be retained. +/// +public sealed class ShaderUniformWriter +{ + private readonly SkslUniformDeclaration _declaration; + private ShaderUniformValue? _value; + private bool _active = true; + + internal ShaderUniformWriter(SkslUniformDeclaration declaration) + { + _declaration = declaration; + } + + /// Sets the binder result from a supported canonical scalar, vector, or matrix value. + /// An unmanaged type in the supported canonical uniform allowlist. + /// The value to validate against the parsed SkSL declaration. + /// + /// The writer is inactive, a value was already set, or the value is incompatible with the SkSL declaration. + /// + /// is not a supported canonical uniform type. + /// An unsigned value cannot be represented by its SkSL type. + public void Set(T value) + where T : unmanaged + { + ThrowIfInactive(); + if (_value is not null) + throw new InvalidOperationException("A shader uniform binder must set its writer exactly once."); + ShaderCanonicalValue canonical = ShaderCanonicalValue.Create(value); + canonical.ThrowIfIncompatible(_declaration); + _value = new ShaderUniformValue(canonical.Values, canonical.Integers, canonical.IsInteger); + } + + /// Sets the binder result from a floating-point sequence copied during the call. + /// The values to validate and copy; the caller's memory is not retained. + /// + /// The writer is inactive, a value was already set, or the sequence is incompatible with the SkSL declaration. + /// + public void Set(ReadOnlySpan values) + { + ThrowIfInactive(); + if (_value is not null) + throw new InvalidOperationException("A shader uniform binder must set its writer exactly once."); + float[] copy = values.ToArray(); + ShaderCanonicalValue.ThrowIfFloatSequenceIncompatible(copy, _declaration); + _value = new ShaderUniformValue(copy, null, false); + } + + internal ShaderUniformValue Complete() + { + ThrowIfInactive(); + return _value + ?? throw new InvalidOperationException("A shader uniform binder must set its writer exactly once."); + } + + internal void Deactivate() => _active = false; + + private void ThrowIfInactive() + { + if (!_active) + throw new InvalidOperationException("The shader uniform writer is no longer active."); + } +} + +/// Transfers the single child shader produced by an execution-time resource binder to the renderer. +/// +/// A binder must call exactly once. The writer is valid only during that binder invocation and +/// must not be retained. +/// +public sealed class ShaderResourceWriter +{ + private SKShader? _shader; + private bool _active = true; + + internal ShaderResourceWriter() + { + } + + /// Sets the binder result and transfers ownership of the shader to the renderer. + /// A non-null, non-disposed shader newly created for this binding invocation. + /// + /// The renderer disposes after binding and program execution, or if binding fails. The + /// binder must not retain, use, or dispose it after this method returns. + /// + /// is . + /// is already disposed. + /// The writer is inactive or a shader was already set. + public void Set(SKShader shader) + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(shader); + ObjectDisposedException.ThrowIf(shader.Handle == IntPtr.Zero, shader); + if (_shader is not null) + throw new InvalidOperationException("A shader resource binder must set its writer exactly once."); + _shader = shader; + } + + internal SKShader Complete() + { + ThrowIfInactive(); + return _shader + ?? throw new InvalidOperationException("A shader resource binder must set its writer exactly once."); + } + + internal void Deactivate() => _active = false; + + internal void DisposePending() + { + _shader?.Dispose(); + _shader = null; + } + + private void ThrowIfInactive() + { + if (!_active) + throw new InvalidOperationException("The shader resource writer is no longer active."); + } +} + +/// Exposes resolved, stage-local metadata to an execution-time shader binder. +/// +/// The context is valid only during the current compiled shader run's binding phase and must not be retained. Every +/// property throws after that phase completes. +/// +public sealed class ShaderExecutionContext +{ + private readonly RenderExecutionSessionToken _token; + private readonly Rect _inputBounds; + private readonly Rect _outputBounds; + private readonly Rect _requiredRegion; + private readonly PixelRect _deviceBounds; + private readonly PixelSize _semanticOutputSize; + private readonly Point _logicalOrigin; + private readonly Vector _deviceGridOffset; + private readonly EffectiveScale _inputEffectiveScale; + private readonly float _outputScale; + private readonly float _workingScale; + private readonly float _maxWorkingScale; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + + internal ShaderExecutionContext( + RenderExecutionSessionToken token, + Rect inputBounds, + Rect outputBounds, + Rect requiredRegion, + PixelRect deviceBounds, + EffectiveScale inputEffectiveScale, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose) + : this( + token, + inputBounds, + outputBounds, + requiredRegion, + deviceBounds, + deviceBounds.ToRect(workingScale), + inputEffectiveScale, + outputScale, + workingScale, + maxWorkingScale, + intent, + purpose) + { + } + + internal ShaderExecutionContext( + RenderExecutionSessionToken token, + Rect inputBounds, + Rect outputBounds, + Rect requiredRegion, + PixelRect deviceBounds, + Rect rasterBounds, + EffectiveScale inputEffectiveScale, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose) + { + ArgumentNullException.ThrowIfNull(token); + _token = token; + _inputBounds = inputBounds; + _outputBounds = outputBounds; + _requiredRegion = requiredRegion; + _deviceBounds = deviceBounds; + _logicalOrigin = rasterBounds.Position; + _deviceGridOffset = new Vector( + (deviceBounds.X / workingScale) - rasterBounds.X, + (deviceBounds.Y / workingScale) - rasterBounds.Y); + _semanticOutputSize = PixelRect.FromRect( + outputBounds.Translate(_deviceGridOffset), + workingScale) + .Size; + if (_semanticOutputSize.Width <= 0 || _semanticOutputSize.Height <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(outputBounds), + outputBounds, + "A shader's semantic output size must be positive."); + } + _inputEffectiveScale = inputEffectiveScale; + _outputScale = outputScale; + _workingScale = workingScale; + _maxWorkingScale = maxWorkingScale; + _intent = intent; + _purpose = purpose; + } + + /// Gets the stage's complete logical input bounds. + /// The shader binding phase has completed. + public Rect InputBounds + { + get { _token.ThrowIfInactive(); return _inputBounds; } + } + + /// Gets the stage's complete logical output bounds. + /// The shader binding phase has completed. + public Rect OutputBounds + { + get { _token.ThrowIfInactive(); return _outputBounds; } + } + + /// Gets the stage-local logical output region required by the current request. + /// The shader binding phase has completed. + public Rect RequiredRegion + { + get { _token.ThrowIfInactive(); return _requiredRegion; } + } + + /// Gets the footprint the stage is evaluated over, in composition-device pixels. + /// + /// The footprint reflects the actual runtime-clamped . + /// Subtract after converting it to logical units to obtain + /// the stage-local footprint. + /// A stage is evaluated over the region the request asked + /// for, so this is its destination footprint. A stage is + /// evaluated over its complete output regardless of how much of it was requested, so this is the complete + /// output footprint and its size equals ; use + /// for the part actually being produced. + /// + /// The shader binding phase has completed. + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return _deviceBounds; } + } + + /// Gets the evaluated footprint size, equal to .Size. + /// The shader binding phase has completed. + public PixelSize DeviceSize + { + get { _token.ThrowIfInactive(); return _deviceBounds.Size; } + } + + /// Gets the complete semantic output dimensions in working-density pixels. + /// + /// The size is derived from , , and + /// . It is independent of the physical backing selected by the execution planner. + /// It matches for a stage and + /// describes the complete output rather than the requested region for a + /// one. + /// + /// The shader binding phase has completed. + public PixelSize SemanticOutputSize + { + get { _token.ThrowIfInactive(); return _semanticOutputSize; } + } + + /// + /// Gets the translation from stage-local coordinates to the composition-device grid used to + /// round . + /// + public Vector DeviceGridOffset + { + get { _token.ThrowIfInactive(); return _deviceGridOffset; } + } + + /// Gets the logical point represented by local output-device coordinate (0, 0). + /// + /// A local device coordinate coord represents + /// LogicalOrigin + coord / WorkingScale. The origin follows , so a + /// stage's coord spans + /// [0, SemanticOutputSize] over its complete output even when a smaller region was requested. + /// + /// The shader binding phase has completed. + public Point LogicalOrigin + { + get + { + _token.ThrowIfInactive(); + return _logicalOrigin; + } + } + + /// Gets the effective-scale supply resolved for the stage input. + /// + /// The first fused stage receives the materialized input scale; later stages receive the fused run's + /// . + /// + /// The shader binding phase has completed. + public EffectiveScale InputEffectiveScale + { + get { _token.ThrowIfInactive(); return _inputEffectiveScale; } + } + + /// Gets the final output density requested for the render, in device pixels per logical unit. + /// This value is not an intermediate allocation ceiling; use for execution. + /// The shader binding phase has completed. + public float OutputScale + { + get { _token.ThrowIfInactive(); return _outputScale; } + } + + /// + /// Gets the positive finite density selected for this stage after working-scale and allocation-limit clamping. + /// + /// The shader binding phase has completed. + public float WorkingScale + { + get { _token.ThrowIfInactive(); return _workingScale; } + } + + /// Gets the sanitized maximum working density allowed by the render request. + /// The shader binding phase has completed. + public float MaxWorkingScale + { + get { _token.ThrowIfInactive(); return _maxWorkingScale; } + } + + /// Gets whether the request targets interactive preview or delivery-quality rendering. + /// The shader binding phase has completed. + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + /// Gets the high-level operation that caused this render request. + /// The shader binding phase has completed. + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } +} + +internal sealed record ShaderUniformValue(float[]? Floats, int[]? Integers, bool IsInteger); + +internal sealed record DirectUniformStructuralKey(Type Type); + +internal sealed record CustomUniformStructuralKey(Type Type, object Binder); + +internal sealed record ResourceBindingStructuralKey(Type Type, object Binder); + +internal sealed class FloatSequenceIdentity(int[] bits) : IEquatable +{ + private readonly int[] _bits = bits; + + public bool Equals(FloatSequenceIdentity? other) + => other is not null && _bits.AsSpan().SequenceEqual(other._bits); + + public override bool Equals(object? obj) => obj is FloatSequenceIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (int value in _bits) + hash.Add(value); + return hash.ToHashCode(); + } +} + +internal readonly record struct ShaderCanonicalValue( + float[]? Values, + int[]? Integers, + bool IsInteger, + object Identity) +{ + public static ShaderCanonicalValue Create(T value) + where T : unmanaged + { + object boxed = value; + return boxed switch + { + float current => Float([current]), + double current => Float([(float)current]), + int current => Integer([current]), + uint current when current <= int.MaxValue => Integer([(int)current]), + uint current => throw new ArgumentOutOfRangeException( + nameof(value), + current, + "A UInt32 shader uniform value cannot exceed Int32.MaxValue."), + short current => Integer([current]), + ushort current => Integer([current]), + byte current => Integer([current]), + sbyte current => Integer([current]), + bool current => Integer([current ? 1 : 0]), + Vector2 current => Float([current.X, current.Y]), + Vector3 current => Float([current.X, current.Y, current.Z]), + Vector4 current => Float([current.X, current.Y, current.Z, current.W]), + // SkSL reads matrix uniforms column-major. System.Numerics stores rows contiguously and transforms + // row vectors, so its storage order already is the column-major encoding of the equivalent + // column-vector SkSL matrix. Matrix3x2 has no SkSL matrix type; it binds to float2[3]. + Matrix3x2 current => Float([ + current.M11, current.M12, + current.M21, current.M22, + current.M31, current.M32]), + Matrix4x4 current => Float([ + current.M11, current.M12, current.M13, current.M14, + current.M21, current.M22, current.M23, current.M24, + current.M31, current.M32, current.M33, current.M34, + current.M41, current.M42, current.M43, current.M44]), + SKPoint current => Float([current.X, current.Y]), + SKPoint3 current => Float([current.X, current.Y, current.Z]), + SKSize current => Float([current.Width, current.Height]), + // SKMatrix also stores rows contiguously but transforms column vectors, so unlike the cases above its + // storage order must be transposed to become column-major. + SKMatrix current => Float([ + current.ScaleX, current.SkewY, current.Persp0, + current.SkewX, current.ScaleY, current.Persp1, + current.TransX, current.TransY, current.Persp2]), + _ => throw new ArgumentException( + $"'{typeof(T).FullName}' is not a canonical shader uniform value type.", + nameof(value)), + }; + } + + public void ThrowIfIncompatible(SkslUniformDeclaration declaration) + { + if (declaration.IsShader) + throw new InvalidOperationException("A shader resource declaration requires a resource binding."); + int required = GetComponentCount(declaration); + int actual = IsInteger ? Integers!.Length : Values!.Length; + bool declaredInteger = declaration.Type is "int" or "int2" or "int3" or "int4" or "bool"; + if (declaredInteger != IsInteger || required != actual) + { + throw new InvalidOperationException( + $"The supplied value is incompatible with SkSL uniform type '{declaration.Type}'."); + } + } + + public static void ThrowIfFloatSequenceIncompatible(float[] values, SkslUniformDeclaration declaration) + { + if (declaration.IsShader || declaration.Type.StartsWith("int", StringComparison.Ordinal) || declaration.Type == "bool") + throw new InvalidOperationException($"SkSL uniform type '{declaration.Type}' does not accept float values."); + int required = GetComponentCount(declaration); + if (values.Length != required) + throw new InvalidOperationException($"SkSL uniform type '{declaration.Type}' requires {required} values."); + } + + private static int GetComponentCount(SkslUniformDeclaration declaration) + { + int count = declaration.Type switch + { + "float" or "half" or "int" or "bool" => 1, + "float2" or "half2" or "int2" => 2, + "float3" or "half3" or "int3" => 3, + "float4" or "half4" or "int4" => 4, + "float2x2" or "half2x2" or "mat2" => 4, + "float3x3" or "half3x3" or "mat3" => 9, + "float4x4" or "half4x4" or "mat4" => 16, + _ => throw new InvalidOperationException($"Unsupported SkSL uniform type '{declaration.Type}'."), + }; + return count * (declaration.ArrayExtent ?? 1); + } + + private static ShaderCanonicalValue Float(float[] values) + { + var identity = new FloatSequenceIdentity(values.Select(BitConverter.SingleToInt32Bits).ToArray()); + return new ShaderCanonicalValue(values, null, false, identity); + } + + private static ShaderCanonicalValue Integer(int[] values) + => new(null, values, true, new IntSequenceIdentity(values)); +} + +internal sealed class IntSequenceIdentity(int[] values) : IEquatable +{ + private readonly int[] _values = [.. values]; + + public bool Equals(IntSequenceIdentity? other) + => other is not null && _values.AsSpan().SequenceEqual(other._values); + + public override bool Equals(object? obj) => obj is IntSequenceIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (int value in _values) + hash.Add(value); + return hash.ToHashCode(); + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ShaderDefinitionCalls.cs b/src/Beutl.Engine/Graphics/FilterEffects/ShaderDefinitionCalls.cs new file mode 100644 index 0000000000..b895d7e3fc --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/ShaderDefinitionCalls.cs @@ -0,0 +1,466 @@ +using Beutl.Graphics.Rendering; +using SkiaSharp; + +namespace Beutl.Graphics.Effects; + +/// Defines the fixed source, metadata, and binding shape of a shader operation. +/// The per-recording values supplied by . +/// +/// A definition is reusable operation shape. A call supplies the values and request-scoped resource bindings for one +/// recording. When any pixel-affecting call state changes, the owning must set +/// before the next request. Value providers and execution binders must be +/// non-capturing so every changing value is read from the call state. +/// +public sealed class ShaderDefinition + where TState : notnull +{ + private readonly ShaderDescriptionKind _kind; + private readonly SkslSource _source; + private readonly RenderBoundsContract _bounds; + private readonly RenderInputDemandContract _inputDemand; + private readonly SKShaderTileMode _sourceTileMode; + private readonly IReadOnlyList> _bindings; + private readonly IReadOnlyList _resourceSlots; + + private ShaderDefinition( + ShaderDescriptionKind kind, + SkslSource source, + RenderBoundsContract bounds, + RenderInputDemandContract inputDemand, + SKShaderTileMode sourceTileMode, + Action>? bindings) + { + var builder = new ShaderDefinitionBuilder(); + bindings?.Invoke(builder); + ValidateBindings(source, builder.Shapes, kind); + + _kind = kind; + _source = source; + _bounds = bounds; + _inputDemand = inputDemand; + _sourceTileMode = sourceTileMode; + _bindings = builder.Templates.ToArray(); + _resourceSlots = RenderDescriptionValidation.CopyResourceSlots(builder.ResourceSlots, nameof(bindings)); + } + + /// Creates a current-pixel shader definition. + /// SkSL defining exactly one half4 apply(half4 color) entry point. + /// The fixed uniform and resource binding shape, or for none. + public static ShaderDefinition CurrentPixel( + string source, + Action>? bindings = null) + => new( + ShaderDescriptionKind.CurrentPixel, + new SkslSource(source, ShaderDescriptionKind.CurrentPixel), + RenderBoundsContract.Identity, + RenderInputDemandContract.Unchanged, + SKShaderTileMode.Decal, + bindings); + + /// Creates a current-pixel shader definition from an already parsed source. + /// A non-null source. + /// The fixed uniform and resource binding shape, or for none. + public static ShaderDefinition CurrentPixel( + SkslSource source, + Action>? bindings = null) + { + ArgumentNullException.ThrowIfNull(source); + if (source.Kind != ShaderDescriptionKind.CurrentPixel) + throw new ArgumentException("The parsed source is not a CurrentPixel source.", nameof(source)); + + return new ShaderDefinition( + ShaderDescriptionKind.CurrentPixel, + source, + RenderBoundsContract.Identity, + RenderInputDemandContract.Unchanged, + SKShaderTileMode.Decal, + bindings); + } + + /// Creates a whole-source shader definition. + /// + /// SkSL defining exactly one half4 main(float2 coord) entry point and an implicit + /// uniform shader src; input. + /// + /// The fixed pure mapping from complete input to complete output bounds. + /// The fixed uniform and resource binding shape, or for none. + /// The fixed sampling mode outside the implicit source bounds. + /// + /// The fixed mapping from this stage's resolved output demand to the demand it places on src. + /// A stage that enlarges what it samples must declare it; the default leaves demand unchanged, which is + /// only correct for a stage that samples src at the density its own consumer asked for. + /// + public static ShaderDefinition WholeSource( + string source, + RenderBoundsContract bounds, + Action>? bindings = null, + SKShaderTileMode sourceTileMode = SKShaderTileMode.Decal, + RenderInputDemandContract inputDemand = default) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + if (!Enum.IsDefined(sourceTileMode)) + throw new ArgumentOutOfRangeException(nameof(sourceTileMode), sourceTileMode, "The source tile mode is invalid."); + + return new ShaderDefinition( + ShaderDescriptionKind.WholeSource, + new SkslSource(source, ShaderDescriptionKind.WholeSource), + bounds, + inputDemand, + sourceTileMode, + bindings); + } + + /// Creates a whole-source shader definition from an already parsed source. + /// A non-null source. + /// + public static ShaderDefinition WholeSource( + SkslSource source, + RenderBoundsContract bounds, + Action>? bindings = null, + SKShaderTileMode sourceTileMode = SKShaderTileMode.Decal, + RenderInputDemandContract inputDemand = default) + { + ArgumentNullException.ThrowIfNull(source); + if (source.Kind != ShaderDescriptionKind.WholeSource) + throw new ArgumentException("The parsed source is not a WholeSource source.", nameof(source)); + bounds.ThrowIfUninitialized(nameof(bounds)); + if (!Enum.IsDefined(sourceTileMode)) + throw new ArgumentOutOfRangeException(nameof(sourceTileMode), sourceTileMode, "The source tile mode is invalid."); + + return new ShaderDefinition( + ShaderDescriptionKind.WholeSource, + source, + bounds, + inputDemand, + sourceTileMode, + bindings); + } + + /// Binds this shader shape to values and resource tokens for one recording. + public ShaderCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal ShaderDescription CreateDescription( + TState state, + IEnumerable? bindings) + { + IReadOnlyList resourceBindings = + RenderDescriptionValidation.ValidateResourceBindings(_resourceSlots, bindings, nameof(bindings)); + + Action apply = builder => + { + foreach (ShaderBindingTemplate binding in _bindings) + binding.Apply(builder, state, resourceBindings); + }; + + return _kind == ShaderDescriptionKind.CurrentPixel + ? ShaderDescription.CurrentPixel(_source, apply) + : ShaderDescription.WholeSource(_source, _bounds, apply, _sourceTileMode, _inputDemand); + } + + private static void ValidateBindings( + SkslSource source, + IReadOnlyList shapes, + ShaderDescriptionKind kind) + { + var supplied = new HashSet(StringComparer.Ordinal); + foreach (ShaderBindingShape shape in shapes) + { + // The description rejects this too, but only once a call is made, which leaves an author with a + // definition that builds and then throws on every use of it. + if (kind == ShaderDescriptionKind.WholeSource && shape.IsResource && shape.Name == "src") + { + throw new ArgumentException( + "The implicit WholeSource input 'src' cannot be supplied as an explicit resource binding.", + nameof(shapes)); + } + + if (!source.Uniforms.TryGetValue(shape.Name, out SkslUniformDeclaration declaration)) + throw new ArgumentException($"The shader does not declare binding '{shape.Name}'.", nameof(shapes)); + + if (shape.IsResource != declaration.IsShader) + { + throw new ArgumentException( + shape.IsResource + ? $"Uniform '{shape.Name}' requires a uniform binding." + : $"Shader declaration '{shape.Name}' requires a resource binding.", + nameof(shapes)); + } + + if (kind == ShaderDescriptionKind.CurrentPixel + && shape.IsResource + && shape.CoordinateSpace != ShaderResourceCoordinateSpace.Value) + { + throw new ArgumentException( + "CurrentPixel shader resources must use Value coordinates.", + nameof(shapes)); + } + + supplied.Add(shape.Name); + } + + foreach ((string name, SkslUniformDeclaration declaration) in source.Uniforms) + { + if (kind == ShaderDescriptionKind.WholeSource && name == "src" && declaration.IsShader) + continue; + if (!supplied.Contains(name)) + throw new ArgumentException($"Shader binding '{name}' was declared but not supplied.", nameof(shapes)); + } + + if (kind == ShaderDescriptionKind.WholeSource + && (!source.Uniforms.TryGetValue("src", out SkslUniformDeclaration sourceDeclaration) + || !sourceDeclaration.IsShader)) + { + throw new ArgumentException( + "A WholeSource shader must declare its implicit upstream input as 'uniform shader src;'.", + nameof(source)); + } + } +} + +/// Binds one shader definition to the values and resources for one recording. +public sealed class ShaderCall + where TState : notnull +{ + internal ShaderCall( + ShaderDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable shader shape. + public ShaderDefinition Definition { get; } + + /// Gets the callback state supplied for this recording. + public TState State { get; } + + internal ShaderDescription Description { get; } +} + +/// Declares the fixed uniform and child-shader binding shape of a shader definition. +/// The call state used to obtain each uniform value. +public sealed class ShaderDefinitionBuilder + where TState : notnull +{ + private readonly List> _templates = []; + private readonly List _shapes = []; + private readonly List _resourceSlots = []; + private readonly HashSet _names = new(StringComparer.Ordinal); + + internal ShaderDefinitionBuilder() + { + } + + /// Declares a direct canonical uniform supplied from the call state. + /// The provider must not capture values; use a lambda or method. + public void Uniform(string name, Func value) + where T : unmanaged + { + ArgumentNullException.ThrowIfNull(value); + ValidateCallStateCallback(value, nameof(value)); + AddUniform(name, new DirectUniformTemplate(name, value)); + } + + internal void ConstantUniform(string name, T value) + where T : unmanaged + => AddUniform(name, new ConstantDirectUniformTemplate(name, value)); + + internal void ConstantUniform(string name, ReadOnlySpan values) + => AddUniform(name, new ConstantFloatSequenceUniformTemplate(name, values.ToArray())); + + /// Declares a floating-point sequence uniform supplied from the call state. + /// The provider must not capture values; use a lambda or method. + public void Uniform(string name, Func> values) + { + ArgumentNullException.ThrowIfNull(values); + ValidateCallStateCallback(values, nameof(values)); + AddUniform(name, new FloatSequenceUniformTemplate(name, values)); + } + + /// Declares a custom uniform binder supplied from the call state. + /// Both callbacks must not capture values; use callbacks. + public void Uniform( + string name, + Func value, + Action bind) + where T : unmanaged + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(bind); + ValidateCallStateCallback(value, nameof(value)); + ValidateCallStateCallback(bind, nameof(bind)); + AddUniform(name, new CustomUniformTemplate(name, value, bind)); + } + + /// Declares a typed child-shader resource slot and its execution binder. + /// The binder must not capture values; use a callback. + public void Resource( + string name, + RenderResourceSlot slot, + ShaderResourceCoordinateSpace coordinateSpace, + Action bind) + where T : class + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(bind); + ValidateCallStateCallback(bind, nameof(bind)); + if (!Enum.IsDefined(coordinateSpace)) + throw new ArgumentOutOfRangeException(nameof(coordinateSpace), coordinateSpace, "The coordinate space is invalid."); + + ValidateName(name); + _templates.Add(new ResourceTemplate(name, slot, coordinateSpace, bind)); + _shapes.Add(new ShaderBindingShape(name, IsResource: true, coordinateSpace)); + _resourceSlots.Add(slot); + } + + internal IReadOnlyList> Templates => _templates; + + internal IReadOnlyList Shapes => _shapes; + + internal IReadOnlyList ResourceSlots => _resourceSlots; + + private void AddUniform(string name, ShaderBindingTemplate template) + { + ValidateName(name); + _templates.Add(template); + _shapes.Add(new ShaderBindingShape(name, IsResource: false, CoordinateSpace: null)); + } + + private void ValidateName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (!(char.IsLetter(name[0]) || name[0] == '_')) + throw new ArgumentException("A shader binding name must be a valid identifier.", nameof(name)); + for (int i = 1; i < name.Length; i++) + { + if (!(char.IsLetterOrDigit(name[i]) || name[i] == '_')) + throw new ArgumentException("A shader binding name must be a valid identifier.", nameof(name)); + } + if (!_names.Add(name)) + throw new ArgumentException($"Duplicate shader binding name '{name}'.", nameof(name)); + } + + private static void ValidateCallStateCallback(Delegate callback, string parameterName) + { + if (RenderIdentityKeyValidator.CapturesState(callback)) + { + throw new ArgumentException( + "A shader definition callback must not capture values. Read changing values from call state and pass a static callback.", + parameterName); + } + } +} + +internal sealed record ShaderBindingShape( + string Name, + bool IsResource, + ShaderResourceCoordinateSpace? CoordinateSpace); + +internal abstract class ShaderBindingTemplate + where TState : notnull +{ + internal abstract void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings); +} + +internal sealed class DirectUniformTemplate( + string name, + Func value) + : ShaderBindingTemplate + where TState : notnull + where TValue : unmanaged +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + => builder.Uniform(name, value(state)); +} + +internal sealed class ConstantDirectUniformTemplate( + string name, + TValue value) + : ShaderBindingTemplate + where TState : notnull + where TValue : unmanaged +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + => builder.Uniform(name, value); +} + +internal sealed class ConstantFloatSequenceUniformTemplate( + string name, + float[] values) + : ShaderBindingTemplate + where TState : notnull +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + => builder.Uniform(name, values); +} + +internal sealed class FloatSequenceUniformTemplate( + string name, + Func> values) + : ShaderBindingTemplate + where TState : notnull +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + { + IReadOnlyList current = values(state) + ?? throw new InvalidOperationException("A shader uniform value provider returned null."); + builder.Uniform(name, current.ToArray()); + } +} + +internal sealed class CustomUniformTemplate( + string name, + Func value, + Action bind) + : ShaderBindingTemplate + where TState : notnull + where TValue : unmanaged +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + => builder.Uniform(name, value(state), bind); +} + +internal sealed class ResourceTemplate( + string name, + RenderResourceSlot slot, + ShaderResourceCoordinateSpace coordinateSpace, + Action bind) + : ShaderBindingTemplate + where TState : notnull + where TValue : class +{ + internal override void Apply( + ShaderBindingBuilder builder, + TState state, + IReadOnlyList resourceBindings) + { + RenderResourceBinding binding = resourceBindings.FirstOrDefault(item => ReferenceEquals(item.Slot, slot)) + ?? throw new InvalidOperationException("The shader definition slot was not bound for this call."); + builder.Resource(name, (RenderResource)binding.Resource, coordinateSpace, bind); + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ShaderDescription.cs b/src/Beutl.Engine/Graphics/FilterEffects/ShaderDescription.cs new file mode 100644 index 0000000000..357ccbf66e --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/ShaderDescription.cs @@ -0,0 +1,355 @@ +using System.Collections.ObjectModel; +using Beutl.Graphics.Rendering; +using SkiaSharp; + +namespace Beutl.Graphics.Effects; + +/// Declares one immutable renderer-neutral shader stage recorded into a render graph. +/// +/// Every description keeps its validated SkSL lowering for compatibility and may also carry an engine-authored +/// SPIR-V lowering. Create instances through or . The renderer +/// derives plan shape from source and declared binding layout. Declared binding callbacks run only during execution +/// and receive execution-scoped writers and contexts that must not be retained. +/// +internal sealed class ShaderDescription +{ + private ShaderDescription( + ShaderDescriptionKind kind, + SkslSource parsed, + SpirvShaderLowering? spirvLowering, + RenderBoundsContract bounds, + RenderInputDemandContract inputDemand, + Action? bindings, + SKShaderTileMode sourceTileMode) + { + var builder = new ShaderBindingBuilder(); + bindings?.Invoke(builder); + ValidateBindings(parsed, builder.Uniforms, builder.Resources, kind); + + Kind = kind; + Source = parsed; + Bounds = bounds; + InputDemand = inputDemand; + Uniforms = new ReadOnlyCollection(builder.Uniforms.ToArray()); + Resources = new ReadOnlyCollection(builder.Resources.ToArray()); + SourceTileMode = sourceTileMode; + spirvLowering?.ValidateForDescription(kind, parsed, Uniforms, Resources); + SpirvLowering = spirvLowering; + StructuralIdentity = new ShaderDescriptionStructuralIdentity( + kind, + parsed.Text, + spirvLowering?.StructuralIdentity, + bounds.StructuralIdentity, + inputDemand.StructuralIdentity, + sourceTileMode, + Uniforms.Select(static item => new ShaderBindingStructuralIdentity(item.Name, item.DefinitionFingerprint)).ToArray(), + Resources.Select(static item => new ShaderResourceStructuralIdentity( + item.Name, + item.CoordinateSpace, + item.DefinitionFingerprint)).ToArray()); + } + + /// Gets whether the stage transforms only the current pixel or samples the complete upstream source. + public ShaderDescriptionKind Kind { get; } + + /// Gets the non-null normalized SkSL compatibility source. + /// Backend program validation may still reject the source during execution. + public SkslSource Source { get; } + + /// Gets the pure mapping from complete input bounds to complete output bounds. + /// descriptions always use . + public RenderBoundsContract Bounds { get; } + + /// Gets the mapping from this stage's resolved output demand to the demand on its input. + /// + /// descriptions always leave demand unchanged; they consume one resolved pixel + /// value and never resample. + /// + public RenderInputDemandContract InputDemand { get; } + + /// Gets the non-null immutable uniform bindings in declaration order. + public IReadOnlyList Uniforms { get; } + + /// Gets the non-null immutable child-shader resource bindings in declaration order. + public IReadOnlyList Resources { get; } + + /// Gets the sampling mode used outside the implicit src input bounds. + /// The value is meaningful for descriptions. + public SKShaderTileMode SourceTileMode { get; } + + /// Gets the optional engine-authored Vulkan lowering for this stage. + internal SpirvShaderLowering? SpirvLowering { get; } + + internal object StructuralIdentity { get; } + + internal object GetStructuralIdentity(ShaderProgramBackend backend) + { + if (!Enum.IsDefined(backend)) + throw new ArgumentOutOfRangeException(nameof(backend)); + if (backend == ShaderProgramBackend.Spirv && SpirvLowering is null) + throw new InvalidOperationException("The shader description has no SPIR-V lowering."); + return new ShaderDescriptionBackendStructuralIdentity(backend, StructuralIdentity); + } + + /// Creates a coordinate-independent shader stage that transforms one resolved pixel value. + /// + /// Non-null SkSL defining exactly one half4 apply(half4 color) entry point. Its argument and result are + /// premultiplied linear-light RGBA16F values. + /// + /// + /// An optional callback invoked immediately to declare bindings, or to declare none. + /// Binder callbacks registered by the builder are deferred until execution. + /// + /// An immutable deferred shader description. + /// + /// The description declares identity bounds and no independent scale change. A stage recorded directly through + /// preserves its input effective + /// scale; when it is the first surviving operation of a , the enclosing filter + /// render node may fold its working-scale contract into that stage and select another density. Public + /// current-pixel stages do not fuse across analytic or antialiased coverage production; the planner resolves + /// that coverage before applying the stage. Compatible fused stages receive stage-local bounds, required region, + /// device footprint, input effective scale, and working scale in their execution-time binders. + /// + /// is . + /// + /// The source grammar, entry point, declarations, or supplied bindings are invalid or incompatible. + /// + internal static ShaderDescription CurrentPixel( + string source, + Action? bindings = null) + => CurrentPixel(new SkslSource(source, ShaderDescriptionKind.CurrentPixel), bindings); + + /// + /// Creates a current-pixel stage from a source that was already normalized and validated. + /// + /// + /// Engine stages whose SkSL text is a compile-time constant share one parsed source so that recording a + /// fragment does not re-tokenize and re-validate it. + /// + internal static ShaderDescription CurrentPixel( + SkslSource source, + Action? bindings) + { + if (source.Kind != ShaderDescriptionKind.CurrentPixel) + throw new ArgumentException("The parsed source is not a CurrentPixel source.", nameof(source)); + + return new ShaderDescription( + ShaderDescriptionKind.CurrentPixel, + source, + spirvLowering: null, + RenderBoundsContract.Identity, + RenderInputDemandContract.Unchanged, + bindings, + SKShaderTileMode.Decal); + } + + /// Creates a current-pixel stage with both its existing SkSL and Vulkan-native lowerings. + internal static ShaderDescription CurrentPixel( + SkslSource source, + SpirvShaderLowering spirvLowering, + Action? bindings) + { + ArgumentNullException.ThrowIfNull(spirvLowering); + if (source.Kind != ShaderDescriptionKind.CurrentPixel) + throw new ArgumentException("The parsed source is not a CurrentPixel source.", nameof(source)); + + return new ShaderDescription( + ShaderDescriptionKind.CurrentPixel, + source, + spirvLowering, + RenderBoundsContract.Identity, + RenderInputDemandContract.Unchanged, + bindings, + SKShaderTileMode.Decal); + } + + /// Creates a materializing shader stage that may sample arbitrary upstream locations. + /// + /// Non-null SkSL defining exactly one half4 main(float2 coord) entry point and declaring the implicit + /// upstream input as uniform shader src;. + /// + /// An initialized pure input-to-output bounds contract. + /// + /// An optional callback invoked immediately to declare bindings other than src, or + /// to declare none. Binder callbacks registered by the builder are deferred until + /// execution. + /// + /// The tile mode used when the implicit source is sampled outside its bounds. + /// An immutable deferred shader description. + /// + /// The stage may lead a fused run whose remaining stages are CurrentPixel transforms, but it never consumes an + /// earlier stage inside that run. Its coord argument is expressed in local output-device pixels and its + /// recorded effective scale is the resolved working density. + /// + /// is . + /// + /// The bounds contract, source grammar, entry point, declarations, or supplied bindings are invalid or + /// incompatible. + /// + /// + /// is not a defined value. + /// + internal static ShaderDescription WholeSource( + string source, + RenderBoundsContract bounds, + Action? bindings = null, + SKShaderTileMode sourceTileMode = SKShaderTileMode.Decal, + RenderInputDemandContract inputDemand = default) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + if (!Enum.IsDefined(sourceTileMode)) + throw new ArgumentOutOfRangeException(nameof(sourceTileMode), sourceTileMode, "The source tile mode is invalid."); + + return new ShaderDescription( + ShaderDescriptionKind.WholeSource, + new SkslSource(source, ShaderDescriptionKind.WholeSource), + spirvLowering: null, + bounds, + inputDemand, + bindings, + sourceTileMode); + } + + internal static ShaderDescription WholeSource( + SkslSource source, + RenderBoundsContract bounds, + Action? bindings, + SKShaderTileMode sourceTileMode, + RenderInputDemandContract inputDemand = default) + { + if (source.Kind != ShaderDescriptionKind.WholeSource) + throw new ArgumentException("The parsed source is not a WholeSource source.", nameof(source)); + bounds.ThrowIfUninitialized(nameof(bounds)); + if (!Enum.IsDefined(sourceTileMode)) + throw new ArgumentOutOfRangeException(nameof(sourceTileMode), sourceTileMode, "The source tile mode is invalid."); + + return new ShaderDescription( + ShaderDescriptionKind.WholeSource, + source, + spirvLowering: null, + bounds, + inputDemand, + bindings, + sourceTileMode); + } + + private static void ValidateBindings( + SkslSource source, + IReadOnlyList uniforms, + IReadOnlyList resources, + ShaderDescriptionKind kind) + { + var supplied = new Dictionary(StringComparer.Ordinal); + foreach (ShaderUniformBinding uniform in uniforms) + { + if (!source.Uniforms.TryGetValue(uniform.Name, out SkslUniformDeclaration declaration)) + throw new ArgumentException($"The shader does not declare uniform '{uniform.Name}'.", nameof(uniforms)); + if (declaration.IsShader) + throw new ArgumentException($"Shader declaration '{uniform.Name}' requires a resource binding.", nameof(uniforms)); + uniform.ValidateDeclaration(declaration); + supplied.Add(uniform.Name, false); + } + + foreach (ShaderResourceBinding resource in resources) + { + if (kind == ShaderDescriptionKind.WholeSource && resource.Name == "src") + { + throw new ArgumentException( + "The implicit WholeSource input 'src' cannot be supplied as an explicit resource binding.", + nameof(resources)); + } + + if (!source.Uniforms.TryGetValue(resource.Name, out SkslUniformDeclaration declaration)) + throw new ArgumentException($"The shader does not declare resource '{resource.Name}'.", nameof(resources)); + if (!declaration.IsShader) + throw new ArgumentException($"Uniform '{resource.Name}' requires a uniform binding.", nameof(resources)); + if (kind == ShaderDescriptionKind.CurrentPixel + && resource.CoordinateSpace != ShaderResourceCoordinateSpace.Value) + { + throw new ArgumentException( + "CurrentPixel shader resources must use Value coordinates.", + nameof(resources)); + } + supplied.Add(resource.Name, true); + } + + foreach ((string name, SkslUniformDeclaration declaration) in source.Uniforms) + { + if (kind == ShaderDescriptionKind.WholeSource + && name == "src" + && declaration.IsShader) + { + continue; + } + + if (!supplied.ContainsKey(name)) + throw new ArgumentException($"Shader binding '{name}' was declared but not supplied.", nameof(uniforms)); + } + + if (kind == ShaderDescriptionKind.WholeSource + && (!source.Uniforms.TryGetValue("src", out SkslUniformDeclaration sourceDeclaration) + || !sourceDeclaration.IsShader)) + { + throw new ArgumentException( + "A WholeSource shader must declare its implicit upstream input as 'uniform shader src;'.", + nameof(source)); + } + } +} + +internal sealed class ShaderDescriptionStructuralIdentity( + ShaderDescriptionKind kind, + string source, + object? spirvLowering, + object bounds, + object inputDemand, + SKShaderTileMode tileMode, + ShaderBindingStructuralIdentity[] uniforms, + ShaderResourceStructuralIdentity[] resources) + : IEquatable +{ + public bool Equals(ShaderDescriptionStructuralIdentity? other) + => other is not null + && kind == other.Kind + && source == other.Source + && Equals(spirvLowering, other.SpirvLowering) + && Equals(bounds, other.Bounds) + && Equals(inputDemand, other.InputDemand) + && tileMode == other.TileMode + && uniforms.AsSpan().SequenceEqual(other.Uniforms) + && resources.AsSpan().SequenceEqual(other.Resources); + + public override bool Equals(object? obj) => obj is ShaderDescriptionStructuralIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(kind); + hash.Add(source, StringComparer.Ordinal); + hash.Add(spirvLowering); + hash.Add(bounds); + hash.Add(inputDemand); + hash.Add(tileMode); + foreach (ShaderBindingStructuralIdentity item in uniforms) + hash.Add(item); + foreach (ShaderResourceStructuralIdentity item in resources) + hash.Add(item); + return hash.ToHashCode(); + } + + private ShaderDescriptionKind Kind => kind; + private string Source => source; + private object? SpirvLowering => spirvLowering; + private object Bounds => bounds; + private object InputDemand => inputDemand; + private SKShaderTileMode TileMode => tileMode; + private ShaderBindingStructuralIdentity[] Uniforms => uniforms; + private ShaderResourceStructuralIdentity[] Resources => resources; +} + +internal sealed record ShaderBindingStructuralIdentity(string Name, object DefinitionFingerprint); + +internal sealed record ShaderResourceStructuralIdentity( + string Name, + ShaderResourceCoordinateSpace CoordinateSpace, + object DefinitionFingerprint); diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ShaderLowering.cs b/src/Beutl.Engine/Graphics/FilterEffects/ShaderLowering.cs new file mode 100644 index 0000000000..920440d7ce --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/ShaderLowering.cs @@ -0,0 +1,246 @@ +using System.Buffers.Binary; +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.Graphics.Effects; + +internal enum ShaderProgramBackend : byte +{ + Sksl, + Spirv, +} + +internal enum ShaderBackendPreference : byte +{ + Auto, + Sksl, + Spirv, +} + +/// +/// Describes an engine-authored Vulkan fragment program that is compiled to SPIR-V for one +/// . +/// +/// +/// The first native increment accepts one CurrentPixel input texture at descriptor binding 0 and maps scalar or +/// vector uniforms to explicitly offset Vulkan push constants. The renderer reserves the first 16 bytes for an +/// integer source-texel offset that preserves raster aprons and partial materialization without filtered sampling. +/// Description construction rejects layouts outside that complete subset instead of deferring an unsupported +/// binding to execution. +/// +internal sealed class SpirvShaderLowering +{ + private readonly IReadOnlyList _pushConstants; + + public SpirvShaderLowering( + string fragmentShaderSource, + IReadOnlyList pushConstants, + bool supportsBitExactSkiaHandoff) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fragmentShaderSource); + ArgumentNullException.ThrowIfNull(pushConstants); + + FragmentShaderSource = fragmentShaderSource.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + SpirvPushConstantBinding[] copy = pushConstants.ToArray(); + var names = new HashSet(StringComparer.Ordinal); + foreach (SpirvPushConstantBinding binding in copy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(binding.Name); + if (!names.Add(binding.Name)) + throw new ArgumentException($"Duplicate SPIR-V push-constant binding '{binding.Name}'.", nameof(pushConstants)); + ArgumentOutOfRangeException.ThrowIfNegative(binding.Offset); + if (binding.Offset < SpirvPushConstants.UserByteOffset || (binding.Offset & 3) != 0) + { + throw new ArgumentException( + $"SPIR-V push-constant binding '{binding.Name}' must start at a four-byte-aligned offset at or after {SpirvPushConstants.UserByteOffset}.", + nameof(pushConstants)); + } + } + + _pushConstants = new ReadOnlyCollection(copy); + SupportsBitExactSkiaHandoff = supportsBitExactSkiaHandoff; + StructuralIdentity = new SpirvShaderLoweringStructuralIdentity( + FragmentShaderSource, + copy, + SupportsBitExactSkiaHandoff); + } + + public string FragmentShaderSource { get; } + + public IReadOnlyList PushConstants => _pushConstants; + + public bool SupportsBitExactSkiaHandoff { get; } + + internal object StructuralIdentity { get; } + + internal void ValidateForDescription( + ShaderDescriptionKind kind, + SkslSource skslSource, + IReadOnlyList uniforms, + IReadOnlyList resources) + { + if (kind != ShaderDescriptionKind.CurrentPixel) + { + throw new ArgumentException( + "The SPIR-V lowering currently supports only CurrentPixel descriptions.", + nameof(kind)); + } + if (resources.Count != 0) + { + throw new ArgumentException( + "The SPIR-V CurrentPixel lowering currently supports only its implicit source texture.", + nameof(resources)); + } + if (_pushConstants.Count != uniforms.Count) + { + throw new ArgumentException( + "Every CurrentPixel uniform must have exactly one SPIR-V push-constant mapping.", + nameof(uniforms)); + } + + var occupiedRanges = new List<(int Start, int End, string Name)>(); + foreach (SpirvPushConstantBinding mapping in _pushConstants) + { + ShaderUniformBinding binding = uniforms.SingleOrDefault( + item => string.Equals(item.Name, mapping.Name, StringComparison.Ordinal)) + ?? throw new ArgumentException( + $"SPIR-V push constant '{mapping.Name}' has no matching shader uniform binding.", + nameof(uniforms)); + SkslUniformDeclaration declaration = skslSource.Uniforms[binding.Name]; + (int alignment, int byteSize) = GetLayout(mapping.Name, declaration); + if (mapping.Offset % alignment != 0) + { + throw new ArgumentException( + $"SPIR-V push constant '{mapping.Name}' at offset {mapping.Offset} does not meet its {alignment}-byte alignment.", + nameof(uniforms)); + } + + int end = checked(mapping.Offset + byteSize); + if (end > SpirvPushConstants.ByteSize) + { + throw new ArgumentException( + $"SPIR-V push constant '{mapping.Name}' exceeds the {SpirvPushConstants.ByteSize}-byte Vulkan minimum.", + nameof(uniforms)); + } + if (occupiedRanges.Any(range => mapping.Offset < range.End && end > range.Start)) + { + throw new ArgumentException( + $"SPIR-V push constant '{mapping.Name}' overlaps another mapping.", + nameof(uniforms)); + } + occupiedRanges.Add((mapping.Offset, end, mapping.Name)); + } + } + + internal SpirvPushConstants Bind( + ShaderDescription description, + ShaderExecutionContext context, + PixelPoint sourceTexelOffset) + { + var result = new SpirvPushConstants(); + Span bytes = result; + BinaryPrimitives.WriteInt32LittleEndian(bytes[..sizeof(int)], sourceTexelOffset.X); + BinaryPrimitives.WriteInt32LittleEndian(bytes.Slice(sizeof(int), sizeof(int)), sourceTexelOffset.Y); + foreach (SpirvPushConstantBinding mapping in _pushConstants) + { + ShaderUniformBinding binding = description.Uniforms.Single( + item => string.Equals(item.Name, mapping.Name, StringComparison.Ordinal)); + SkslUniformDeclaration declaration = description.Source.Uniforms[binding.Name]; + ShaderUniformValue value = binding.Bind(declaration, context); + if (value.IsInteger) + { + int[] integers = value.Integers!; + for (int index = 0; index < integers.Length; index++) + { + BinaryPrimitives.WriteInt32LittleEndian( + bytes.Slice(mapping.Offset + (index * sizeof(int)), sizeof(int)), + integers[index]); + } + } + else + { + float[] floats = value.Floats!; + for (int index = 0; index < floats.Length; index++) + { + BinaryPrimitives.WriteInt32LittleEndian( + bytes.Slice(mapping.Offset + (index * sizeof(float)), sizeof(float)), + BitConverter.SingleToInt32Bits(floats[index])); + } + } + } + return result; + } + + private static (int Alignment, int ByteSize) GetLayout( + string name, + SkslUniformDeclaration declaration) + { + if (declaration.ArrayExtent is not null) + { + throw new ArgumentException( + $"SPIR-V push constant '{name}' cannot use an array in the current native subset.", + nameof(declaration)); + } + + return declaration.Type switch + { + "float" or "half" or "int" or "bool" => (4, 4), + "float2" or "half2" or "int2" => (8, 8), + "float3" or "half3" or "int3" => (16, 12), + "float4" or "half4" or "int4" => (16, 16), + _ => throw new ArgumentException( + $"SPIR-V push constant '{name}' uses unsupported type '{declaration.Type}'.", + nameof(declaration)), + }; + } +} + +internal readonly record struct SpirvPushConstantBinding(string Name, int Offset); + +[InlineArray(ByteSize)] +internal struct SpirvPushConstants +{ + public const int ByteSize = 128; + public const int UserByteOffset = 16; + + private byte _element0; +} + +internal sealed class SpirvShaderLoweringStructuralIdentity( + string source, + SpirvPushConstantBinding[] pushConstants, + bool supportsBitExactSkiaHandoff) + : IEquatable +{ + public bool Equals(SpirvShaderLoweringStructuralIdentity? other) + => other is not null + && source == other.Source + && supportsBitExactSkiaHandoff == other.SupportsBitExactSkiaHandoff + && pushConstants.AsSpan().SequenceEqual(other.PushConstants); + + public override bool Equals(object? obj) + => obj is SpirvShaderLoweringStructuralIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(source, StringComparer.Ordinal); + hash.Add(supportsBitExactSkiaHandoff); + foreach (SpirvPushConstantBinding item in pushConstants) + hash.Add(item); + return hash.ToHashCode(); + } + + private string Source => source; + + private SpirvPushConstantBinding[] PushConstants => pushConstants; + + private bool SupportsBitExactSkiaHandoff => supportsBitExactSkiaHandoff; +} + +internal sealed record ShaderDescriptionBackendStructuralIdentity( + ShaderProgramBackend Backend, + object DescriptionIdentity); diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SkslLexer.cs b/src/Beutl.Engine/Graphics/FilterEffects/SkslLexer.cs new file mode 100644 index 0000000000..747e662a08 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/SkslLexer.cs @@ -0,0 +1,168 @@ +using System.Text; + +namespace Beutl.Graphics.Effects; + +/// +/// Shared significant-token scanner for the validation and alpha-renaming sides of the SKSL authoring contract. +/// Keeping comment handling, identifier boundaries, and scope depth here prevents accepted source from being +/// interpreted differently when snippets are merged. +/// +internal static class SkslLexer +{ + internal static List Tokenize(string source) + { + var tokens = new List(); + int braceDepth = 0; + int parenthesisDepth = 0; + for (int i = 0; i < source.Length; i++) + { + char c = source[i]; + if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') + { + while (i < source.Length && source[i] != '\n') + i++; + continue; + } + + if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') + { + i = SkipBlockComment(source, i); + continue; + } + + if (char.IsWhiteSpace(c)) + continue; + + if (char.IsDigit(c) || c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1])) + { + int start = i; + if (c == '0' + && i + 2 < source.Length + && source[i + 1] is 'x' or 'X' + && Uri.IsHexDigit(source[i + 2])) + { + i += 2; + while (i + 1 < source.Length && Uri.IsHexDigit(source[i + 1])) + i++; + } + else if (c == '.') + { + while (i + 1 < source.Length && char.IsDigit(source[i + 1])) + i++; + } + else + { + while (i + 1 < source.Length && char.IsDigit(source[i + 1])) + i++; + if (i + 1 < source.Length && source[i + 1] == '.') + { + i++; + while (i + 1 < source.Length && char.IsDigit(source[i + 1])) + i++; + } + } + + if (i + 1 < source.Length && source[i + 1] is 'e' or 'E') + { + int exponentEnd = i + 2; + if (exponentEnd < source.Length && source[exponentEnd] is '+' or '-') + exponentEnd++; + int exponentDigits = exponentEnd; + while (exponentEnd < source.Length && char.IsDigit(source[exponentEnd])) + exponentEnd++; + if (exponentEnd > exponentDigits) + i = exponentEnd - 1; + } + + if (i + 1 < source.Length && source[i + 1] is 'f' or 'F' or 'h' or 'H' or 'u' or 'U') + i++; + + tokens.Add(new SkslToken( + source[start..(i + 1)], false, braceDepth, parenthesisDepth, start, i + 1 - start)); + continue; + } + + if (char.IsLetter(c) || c == '_') + { + int start = i; + while (i + 1 < source.Length && (char.IsLetterOrDigit(source[i + 1]) || source[i + 1] == '_')) + i++; + tokens.Add(new SkslToken( + source[start..(i + 1)], true, braceDepth, parenthesisDepth, start, i + 1 - start)); + continue; + } + + if (c == '{') + braceDepth++; + else if (c == '}' && braceDepth > 0) + braceDepth--; + else if (c == '(') + parenthesisDepth++; + else if (c == ')' && parenthesisDepth > 0) + parenthesisDepth--; + + tokens.Add(new SkslToken(c.ToString(), false, braceDepth, parenthesisDepth, i, 1)); + } + + return tokens; + } + + // Regex-based uniform metadata still needs a comment-free source. Preserve whitespace at comment boundaries so + // removing a comment cannot join two identifiers into a token the significant-token scanner would never emit. + internal static string StripComments(string source) + { + var result = new StringBuilder(source.Length); + for (int i = 0; i < source.Length; i++) + { + char c = source[i]; + if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') + { + while (i < source.Length && source[i] != '\n') + i++; + if (i < source.Length) + result.Append('\n'); + continue; + } + + if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') + { + i = SkipBlockComment(source, i); + result.Append(' '); + continue; + } + + result.Append(c); + } + + return result.ToString(); + } + + /// + /// Returns the index of the comment's closing slash, or the end of the source when it is never closed. + /// + /// + /// An unterminated block comment is a syntax error, and reporting it belongs to the shading-language + /// compiler, which sees the source with its comments intact and can name a line. This scanner also runs + /// on the render path while an effect resource compiles its script, where throwing would fail the frame + /// rather than surface a message on the effect, so it consumes the rest of the source instead. + /// + private static int SkipBlockComment(string source, int start) + { + int i = start + 2; + while (i + 1 < source.Length && !(source[i] == '*' && source[i + 1] == '/')) + i++; + + return i + 1 >= source.Length ? source.Length : i + 1; + } +} + +internal readonly record struct SkslToken( + string Text, + bool IsIdentifier, + int BraceDepth, + int ParenthesisDepth, + int Start, + int Length) +{ + public int Depth => BraceDepth + ParenthesisDepth; +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SkslSnippetMerger.cs b/src/Beutl.Engine/Graphics/FilterEffects/SkslSnippetMerger.cs new file mode 100644 index 0000000000..5d3d6f0834 --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/SkslSnippetMerger.cs @@ -0,0 +1,727 @@ +using System.Collections.ObjectModel; +using System.Text; + +namespace Beutl.Graphics.Effects; + +internal enum SkslCoverageBehavior +{ + RequiresResolvedCoverage, + PremultipliedCoverageHomogeneous, +} + +internal enum SkslBindingKind +{ + Uniform, + Resource, +} + +internal enum SkslBackendLimit +{ + StageCount, + UniformVectors, + Samplers, + Children, + SourceBytes, + ProgramTokens, +} + +internal sealed class SkslSnippetStage +{ + public SkslSnippetStage( + ShaderDescription description, + SkslCoverageBehavior coverageBehavior = SkslCoverageBehavior.RequiresResolvedCoverage) + { + ArgumentNullException.ThrowIfNull(description); + if (description.Kind is not (ShaderDescriptionKind.CurrentPixel or ShaderDescriptionKind.WholeSource)) + { + throw new ArgumentException( + "Only validated CurrentPixel and WholeSource shader descriptions can participate in a snippet run.", + nameof(description)); + } + if (!Enum.IsDefined(coverageBehavior)) + throw new ArgumentOutOfRangeException(nameof(coverageBehavior)); + + Description = description; + CoverageBehavior = coverageBehavior; + } + + public ShaderDescription Description { get; } + + public SkslCoverageBehavior CoverageBehavior { get; } +} + +internal sealed class SkslBackendBudget : IEquatable +{ + private static readonly object s_unlimitedCapability = new(); + + public SkslBackendBudget( + object capabilityClass, + int maxStages, + int maxUniformVectors, + int maxSamplers, + int maxChildren, + int maxSourceBytes, + int maxProgramTokens) + { + ArgumentNullException.ThrowIfNull(capabilityClass); + ArgumentOutOfRangeException.ThrowIfLessThan(maxStages, 1); + ArgumentOutOfRangeException.ThrowIfNegative(maxUniformVectors); + ArgumentOutOfRangeException.ThrowIfNegative(maxSamplers); + ArgumentOutOfRangeException.ThrowIfLessThan(maxChildren, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maxSourceBytes, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maxProgramTokens, 1); + + CapabilityClass = capabilityClass; + MaxStages = maxStages; + MaxUniformVectors = maxUniformVectors; + MaxSamplers = maxSamplers; + MaxChildren = maxChildren; + MaxSourceBytes = maxSourceBytes; + MaxProgramTokens = maxProgramTokens; + } + + public static SkslBackendBudget Unlimited { get; } = new( + s_unlimitedCapability, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue, + int.MaxValue); + + public object CapabilityClass { get; } + + public int MaxStages { get; } + + public int MaxUniformVectors { get; } + + public int MaxSamplers { get; } + + public int MaxChildren { get; } + + public int MaxSourceBytes { get; } + + public int MaxProgramTokens { get; } + + public bool Equals(SkslBackendBudget? other) + => other is not null + && Equals(CapabilityClass, other.CapabilityClass) + && MaxStages == other.MaxStages + && MaxUniformVectors == other.MaxUniformVectors + && MaxSamplers == other.MaxSamplers + && MaxChildren == other.MaxChildren + && MaxSourceBytes == other.MaxSourceBytes + && MaxProgramTokens == other.MaxProgramTokens; + + public override bool Equals(object? obj) => obj is SkslBackendBudget other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine( + CapabilityClass, + MaxStages, + MaxUniformVectors, + MaxSamplers, + MaxChildren, + MaxSourceBytes, + MaxProgramTokens); +} + +internal sealed record SkslMergedBindingLayout( + int StageIndex, + int BindingIndex, + SkslBindingKind Kind, + string OriginalName, + string MergedName, + string Type, + int? ArrayExtent, + ShaderResourceCoordinateSpace? CoordinateSpace); + +internal sealed record SkslMergedStageLayout( + int StageIndex, + string Prefix, + SkslCoverageBehavior CoverageBehavior); + +internal sealed class SkslMergedProgram +{ + internal SkslMergedProgram( + string source, + IReadOnlyList stages, + IReadOnlyList bindings, + SkslBackendBudget budget, + int uniformVectorCount, + int samplerCount, + int childCount, + int sourceByteCount, + int programTokenCount, + IReadOnlyList overflowReasons) + { + Source = source; + Stages = new ReadOnlyCollection(stages.ToArray()); + Bindings = new ReadOnlyCollection(bindings.ToArray()); + Budget = budget; + UniformVectorCount = uniformVectorCount; + SamplerCount = samplerCount; + ChildCount = childCount; + SourceByteCount = sourceByteCount; + ProgramTokenCount = programTokenCount; + OverflowReasons = new ReadOnlyCollection(overflowReasons.ToArray()); + Identity = ShaderProgramIdentity.CreateSksl(Source, Bindings, Budget); + } + + public string Source { get; } + + public IReadOnlyList Stages { get; } + + public IReadOnlyList Bindings { get; } + + public SkslBackendBudget Budget { get; } + + public ShaderProgramIdentity Identity { get; } + + public int StageCount => Stages.Count; + + public int UniformVectorCount { get; } + + public int SamplerCount { get; } + + public int ChildCount { get; } + + public int SourceByteCount { get; } + + public int ProgramTokenCount { get; } + + public IReadOnlyList OverflowReasons { get; } + + public bool RequiresStandaloneExecution => OverflowReasons.Count != 0; + + public bool IsPremultipliedCoverageHomogeneous + => Stages.All(static stage => + stage.CoverageBehavior == SkslCoverageBehavior.PremultipliedCoverageHomogeneous); + + public bool RequiresResolvedCoverage => !IsPremultipliedCoverageHomogeneous; +} + +/// +/// A program-cache bucket identity. The stable hash is only the bucket selector; equality compares the complete +/// backend, generated source, binding signature, capability class, and relevant backend limits. +/// +internal sealed class ShaderProgramIdentity : IEquatable +{ + private readonly object[] _bindings; + + private ShaderProgramIdentity( + ShaderProgramBackend backend, + string source, + IEnumerable bindings, + object? descriptionIdentity, + SkslBackendBudget budget, + int? bucketHashOverride = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(budget); + if (!Enum.IsDefined(backend)) + throw new ArgumentOutOfRangeException(nameof(backend)); + + Backend = backend; + Source = source; + _bindings = bindings.ToArray(); + DescriptionIdentity = descriptionIdentity; + Budget = budget; + BucketHash = bucketHashOverride ?? ComputeStableBucketHash(backend, source); + } + + public int BucketHash { get; } + + private ShaderProgramBackend Backend { get; } + + private string Source { get; } + + private object? DescriptionIdentity { get; } + + private SkslBackendBudget Budget { get; } + + internal static ShaderProgramIdentity CreateSksl( + string source, + IReadOnlyList bindings, + SkslBackendBudget budget, + int? bucketHashOverride = null) + => new( + ShaderProgramBackend.Sksl, + source, + bindings.Cast(), + descriptionIdentity: null, + budget, + bucketHashOverride); + + internal static ShaderProgramIdentity CreateStandaloneSksl( + string source, + SkslBackendBudget budget) + => CreateSksl(source, [], budget); + + internal static ShaderProgramIdentity CreateSpirv( + ShaderDescription description, + SpirvShaderLowering lowering, + SkslBackendBudget budget) + => new( + ShaderProgramBackend.Spirv, + lowering.FragmentShaderSource, + lowering.PushConstants.Cast(), + description.GetStructuralIdentity(ShaderProgramBackend.Spirv), + budget); + + public bool Equals(ShaderProgramIdentity? other) + => other is not null + && BucketHash == other.BucketHash + && Backend == other.Backend + && Source == other.Source + && Equals(DescriptionIdentity, other.DescriptionIdentity) + && Budget.Equals(other.Budget) + && _bindings.AsSpan().SequenceEqual(other._bindings); + + public override bool Equals(object? obj) => obj is ShaderProgramIdentity other && Equals(other); + + public override int GetHashCode() => BucketHash; + + private static int ComputeStableBucketHash(ShaderProgramBackend backend, string source) + { + const uint offset = 2166136261; + const uint prime = 16777619; + uint hash = (offset ^ (byte)backend) * prime; + foreach (char value in source) + { + hash ^= value; + hash *= prime; + } + return unchecked((int)hash); + } +} + +/// +/// Composes a leading WholeSource shader and validated CurrentPixel snippets into whole-source SkSL while preserving +/// authored order. CurrentPixel declarations and the WholeSource entry point are alpha-renamed from lexer token +/// offsets, so comments and member/swizzle identifiers cannot be corrupted by textual replacement. Runs split before +/// the first stage that would overflow the selected backend budget. A stage that cannot fit by itself is returned as a +/// one-stage standalone fallback instead of disappearing. +/// +internal static class SkslSnippetMerger +{ + public const string SourceChildName = "src"; + + private const string GeneratedPixelName = "__beutl_pixel"; + private const string HeadPrefix = "__beutl_head_"; + private const string HeadEntryPointName = HeadPrefix + "main"; + private const string StagePrefix = "__beutl_s"; + private const string SourceHeader = "uniform shader src;\n"; + private const string MainHeader = + "half4 main(float2 coord) {\n" + + " half4 " + GeneratedPixelName + " = src.eval(coord);\n"; + private const string HeadMainHeader = + "half4 main(float2 coord) {\n" + + " half4 " + GeneratedPixelName + " = " + HeadEntryPointName + "(coord);\n"; + private const string MainFooter = " return " + GeneratedPixelName + ";\n}\n"; + private static readonly HashSet s_headEntryPoint = new(StringComparer.Ordinal) { "main" }; + private static readonly int s_currentPixelFixedSourceByteCount = + Encoding.UTF8.GetByteCount(SourceHeader) + + Encoding.UTF8.GetByteCount(MainHeader) + + Encoding.UTF8.GetByteCount(MainFooter); + private static readonly int s_currentPixelFixedProgramTokenCount = + SkslLexer.Tokenize(SourceHeader).Count + + SkslLexer.Tokenize(MainHeader).Count + + SkslLexer.Tokenize(MainFooter).Count; + private static readonly int s_headFixedSourceByteCount = + Encoding.UTF8.GetByteCount(HeadMainHeader) + + Encoding.UTF8.GetByteCount(MainFooter); + private static readonly int s_headFixedProgramTokenCount = + SkslLexer.Tokenize(HeadMainHeader).Count + + SkslLexer.Tokenize(MainFooter).Count; + + public static SkslMergedProgram Merge(IReadOnlyList stages) + { + PreparedStage[] prepared = ValidateAndPrepare(stages); + return CreateProgram( + prepared, + SkslBackendBudget.Unlimited, + CalculateMetrics(prepared)); + } + + public static IReadOnlyList MergeAndSplit( + IReadOnlyList stages, + SkslBackendBudget budget) + { + ArgumentNullException.ThrowIfNull(budget); + PreparedStage[] prepared = ValidateAndPrepare(stages); + + var result = new List(); + var current = new List(); + ProgramMetrics currentMetrics = default; + foreach (PreparedStage stage in prepared) + { + if (current.Count == 0) + currentMetrics = ProgramMetrics.CreateEmpty(stage.IsWholeSourceHead); + ProgramMetrics candidateMetrics = currentMetrics.Add(stage); + if (FitsBudget(candidateMetrics, budget)) + { + current.Add(stage); + currentMetrics = candidateMetrics; + continue; + } + + if (current.Count != 0) + { + result.Add(CreateProgram(current, budget, currentMetrics)); + current.Clear(); + currentMetrics = ProgramMetrics.CreateEmpty(stage.IsWholeSourceHead); + candidateMetrics = currentMetrics.Add(stage); + } + + if (!FitsBudget(candidateMetrics, budget)) + { + result.Add(CreateProgram([stage], budget, candidateMetrics)); + } + else + { + current.Add(stage); + currentMetrics = candidateMetrics; + } + } + + if (current.Count != 0) + result.Add(CreateProgram(current, budget, currentMetrics)); + + return new ReadOnlyCollection(result); + } + + private static PreparedStage[] ValidateAndPrepare(IReadOnlyList stages) + { + ArgumentNullException.ThrowIfNull(stages); + if (stages.Count == 0) + throw new ArgumentException("At least one shader stage is required.", nameof(stages)); + + var result = new PreparedStage[stages.Count]; + for (int index = 0; index < stages.Count; index++) + { + SkslSnippetStage stage = stages[index] + ?? throw new ArgumentException("A shader stage cannot be null.", nameof(stages)); + if (index != 0 && stage.Description.Kind == ShaderDescriptionKind.WholeSource) + { + throw new ArgumentException( + "A WholeSource shader can participate only as the first stage of a snippet run.", + nameof(stages)); + } + result[index] = Prepare(index, stage); + } + return result; + } + + private static PreparedStage Prepare(int index, SkslSnippetStage stage) + { + bool isWholeSourceHead = stage.Description.Kind == ShaderDescriptionKind.WholeSource; + string prefix = isWholeSourceHead ? HeadPrefix : GetPrefix(index); + IReadOnlySet renamedNames = isWholeSourceHead + ? s_headEntryPoint + : stage.Description.Source.TopLevelSymbols; + RenameResult renamed = Rename(stage.Description.Source, prefix, renamedNames); + bool appendNewline = renamed.Source.Length == 0 || renamed.Source[^1] != '\n'; + string invocation = isWholeSourceHead ? string.Empty : CreateInvocation(prefix); + var bindings = new List( + stage.Description.Uniforms.Count + stage.Description.Resources.Count); + AddBindings(index, stage, isWholeSourceHead ? string.Empty : prefix, bindings); + int sourceBytes = Encoding.UTF8.GetByteCount(renamed.Source); + if (appendNewline) + sourceBytes = SaturatingAdd(sourceBytes, 1); + int programTokens = renamed.TokenCount; + if (invocation.Length != 0) + { + sourceBytes = SaturatingAdd(sourceBytes, Encoding.UTF8.GetByteCount(invocation)); + programTokens = SaturatingAdd( + programTokens, + SkslLexer.Tokenize(invocation).Count); + } + return new PreparedStage( + index, + stage, + isWholeSourceHead, + prefix, + renamed.Source, + appendNewline, + invocation, + bindings.ToArray(), + GetUniformVectorCount(stage.Description.Source), + stage.Description.Resources.Count, + sourceBytes, + programTokens); + } + + private static SkslMergedProgram CreateProgram( + IReadOnlyList stages, + SkslBackendBudget budget, + ProgramMetrics metrics) + { + var source = new StringBuilder(); + bool hasWholeSourceHead = stages[0].IsWholeSourceHead; + if (!hasWholeSourceHead) + source.Append(SourceHeader); + var stageLayouts = new List(stages.Count); + var bindingLayouts = new List(); + + foreach (PreparedStage prepared in stages) + { + source.Append(prepared.Source); + if (prepared.AppendNewline) + source.Append('\n'); + + stageLayouts.Add(new SkslMergedStageLayout( + prepared.Index, + prepared.Prefix, + prepared.Stage.CoverageBehavior)); + bindingLayouts.AddRange(prepared.Bindings); + } + + source.Append(hasWholeSourceHead ? HeadMainHeader : MainHeader); + foreach (PreparedStage prepared in stages) + source.Append(prepared.Invocation); + source.Append(MainFooter); + + string mergedSource = source.ToString(); + IReadOnlyList overflow = GetOverflowReasons( + metrics.StageCount, + metrics.UniformVectorCount, + metrics.SamplerCount, + metrics.ChildCount, + metrics.SourceByteCount, + metrics.ProgramTokenCount, + budget); + + return new SkslMergedProgram( + mergedSource, + stageLayouts, + bindingLayouts, + budget, + metrics.UniformVectorCount, + metrics.SamplerCount, + metrics.ChildCount, + metrics.SourceByteCount, + metrics.ProgramTokenCount, + overflow); + } + + internal static bool IsRendererGeneratedName(string name) + { + if (name is GeneratedPixelName or HeadEntryPointName) + return true; + if (!name.StartsWith(StagePrefix, StringComparison.Ordinal)) + return false; + + ReadOnlySpan suffix = name.AsSpan(StagePrefix.Length); + int separator = suffix.IndexOf('_'); + return separator > 0 + && (separator == 1 || suffix[0] != '0') + && separator + 1 < suffix.Length + && int.TryParse(suffix[..separator], out int stageIndex) + && stageIndex >= 0; + } + + private static string GetPrefix(int stageIndex) => $"{StagePrefix}{stageIndex}_"; + + private static string CreateInvocation(string prefix) + => $" {GeneratedPixelName} = {prefix}apply({GeneratedPixelName});\n"; + + private static RenameResult Rename( + SkslSource source, + string prefix, + IReadOnlySet names) + { + List tokens = SkslLexer.Tokenize(source.Text); + var result = new StringBuilder(source.Text.Length + (names.Count * prefix.Length)); + int copiedThrough = 0; + for (int index = 0; index < tokens.Count; index++) + { + SkslToken token = tokens[index]; + if (!token.IsIdentifier + || !names.Contains(token.Text) + || index > 0 && tokens[index - 1].Text == ".") + { + continue; + } + + result.Append(source.Text, copiedThrough, token.Start - copiedThrough); + result.Append(prefix).Append(token.Text); + copiedThrough = token.Start + token.Length; + } + + result.Append(source.Text, copiedThrough, source.Text.Length - copiedThrough); + return new RenameResult(result.ToString(), tokens.Count); + } + + private static ProgramMetrics CalculateMetrics(IReadOnlyList stages) + { + ProgramMetrics result = ProgramMetrics.CreateEmpty(stages[0].IsWholeSourceHead); + foreach (PreparedStage stage in stages) + result = result.Add(stage); + return result; + } + + private static void AddBindings( + int stageIndex, + SkslSnippetStage stage, + string prefix, + List result) + { + ShaderDescription description = stage.Description; + for (int bindingIndex = 0; bindingIndex < description.Uniforms.Count; bindingIndex++) + { + ShaderUniformBinding binding = description.Uniforms[bindingIndex]; + SkslUniformDeclaration declaration = description.Source.Uniforms[binding.Name]; + result.Add(new SkslMergedBindingLayout( + stageIndex, + bindingIndex, + SkslBindingKind.Uniform, + binding.Name, + prefix + binding.Name, + declaration.Type, + declaration.ArrayExtent, + null)); + } + + for (int bindingIndex = 0; bindingIndex < description.Resources.Count; bindingIndex++) + { + ShaderResourceBinding binding = description.Resources[bindingIndex]; + SkslUniformDeclaration declaration = description.Source.Uniforms[binding.Name]; + result.Add(new SkslMergedBindingLayout( + stageIndex, + bindingIndex, + SkslBindingKind.Resource, + binding.Name, + prefix + binding.Name, + declaration.Type, + declaration.ArrayExtent, + binding.CoordinateSpace)); + } + } + + private static int GetUniformVectorCount(SkslSource source) + { + int result = 0; + foreach (SkslUniformDeclaration declaration in source.Uniforms.Values) + { + if (declaration.IsShader) + continue; + + int vectors = GetTypeVectorCount(declaration.Type); + if (declaration.ArrayExtent is int extent) + vectors = SaturatingMultiply(vectors, extent); + result = SaturatingAdd(result, vectors); + } + return result; + } + + private static int GetTypeVectorCount(string type) + { + if (type is "mat2" or "mat3" or "mat4") + return type[^1] - '0'; + + int separator = type.IndexOf('x', StringComparison.Ordinal); + if (separator > 0 + && separator + 1 < type.Length + && type.AsSpan(separator + 1).Length == 1 + && char.IsAsciiDigit(type[separator + 1])) + { + return type[separator + 1] - '0'; + } + + return 1; + } + + private static IReadOnlyList GetOverflowReasons( + int stages, + int uniforms, + int samplers, + int children, + int sourceBytes, + int programTokens, + SkslBackendBudget budget) + { + var result = new List(6); + if (stages > budget.MaxStages) + result.Add(SkslBackendLimit.StageCount); + if (uniforms > budget.MaxUniformVectors) + result.Add(SkslBackendLimit.UniformVectors); + if (samplers > budget.MaxSamplers) + result.Add(SkslBackendLimit.Samplers); + if (children > budget.MaxChildren) + result.Add(SkslBackendLimit.Children); + if (sourceBytes > budget.MaxSourceBytes) + result.Add(SkslBackendLimit.SourceBytes); + if (programTokens > budget.MaxProgramTokens) + result.Add(SkslBackendLimit.ProgramTokens); + return result; + } + + private static bool FitsBudget(ProgramMetrics metrics, SkslBackendBudget budget) + => metrics.StageCount <= budget.MaxStages + && metrics.UniformVectorCount <= budget.MaxUniformVectors + && metrics.SamplerCount <= budget.MaxSamplers + && metrics.ChildCount <= budget.MaxChildren + && metrics.SourceByteCount <= budget.MaxSourceBytes + && metrics.ProgramTokenCount <= budget.MaxProgramTokens; + + private static int SaturatingAdd(int left, int right) + { + long result = (long)left + right; + return result >= int.MaxValue ? int.MaxValue : (int)result; + } + + private static int SaturatingMultiply(int left, int right) + { + long result = (long)left * right; + return result >= int.MaxValue ? int.MaxValue : (int)result; + } + + private sealed record PreparedStage( + int Index, + SkslSnippetStage Stage, + bool IsWholeSourceHead, + string Prefix, + string Source, + bool AppendNewline, + string Invocation, + SkslMergedBindingLayout[] Bindings, + int UniformVectorCount, + int ResourceCount, + int SourceByteCount, + int ProgramTokenCount); + + private readonly record struct RenameResult(string Source, int TokenCount); + + private readonly record struct ProgramMetrics( + int StageCount, + int UniformVectorCount, + int SamplerCount, + int ChildCount, + int SourceByteCount, + int ProgramTokenCount) + { + public static ProgramMetrics CreateEmpty(bool hasWholeSourceHead) + => new( + 0, + 0, + 1, + 1, + hasWholeSourceHead + ? s_headFixedSourceByteCount + : s_currentPixelFixedSourceByteCount, + hasWholeSourceHead + ? s_headFixedProgramTokenCount + : s_currentPixelFixedProgramTokenCount); + + public ProgramMetrics Add(PreparedStage stage) + => new( + SaturatingAdd(StageCount, 1), + SaturatingAdd(UniformVectorCount, stage.UniformVectorCount), + SaturatingAdd(SamplerCount, stage.ResourceCount), + SaturatingAdd(ChildCount, stage.ResourceCount), + SaturatingAdd(SourceByteCount, stage.SourceByteCount), + SaturatingAdd(ProgramTokenCount, stage.ProgramTokenCount)); + } +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs b/src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs new file mode 100644 index 0000000000..6cb437222b --- /dev/null +++ b/src/Beutl.Engine/Graphics/FilterEffects/SkslSource.cs @@ -0,0 +1,929 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Text; +using System.Text.RegularExpressions; + +namespace Beutl.Graphics.Effects; + +/// Identifies the execution model and entry-point contract of a shader description. +public enum ShaderDescriptionKind +{ + /// + /// Transforms one coverage-resolved, premultiplied linear-light pixel through + /// half4 apply(half4 color). + /// + /// + /// Current-pixel stages have no output-position coordinate and may fuse only with structurally compatible + /// adjacent stages after analytic or antialiased coverage has been resolved. + /// + CurrentPixel, + + /// + /// Materializes a complete source through half4 main(float2 coord) and may sample arbitrary upstream + /// locations. + /// + /// + /// Whole-source stages must declare the implicit src child shader. They may lead a fused run but cannot + /// consume an earlier stage inside that run. + /// + WholeSource, +} + +/// Provides normalized SkSL source that passed Beutl's description-level contract checks. +/// +/// Instances are created by and +/// . The source model is immutable. These checks are not a complete SkSL +/// compiler; backend program validation may still reject a source during execution. +/// +public sealed partial class SkslSource +{ + [GeneratedRegex( + @"\buniform\s+(?:(?:lowp|mediump|highp)\s+)?(?[A-Za-z_][A-Za-z0-9_]*)\s+(?[A-Za-z_][A-Za-z0-9_]*)(?\s*\[\s*(?[^\]]*)\s*\])?\s*;", + RegexOptions.CultureInvariant)] + private static partial Regex UniformRegex(); + + [GeneratedRegex( + @"\buniform\s+(?:(?:lowp|mediump|highp)\s+)?[A-Za-z_][A-Za-z0-9_]*(?:\s*\[[^\]]*\])*\s+[A-Za-z_][A-Za-z0-9_]*(?:\s*\[[^\]]*\])*\s*,", + RegexOptions.CultureInvariant)] + private static partial Regex MultiDeclaratorUniformRegex(); + + [GeneratedRegex( + @"\bhalf4\s+apply\s*\(\s*half4\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*\)", + RegexOptions.CultureInvariant)] + private static partial Regex CurrentPixelEntryRegex(); + + [GeneratedRegex( + @"\bhalf4\s+main\s*\(\s*float2\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*\)", + RegexOptions.CultureInvariant)] + private static partial Regex WholeSourceEntryRegex(); + + private readonly IReadOnlyDictionary _uniforms; + private readonly IReadOnlySet? _topLevelSymbols; + + /// Parses and validates SkSL for a current-pixel stage. + /// + /// Non-null SkSL defining exactly one half4 apply(half4 color) entry point. + /// + /// An immutable parsed source that any number of definitions can share. + /// + /// Parsing once and reusing the result keeps a definition's recording free of re-tokenization, which is + /// what the engine's own effects do with their compile-time constant sources. + /// + /// is . + /// + /// The source grammar, entry point, or declarations are invalid. + /// + public static SkslSource CurrentPixel(string source) + => new(source, ShaderDescriptionKind.CurrentPixel); + + /// Parses and validates SkSL for a whole-source stage. + /// + /// Non-null SkSL defining exactly one half4 main(float2 coord) entry point and declaring the + /// implicit upstream input as uniform shader src;. + /// + /// An immutable parsed source that any number of definitions can share. + /// + public static SkslSource WholeSource(string source) + => new(source, ShaderDescriptionKind.WholeSource); + + internal SkslSource(string text, ShaderDescriptionKind kind) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + string normalized = Normalize(text); + List tokens = SkslLexer.Tokenize(normalized); + ValidateBalancedTokens(tokens); + if (kind == ShaderDescriptionKind.CurrentPixel) + { + CurrentPixelValidationResult validation = new CurrentPixelValidator(tokens).Validate(); + _uniforms = validation.Uniforms; + _topLevelSymbols = validation.TopLevelSymbols; + } + else + { + _uniforms = ParseUniforms(normalized); + ValidateWholeSourceEntryPoint(normalized); + ValidateWholeSourceReservedDeclarations(tokens); + } + + Text = normalized; + Kind = kind; + IdentityHash = ComputeHash(normalized); + } + + /// Gets the contract-checked source normalized to LF line endings with one trailing newline. + public string Text { get; } + + /// Gets a deterministic, non-cryptographic hash of the normalized source. + /// + /// The hash is a convenience value, not a unique identity. Do not use it as the sole equality key; the renderer's + /// own reuse contract compares the complete normalized source and the remaining structural metadata. + /// + public string IdentityHash { get; } + + /// Gets the entry-point and execution contract validated for this source. + public ShaderDescriptionKind Kind { get; } + + internal IReadOnlyDictionary Uniforms => _uniforms; + + internal IReadOnlySet TopLevelSymbols + => _topLevelSymbols + ?? throw new InvalidOperationException( + "Top-level symbol metadata is available only for CurrentPixel sources."); + + internal static bool HasCurrentPixelEntryPoint(string source) + { + if (string.IsNullOrWhiteSpace(source)) + return false; + + return CurrentPixelEntryRegex().IsMatch(SkslLexer.StripComments(source)); + } + + internal static bool HasUniformDeclaration(string source, string name) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + string stripped = SkslLexer.StripComments(source); + foreach (Match match in UniformRegex().Matches(stripped)) + { + if (match.Groups["name"].Value == name) + return true; + } + + return false; + } + + private static string Normalize(string source) + { + string normalized = source.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Trim(); + return normalized + "\n"; + } + + private static void ValidateBalancedTokens(IReadOnlyList tokens) + { + int braces = 0; + int parentheses = 0; + int brackets = 0; + foreach (SkslToken token in tokens) + { + switch (token.Text) + { + case "{": + braces++; + break; + case "}": + braces--; + break; + case "(": + parentheses++; + break; + case ")": + parentheses--; + break; + case "[": + brackets++; + break; + case "]": + brackets--; + break; + } + + if (braces < 0 || parentheses < 0 || brackets < 0) + throw new ArgumentException("The SkSL source has unbalanced delimiters.", "source"); + } + + if (braces != 0 || parentheses != 0 || brackets != 0) + throw new ArgumentException("The SkSL source has unbalanced delimiters.", "source"); + } + + private static IReadOnlyDictionary ParseUniforms(string source) + { + string stripped = SkslLexer.StripComments(source); + if (MultiDeclaratorUniformRegex().IsMatch(stripped)) + { + throw new ArgumentException( + "Each shader uniform must use its own declaration so binding names can be rewritten safely.", + nameof(source)); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (Match match in UniformRegex().Matches(stripped)) + { + string name = match.Groups["name"].Value; + string type = match.Groups["type"].Value; + int? extent = null; + if (match.Groups["array"].Success) + { + string value = match.Groups["extent"].Value.Trim(); + if (!int.TryParse(value, out int parsed) || parsed <= 0) + throw new ArgumentException("Shader uniform arrays require a positive fixed extent.", nameof(source)); + extent = parsed; + } + + if (SkslSnippetMerger.IsRendererGeneratedName(name) + || name.StartsWith("fe", StringComparison.Ordinal) && name.Contains('_', StringComparison.Ordinal)) + { + throw new ArgumentException($"The shader binding name '{name}' is reserved by the renderer.", nameof(source)); + } + + if (!result.TryAdd(name, new SkslUniformDeclaration(type, extent))) + throw new ArgumentException($"The shader declares duplicate binding '{name}'.", nameof(source)); + } + + return new ReadOnlyDictionary(result); + } + + private static void ValidateWholeSourceEntryPoint(string source) + { + string stripped = SkslLexer.StripComments(source); + MatchCollection currentEntries = CurrentPixelEntryRegex().Matches(stripped); + MatchCollection wholeEntries = WholeSourceEntryRegex().Matches(stripped); + if (wholeEntries.Count != 1 || currentEntries.Count != 0) + { + throw new ArgumentException( + "A WholeSource shader must define exactly one 'half4 main(float2 coord)' entry point and no apply entry point.", + nameof(source)); + } + } + + private static void ValidateWholeSourceReservedDeclarations(IReadOnlyList tokens) + { + for (int index = 0; index < tokens.Count; index++) + { + SkslToken token = tokens[index]; + if (!token.IsIdentifier + || token.Depth != 0 + || !SkslSnippetMerger.IsRendererGeneratedName(token.Text) + || index > 0 && tokens[index - 1].Text == "." + || index + 1 >= tokens.Count) + { + continue; + } + + string next = tokens[index + 1].Text; + if (!IsTopLevelDeclarationBoundary(next)) + continue; + + throw new ArgumentException( + $"The top-level shader declaration name '{token.Text}' is reserved by the renderer.", + "source"); + } + } + + private static bool IsTopLevelDeclarationBoundary(string token) + => token is "(" or "=" or "[" or ";" or "{" or ","; + + private sealed class CurrentPixelValidator + { + private static readonly HashSet s_precisionQualifiers = + new(StringComparer.Ordinal) { "lowp", "mediump", "highp" }; + + private static readonly HashSet s_valueTypes = new(StringComparer.Ordinal) + { + "bool", + "int", "int2", "int3", "int4", + "uint", "uint2", "uint3", "uint4", + "half", "half2", "half3", "half4", + "float", "float2", "float3", "float4", + "half2x2", "half3x3", "half4x4", + "float2x2", "float3x3", "float4x4", + "mat2", "mat3", "mat4", + }; + + private static readonly HashSet s_uniformTypes = new(StringComparer.Ordinal) + { + "bool", + "int", "int2", "int3", "int4", + "half", "half2", "half3", "half4", + "float", "float2", "float3", "float4", + "half2x2", "half3x3", "half4x4", + "float2x2", "float3x3", "float4x4", + "mat2", "mat3", "mat4", + "shader", + }; + + // Only deterministic value functions whose result is wholly determined by their explicit arguments are + // accepted. In particular, derivative, sample, coordinate and stage-interface built-ins are absent. + private static readonly HashSet s_valueBuiltins = new(StringComparer.Ordinal) + { + "abs", "acos", "all", "any", "asin", "atan", "atan2", + "ceil", "clamp", "cos", "cross", "degrees", "determinant", "distance", "dot", + "equal", "exp", "exp2", "faceforward", "floor", "fract", "frexp", + "fromLinearSrgb", "inverse", "inversesqrt", "length", + "lessThan", "lessThanEqual", "log", "log2", "matrixCompMult", "max", "min", "mix", "mod", + "normalize", "not", "notEqual", "pow", "premul", "radians", "reflect", "refract", "round", + "saturate", "sign", "sin", "smoothstep", "sqrt", "step", "tan", "toLinearSrgb", "transpose", + "trunc", "unpremul", + }; + + private static readonly HashSet s_languageKeywords = new(StringComparer.Ordinal) + { + "break", "const", "continue", "do", "else", "false", "for", "if", "return", "true", + "uniform", "while", + }; + + private static readonly HashSet s_forbiddenIdentifiers = new(StringComparer.Ordinal) + { + "dFdx", "dFdy", "fwidth", + "fragCoord", "deviceCoord", "sampleCoord", "sampleCoords", + }; + + private readonly IReadOnlyList _tokens; + private readonly Dictionary _uniforms = new(StringComparer.Ordinal); + private readonly Dictionary _globals = new(StringComparer.Ordinal); + private readonly HashSet _allLocalNames = new(StringComparer.Ordinal); + private readonly List _functions = []; + private readonly List _globalInitializers = []; + private int _applyCount; + + internal CurrentPixelValidator(IReadOnlyList tokens) + { + _tokens = tokens; + } + + internal CurrentPixelValidationResult Validate() + { + ParseTopLevel(); + if (_applyCount != 1) + { + throw ValidationError( + "A CurrentPixel shader must define exactly one 'half4 apply(half4 color)' entry point."); + } + + foreach (ExpressionRange initializer in _globalInitializers) + { + ValidateIdentifiers(initializer.Start, initializer.End, null); + ValidateResourceSampling(initializer.Start, initializer.End, null); + } + foreach (FunctionDeclaration function in _functions) + ValidateFunction(function); + + return new CurrentPixelValidationResult( + new ReadOnlyDictionary(_uniforms), + new HashSet(_globals.Keys, StringComparer.Ordinal)); + } + + private void ParseTopLevel() + { + int index = 0; + while (index < _tokens.Count) + { + string token = _tokens[index].Text; + index = token switch + { + "uniform" => ParseUniform(index), + "const" => ParseGlobalConstant(index), + ";" => throw ValidationError("Empty top-level declarations are not supported by CurrentPixel."), + _ => ParseFunction(index), + }; + } + } + + private int ParseUniform(int start) + { + int index = start + 1; + SkipPrecision(ref index); + string type = ReadIdentifier(ref index, "A CurrentPixel uniform requires a supported type."); + if (!s_uniformTypes.Contains(type)) + throw ValidationError($"CurrentPixel uniform type '{type}' is not supported."); + + string name = ReadIdentifier(ref index, "A CurrentPixel uniform requires one binding name."); + int? extent = ParseOptionalFixedArray(ref index); + Expect(index, ";", "Each CurrentPixel uniform must use one complete declaration."); + index++; + + if (type == "shader" && extent is not null) + throw ValidationError("CurrentPixel shader-resource arrays are not supported."); + ValidateDeclaredName(name, allowApply: false); + AddGlobal(name, type == "shader" ? SymbolKind.Shader : SymbolKind.Value); + if (!_uniforms.TryAdd(name, new SkslUniformDeclaration(type, extent))) + throw ValidationError($"The shader declares duplicate binding '{name}'."); + return index; + } + + private int ParseGlobalConstant(int start) + { + int index = start + 1; + SkipPrecision(ref index); + string type = ReadIdentifier(ref index, "A CurrentPixel constant requires a value type."); + if (!s_valueTypes.Contains(type)) + throw ValidationError($"CurrentPixel constant type '{type}' is not supported."); + + string name = ReadIdentifier(ref index, "A CurrentPixel constant requires one name."); + _ = ParseOptionalFixedArray(ref index); + Expect(index, "=", "A top-level CurrentPixel constant requires an initializer."); + int expressionStart = ++index; + int end = FindStatementEnd(index); + if (expressionStart == end) + throw ValidationError("A top-level CurrentPixel constant requires an initializer."); + if (ContainsTopLevelComma(expressionStart, end)) + throw ValidationError("Each top-level CurrentPixel constant must use its own declaration."); + if (ContainsToken(expressionStart, end, "{") || ContainsToken(expressionStart, end, "}")) + throw ValidationError("Brace initializers are not supported by the CurrentPixel merger."); + + ValidateDeclaredName(name, allowApply: false); + AddGlobal(name, SymbolKind.Value); + _globalInitializers.Add(new ExpressionRange(expressionStart, end)); + return end + 1; + } + + private int ParseFunction(int start) + { + int index = start; + SkipPrecision(ref index); + string returnType = ReadIdentifier(ref index, "CurrentPixel supports only value-returning helper functions."); + if (!s_valueTypes.Contains(returnType)) + throw ValidationError($"CurrentPixel function return type '{returnType}' is not supported."); + + string name = ReadIdentifier(ref index, "A CurrentPixel function requires a name."); + ValidateDeclaredName(name, allowApply: name == "apply"); + Expect(index, "(", "A CurrentPixel top-level declaration must be a complete function definition."); + int openParameters = index; + int closeParameters = FindMatching(openParameters, "(", ")"); + index = closeParameters + 1; + Expect(index, "{", "CurrentPixel function prototypes and mutable global declarations are not supported."); + int openBody = index; + int closeBody = FindMatching(openBody, "{", "}"); + + var locals = new HashSet(StringComparer.Ordinal); + List parameters = ParseParameters(openParameters + 1, closeParameters, locals); + if (name == "apply") + { + _applyCount++; + if (returnType != "half4" + || parameters.Count != 1 + || parameters[0] != new ParameterDeclaration("half4", "color", null)) + { + throw ValidationError( + "The CurrentPixel entry point must be exactly 'half4 apply(half4 color)'."); + } + } + else if (name == "main") + { + throw ValidationError("CurrentPixel shaders cannot define a whole-source main entry point."); + } + + AddGlobal(name, SymbolKind.Function); + ParseLocalDeclarations(openBody + 1, closeBody, locals); + _functions.Add(new FunctionDeclaration(openBody + 1, closeBody, locals)); + return closeBody + 1; + } + + private List ParseParameters( + int start, + int end, + HashSet locals) + { + var result = new List(); + if (start == end) + return result; + + int segmentStart = start; + while (segmentStart < end) + { + int segmentEnd = FindTopLevelCommaOrEnd(segmentStart, end); + int index = segmentStart; + SkipPrecision(ref index, segmentEnd); + string type = ReadIdentifier( + ref index, + "CurrentPixel function parameters require an unqualified value type.", + segmentEnd); + if (!s_valueTypes.Contains(type)) + throw ValidationError($"CurrentPixel function parameter type '{type}' is not supported."); + string name = ReadIdentifier( + ref index, + "Each CurrentPixel function parameter requires one name.", + segmentEnd); + int? extent = ParseOptionalFixedArray(ref index, segmentEnd); + if (index != segmentEnd) + throw ValidationError("CurrentPixel function parameters cannot use qualifiers or multi-declarators."); + + AddLocal(name, locals); + result.Add(new ParameterDeclaration(type, name, extent)); + segmentStart = segmentEnd + 1; + } + + return result; + } + + private void ParseLocalDeclarations(int start, int end, HashSet locals) + { + int index = start; + while (index < end) + { + if (!TryGetLocalDeclaration(index, start, out int typeIndex, out int nameIndex)) + { + index++; + continue; + } + + string type = _tokens[typeIndex].Text; + if (!s_valueTypes.Contains(type)) + throw ValidationError($"CurrentPixel local type '{type}' is not supported."); + string name = _tokens[nameIndex].Text; + int cursor = nameIndex + 1; + _ = ParseOptionalFixedArray(ref cursor, end); + int statementEnd = FindStatementEnd(cursor, end); + if (ContainsTopLevelComma(cursor, statementEnd)) + throw ValidationError("Each CurrentPixel local must use its own declaration."); + + if (cursor == statementEnd || _tokens[cursor].Text != "=") + { + throw ValidationError( + "CurrentPixel locals require a value-derived initializer and support only fixed array extents."); + } + + AddLocal(name, locals); + index = statementEnd + 1; + } + } + + private bool TryGetLocalDeclaration(int index, int bodyStart, out int typeIndex, out int nameIndex) + { + typeIndex = index; + nameIndex = -1; + if (!IsDeclarationStart(index, bodyStart)) + return false; + + if (_tokens[typeIndex].Text == "const") + typeIndex++; + if (typeIndex < _tokens.Count && s_precisionQualifiers.Contains(_tokens[typeIndex].Text)) + typeIndex++; + if (typeIndex + 1 >= _tokens.Count + || !_tokens[typeIndex].IsIdentifier + || !s_valueTypes.Contains(_tokens[typeIndex].Text) + || !_tokens[typeIndex + 1].IsIdentifier) + { + return false; + } + + nameIndex = typeIndex + 1; + return true; + } + + private bool IsDeclarationStart(int index, int bodyStart) + { + if (index == bodyStart) + return true; + string previous = _tokens[index - 1].Text; + if (previous is "{" or ";" or "}") + return true; + return previous == "(" + && index >= 2 + && _tokens[index - 2].Text == "for"; + } + + private void ValidateFunction(FunctionDeclaration function) + { + ValidateIdentifiers(function.BodyStart, function.BodyEnd, function.Locals); + ValidateResourceSampling(function.BodyStart, function.BodyEnd, function.Locals); + } + + private void ValidateResourceSampling( + int start, + int end, + HashSet? locals) + { + for (int index = start; index + 2 < end; index++) + { + if (_tokens[index].Text != "." || _tokens[index + 1].Text != "eval") + continue; + + if (index == start + || !_tokens[index - 1].IsIdentifier + || Resolve(_tokens[index - 1].Text, locals) != SymbolKind.Shader + || _tokens[index + 2].Text != "(") + { + throw ValidationError( + "CurrentPixel resource sampling must use a directly declared shader binding followed by '.eval(...)'."); + } + + int close = FindMatching(index + 2, "(", ")"); + if (close > end) + throw ValidationError("A CurrentPixel resource eval call escapes its validated expression."); + int argumentStart = index + 3; + if (argumentStart == close || ContainsTopLevelComma(argumentStart, close)) + { + throw ValidationError( + "CurrentPixel resource eval requires exactly one value-coordinate expression."); + } + } + } + + private void ValidateIdentifiers(int start, int end, HashSet? locals) + { + for (int index = start; index < end; index++) + { + SkslToken token = _tokens[index]; + if (!token.IsIdentifier) + continue; + + string name = token.Text; + if (name.StartsWith("sk_", StringComparison.Ordinal) + || s_forbiddenIdentifiers.Contains(name)) + { + throw ValidationError( + $"CurrentPixel cannot use coordinate, derivative, or stage built-in '{name}'."); + } + + if (index > start && _tokens[index - 1].Text == ".") + { + if (name != "eval" && !IsSwizzle(name)) + { + throw ValidationError( + $"CurrentPixel member '{name}' is not a value swizzle or restricted resource eval."); + } + continue; + } + + if (s_languageKeywords.Contains(name) + || s_precisionQualifiers.Contains(name) + || s_valueTypes.Contains(name)) + { + continue; + } + + if (s_valueBuiltins.Contains(name)) + { + if (index + 1 >= end || _tokens[index + 1].Text != "(") + throw ValidationError($"CurrentPixel built-in '{name}' must be called directly."); + continue; + } + + SymbolKind? kind = Resolve(name, locals); + switch (kind) + { + case SymbolKind.Value: + break; + case SymbolKind.Function: + if (index + 1 >= end || _tokens[index + 1].Text != "(") + throw ValidationError("CurrentPixel helper functions cannot be retained as values."); + break; + case SymbolKind.Shader: + if (index + 3 >= end + || _tokens[index + 1].Text != "." + || _tokens[index + 2].Text != "eval" + || _tokens[index + 3].Text != "(") + { + throw ValidationError( + $"CurrentPixel shader resource '{name}' may be used only through restricted '.eval(...)'."); + } + break; + default: + throw ValidationError( + $"CurrentPixel identifier '{name}' is not a declared value or an allowed deterministic built-in."); + } + } + } + + private SymbolKind? Resolve(string name, HashSet? locals) + { + if (locals?.Contains(name) == true) + return SymbolKind.Value; + return _globals.TryGetValue(name, out SymbolKind kind) ? kind : null; + } + + private void AddGlobal(string name, SymbolKind kind) + { + if (_allLocalNames.Contains(name) || !_globals.TryAdd(name, kind)) + throw ValidationError($"CurrentPixel declaration '{name}' conflicts with another declaration."); + } + + private void AddLocal(string name, HashSet locals) + { + ValidateDeclaredName(name, allowApply: false); + if (_globals.ContainsKey(name) || !locals.Add(name)) + throw ValidationError($"CurrentPixel local '{name}' shadows another declaration."); + _allLocalNames.Add(name); + } + + private static void ValidateDeclaredName(string name, bool allowApply) + { + if (name == "src" || name == "main" || name == "apply" && !allowApply) + { + throw ValidationError($"CurrentPixel declaration name '{name}' is reserved by the renderer."); + } + if (name.StartsWith("__beutl", StringComparison.Ordinal) + || name.StartsWith("fe", StringComparison.Ordinal) && name.Contains('_', StringComparison.Ordinal) + || name.StartsWith("sk_", StringComparison.Ordinal) + || s_languageKeywords.Contains(name) + || s_precisionQualifiers.Contains(name) + || s_valueTypes.Contains(name) + || s_valueBuiltins.Contains(name) + || s_forbiddenIdentifiers.Contains(name)) + { + throw ValidationError($"CurrentPixel declaration name '{name}' cannot be renamed safely."); + } + } + + private bool ContainsToken(int start, int end, string value) + { + for (int index = start; index < end; index++) + { + if (_tokens[index].Text == value) + return true; + } + return false; + } + + private int FindStatementEnd(int start, int limit = int.MaxValue) + { + int parentheses = 0; + int brackets = 0; + int braces = 0; + int end = Math.Min(limit, _tokens.Count); + for (int index = start; index < end; index++) + { + switch (_tokens[index].Text) + { + case "(": parentheses++; break; + case ")": + if (parentheses == 0) + throw ValidationError("A CurrentPixel declaration crosses its containing scope."); + parentheses--; + break; + case "[": brackets++; break; + case "]": + if (brackets == 0) + throw ValidationError("A CurrentPixel declaration has an invalid array expression."); + brackets--; + break; + case "{": braces++; break; + case "}": + if (braces == 0) + throw ValidationError("A CurrentPixel declaration crosses its containing block."); + braces--; + break; + case ";" when parentheses == 0 && brackets == 0 && braces == 0: + return index; + } + } + + throw ValidationError("A CurrentPixel declaration must end with ';'."); + } + + private bool ContainsTopLevelComma(int start, int end) + { + int parentheses = 0; + int brackets = 0; + int braces = 0; + for (int index = start; index < end; index++) + { + switch (_tokens[index].Text) + { + case "(": parentheses++; break; + case ")": parentheses--; break; + case "[": brackets++; break; + case "]": brackets--; break; + case "{": braces++; break; + case "}": braces--; break; + case "," when parentheses == 0 && brackets == 0 && braces == 0: + return true; + } + } + return false; + } + + private int FindTopLevelCommaOrEnd(int start, int end) + { + int parentheses = 0; + int brackets = 0; + for (int index = start; index < end; index++) + { + switch (_tokens[index].Text) + { + case "(": parentheses++; break; + case ")": parentheses--; break; + case "[": brackets++; break; + case "]": brackets--; break; + case "," when parentheses == 0 && brackets == 0: + return index; + } + } + return end; + } + + private int FindMatching(int openIndex, string open, string close) + { + Expect(openIndex, open, $"Expected '{open}'."); + int depth = 0; + for (int index = openIndex; index < _tokens.Count; index++) + { + if (_tokens[index].Text == open) + depth++; + else if (_tokens[index].Text == close && --depth == 0) + return index; + } + throw ValidationError($"The CurrentPixel source has an unmatched '{open}'."); + } + + private int? ParseOptionalFixedArray(ref int index, int limit = int.MaxValue) + { + int end = Math.Min(limit, _tokens.Count); + if (index >= end || _tokens[index].Text != "[") + return null; + if (index + 2 >= end + || !int.TryParse(_tokens[index + 1].Text, out int extent) + || extent <= 0 + || _tokens[index + 2].Text != "]") + { + throw ValidationError("CurrentPixel arrays require one positive fixed integer extent."); + } + index += 3; + return extent; + } + + private void SkipPrecision(ref int index, int limit = int.MaxValue) + { + if (index < Math.Min(limit, _tokens.Count) && s_precisionQualifiers.Contains(_tokens[index].Text)) + index++; + } + + private string ReadIdentifier(ref int index, string message, int limit = int.MaxValue) + { + if (index >= Math.Min(limit, _tokens.Count) || !_tokens[index].IsIdentifier) + throw ValidationError(message); + return _tokens[index++].Text; + } + + private void Expect(int index, string expected, string message) + { + if (index >= _tokens.Count || _tokens[index].Text != expected) + throw ValidationError(message); + } + + private static bool IsSwizzle(string name) + { + if (name.Length is < 1 or > 4) + return false; + return IsSwizzleAlphabet(name, "xyzw") + || IsSwizzleAlphabet(name, "rgba") + || IsSwizzleAlphabet(name, "stpq"); + } + + private static bool IsSwizzleAlphabet(string value, string alphabet) + { + foreach (char current in value) + { + if (!alphabet.Contains(current, StringComparison.Ordinal)) + return false; + } + return true; + } + + private static ArgumentException ValidationError(string message) + => new(message, "source"); + + private enum SymbolKind + { + Value, + Shader, + Function, + } + + private readonly record struct ParameterDeclaration(string Type, string Name, int? ArrayExtent); + + private readonly record struct ExpressionRange(int Start, int End); + + private sealed record FunctionDeclaration(int BodyStart, int BodyEnd, HashSet Locals); + } + + private sealed record CurrentPixelValidationResult( + IReadOnlyDictionary Uniforms, + IReadOnlySet TopLevelSymbols); + + private static string ComputeHash(string source) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + const int stackBufferSize = 512; + int byteCount = Encoding.UTF8.GetByteCount(source); + byte[]? rented = null; + Span bytes = byteCount <= stackBufferSize + ? stackalloc byte[byteCount] + : (rented = ArrayPool.Shared.Rent(byteCount)); + ulong hash = offset; + try + { + int written = Encoding.UTF8.GetBytes(source, bytes); + foreach (byte value in bytes[..written]) + { + hash ^= value; + hash *= prime; + } + } + finally + { + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + + return hash.ToString("x16"); + } +} + +internal readonly record struct SkslUniformDeclaration(string Type, int? ArrayExtent) +{ + public bool IsShader => Type == "shader"; +} diff --git a/src/Beutl.Engine/Graphics/FilterEffects/SplitEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/SplitEffect.cs index 2ed16d53ae..10e41ecd51 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/SplitEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/SplitEffect.cs @@ -59,7 +59,8 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource t.Bounds.Height + (d.VerticalSpacing * (d.VerticalDivisions - 1))); newBounds = t.Bounds.CenterRect(newBounds); - var newTargets = new EffectTarget[d.HorizontalDivisions * d.VerticalDivisions]; + var newTargets = new EffectTargets(); + bool allocationFailed = false; for (int v = 0; v < d.VerticalDivisions; v++) { @@ -71,6 +72,12 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource newBounds.Y + (divHeight + d.VerticalSpacing) * v, divWidth, divHeight)); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + allocationFailed = true; + break; + } // Crop offset is device px; draw in device space. using (ImmediateCanvas canvas = effectContext.Open(newTarget)) @@ -80,16 +87,46 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource canvas.DrawRenderTarget(renderTarget, new Point(-divWidth * h * w, -divHeight * v * w)); } - newTargets[v * d.HorizontalDivisions + h] = newTarget; + newTargets.Add(newTarget); } + + if (allocationFailed) + break; + } + + if (allocationFailed) + { + newTargets.Dispose(); + continue; } t.Dispose(); effectContext.Targets.RemoveAt(i); effectContext.Targets.InsertRange(i, newTargets); - i += newTargets.Length - 1; + i += newTargets.Count - 1; } } - }); + }, + TransformBounds); + } + + private static Rect TransformBounds( + (int HorizontalDivisions, int VerticalDivisions, float HorizontalSpacing, float VerticalSpacing) d, + Rect bounds) + { + // Negative spacing walks a tile back past the layout box by a distance that depends on the + // individual target's width, which the aggregate rectangle cannot bound. + if (d.HorizontalSpacing < 0 || d.VerticalSpacing < 0) + return Rect.Invalid; + + if (d.HorizontalDivisions < 1 || d.VerticalDivisions < 1) + return Rect.Empty; + + return bounds.CenterRect( + new Rect( + 0, + 0, + bounds.Width + (d.HorizontalSpacing * (d.HorizontalDivisions - 1)), + bounds.Height + (d.VerticalSpacing * (d.VerticalDivisions - 1)))); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/StrokeEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/StrokeEffect.cs index 21275cbfdb..8560bf0a4d 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/StrokeEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/StrokeEffect.cs @@ -78,7 +78,7 @@ static SKPath CreateBorderPath(Bitmap src) { EffectTarget target = context.Targets[i]; RenderTarget srcRenderTarget = target.RenderTarget!; - using var src = srcRenderTarget.Snapshot(); + using Bitmap src = srcRenderTarget.SnapshotAlpha(); // The contour path is device px; map to logical (/ w) for logical pen width/offset. float w = context.WorkingScale; @@ -91,6 +91,12 @@ static SKPath CreateBorderPath(Bitmap src) target.Bounds.Y - transformedBounds.Y); EffectTarget newTarget = context.CreateTarget(transformedBounds); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + continue; + } + using (ImmediateCanvas newCanvas = context.Open(newTarget)) using (newCanvas.PushTransform(origin)) { diff --git a/src/Beutl.Engine/Graphics/FilterEffects/Threshold.cs b/src/Beutl.Engine/Graphics/FilterEffects/Threshold.cs index 71c43f0ef1..ebcbf958ad 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/Threshold.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/Threshold.cs @@ -1,50 +1,36 @@ using System.ComponentModel.DataAnnotations; -using System.Reactive; using Beutl.Engine; using Beutl.Language; -using Beutl.Logging; -using Microsoft.Extensions.Logging; -using SkiaSharp; namespace Beutl.Graphics.Effects; [Display(Name = nameof(GraphicsStrings.Threshold), ResourceType = typeof(GraphicsStrings))] public sealed partial class Threshold : FilterEffect { - private static readonly ILogger s_logger = Log.CreateLogger(); - private static readonly SKSLShader? s_shader; + private const string ShaderSource = + """ + uniform float threshold; + uniform float smoothness; + uniform float strength; - static Threshold() - { - string sksl = - """ - uniform shader src; - uniform float threshold; - uniform float smoothness; - uniform float strength; - - const float3 LUMA = float3(0.2126, 0.7152, 0.0722); + const float3 LUMA = float3(0.2126, 0.7152, 0.0722); - half4 main(float2 coord) { - half4 c = src.eval(coord); - float3 rgb = c.rgb; + half4 apply(half4 color) { + float3 rgb = color.rgb; - float luma = dot(rgb, LUMA); - float lower = threshold - smoothness * 0.5; - float upper = threshold + smoothness * 0.5; - float t = smoothstep(lower, upper, luma); + float luma = dot(rgb, LUMA); + float lower = threshold - smoothness * 0.5; + float upper = threshold + smoothness * 0.5; + float t = smoothstep(lower, upper, luma); - t = mix(luma, t, strength); - return half4(t); - } - """; - - if (!SKSLShader.TryCreate(sksl, out s_shader, out string? errorText)) - { - s_logger.LogError("Failed to compile threshold shader: {ErrorText}", errorText); + t = mix(luma, t, strength); + return half4(t); } - } + """; + + private static readonly SkslSource s_shaderSource = + new(ShaderSource, ShaderDescriptionKind.CurrentPixel); public Threshold() { @@ -65,37 +51,14 @@ public Threshold() public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) { - if (s_shader is null) - { - throw new InvalidOperationException("Failed to compile SKSL."); - } - var r = (Resource)resource; - context.CustomEffect( - (r, Unit.Default), - (t, c) => OnApply(t.r, c), - static (_, rect) => rect); - } - - private static void OnApply(Resource data, CustomFilterEffectContext context) - { - if (s_shader is null) return; - - for (int i = 0; i < context.Targets.Count; i++) - { - using var target = context.Targets[i]; - var renderTarget = target.RenderTarget!; - - using SKImage image = renderTarget.Value.Snapshot(); - using SKShader baseShader = image.ToShader(SKShaderTileMode.Decal, SKShaderTileMode.Decal); - var builder = s_shader.CreateBuilder(); - - builder.Children["src"] = baseShader; - builder.Uniforms["threshold"] = data.Value / 100f; - builder.Uniforms["smoothness"] = data.Smoothness / 100f; - builder.Uniforms["strength"] = data.Strength / 100f; - - context.Targets[i] = s_shader.ApplyToNewTarget(context, builder, target.Bounds); - } + context.Shader(ShaderDescription.CurrentPixel( + s_shaderSource, + bindings => + { + bindings.Uniform("threshold", r.Value / 100f); + bindings.Uniform("smoothness", r.Smoothness / 100f); + bindings.Uniform("strength", r.Strength / 100f); + })); } } diff --git a/src/Beutl.Engine/Graphics/FilterEffects/TransformEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/TransformEffect.cs index 6b237eff49..23e74ec6f6 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/TransformEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/TransformEffect.cs @@ -36,11 +36,15 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource if (!r.ApplyToTarget) { - Vector origin = originPoint.ToPixels(context.Bounds.Size) + context.Bounds.Position; - Matrix offset = Matrix.CreateTranslation(origin); - - Matrix transform = (-offset) * mat * offset; - context.Transform(transform, r.BitmapInterpolationMode); + context.Transform( + (mat, originPoint), + static (data, bounds) => + { + Vector origin = data.originPoint.ToPixels(bounds.Size) + bounds.Position; + Matrix offset = Matrix.CreateTranslation(origin); + return (-offset) * data.mat * offset; + }, + r.BitmapInterpolationMode); } else { @@ -55,6 +59,12 @@ public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource Matrix m2 = -offset2 * data.mat * offset2; EffectTarget newTarget = effectContext.CreateTarget(target.Bounds.TransformToAABB(m1)); + if (newTarget.IsEmpty) + { + newTarget.Dispose(); + return target; + } + using var canvas = effectContext.Open(newTarget); using (canvas.PushTransform(Matrix.CreateTranslation(target.Bounds.Position - newTarget.Bounds.Position))) using (canvas.PushTransform(m2)) diff --git a/src/Beutl.Engine/Graphics/ImmediateCanvas.cs b/src/Beutl.Engine/Graphics/ImmediateCanvas.cs index d061d5cbc1..254c5e49e8 100644 --- a/src/Beutl.Engine/Graphics/ImmediateCanvas.cs +++ b/src/Beutl.Engine/Graphics/ImmediateCanvas.cs @@ -8,13 +8,47 @@ namespace Beutl.Graphics; +internal enum ImmediateCanvasFlushKind : byte +{ + CanvasClose, + CanvasSubmit, + SourceSurface, + // Submit followed by a CPU completion wait. + PrepareForSampling, + // Submit only; the consumer is ordered in the same Skia context. + PrepareForSamplingSubmit, +} + public partial class ImmediateCanvas : IDisposable, IPopable { - internal readonly RenderTarget _renderTarget; + /// + /// The largest normalized basis dot product still reads as + /// orthogonal. + /// + private const float OrthogonalBasisTolerance = 1e-5f; + + private static readonly AsyncLocal s_flushObserver = new(); + private static readonly AsyncLocal s_pixelOperationObserver = new(); + private static readonly AsyncLocal s_drawableBrushMaterializer = new(); + private static readonly AsyncLocal s_renderTargetLeaseSession = new(); + private static readonly Lazy s_rectCoverageEffect = new(CreateRectCoverageEffect); + private static readonly Lazy s_opacityScaleEffect = new(CreateOpacityScaleEffect); + private static readonly SKSamplingOptions s_bitmapSampling = new(SKCubicResampler.Mitchell); + + // A tent kernel has no negative lobe, so a resampled composite cannot emit a value outside the + // range of the samples it interpolated. + private static readonly SKSamplingOptions s_compositeSampling = new(SKFilterMode.Linear, SKMipmapMode.None); + + private readonly RenderTarget _renderTargetValue; private readonly Dispatcher? _dispatcher; private readonly SKPaint _sharedFillPaint = new(); private readonly SKPaint _sharedStrokePaint = new(); private readonly Stack _states = new(); + internal bool HasActiveSaveLayer => _states.Any(static state => state is + CanvasPushedState.LayerPushedState + or CanvasPushedState.MaskPushedState + or CanvasPushedState.BlendModePushedState + or CanvasPushedState.OpacityPushedState); private int _disposeClaimed; private Matrix _currentTransform; // Base CTM = CreateScale(SurfaceDensity); identity when density == 1. @@ -26,22 +60,53 @@ public partial class ImmediateCanvas : IDisposable, IPopable private float _currentDensity; // Base matrix for the Set transform operator: _baseTransform normally, identity inside PushDeviceSpace(). private Matrix _currentBaseTransform; + private RenderExecutionSessionToken? _executionToken; + private CallbackCanvasCapability? _callbackCapability; + private bool _isReplayingTargetScope; + private BlendMode? _directBlendMode; + private bool _productRectangleCoverage; + private int _callbackStateFloor; + private readonly bool _flushOnDispose; + private bool _allowDeferredSameContextSampling; + private bool _submitOnDispose; public ImmediateCanvas(RenderTarget renderTarget, float density = 1f, - float maxWorkingScale = float.PositiveInfinity, Size logicalSize = default) + float maxWorkingScale = float.PositiveInfinity, Size logicalSize = default, + RenderIntent intent = RenderIntent.Preview) + : this(renderTarget, density, maxWorkingScale, logicalSize, intent, flushOnDispose: true, + deviceOrigin: default) + { + } + + private ImmediateCanvas( + RenderTarget renderTarget, + float density, + float maxWorkingScale, + Size logicalSize, + RenderIntent intent, + bool flushOnDispose, + PixelPoint deviceOrigin) { + ArgumentNullException.ThrowIfNull(renderTarget); if (density <= 0f || !float.IsFinite(density)) throw new ArgumentOutOfRangeException(nameof(density), density, "Density must be a positive finite value."); + if (!Enum.IsDefined(intent)) + throw new ArgumentOutOfRangeException(nameof(intent), intent, "Unknown render intent."); _dispatcher = Dispatcher.Current; - _renderTarget = renderTarget; - Canvas = _renderTarget.Value.Canvas; + _flushOnDispose = flushOnDispose; + _renderTargetValue = renderTarget; + Canvas = _renderTarget.RawValue.Canvas; DeviceSize = new PixelSize(renderTarget.Width, renderTarget.Height); + DeviceOrigin = deviceOrigin; LogicalSize = logicalSize.IsDefault ? DeviceSize.ToSize(density) : logicalSize; SurfaceDensity = density; _currentDensity = density; - MaxWorkingScale = RenderNodeContext.SanitizeMaxWorkingScale(maxWorkingScale); + MaxWorkingScale = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + Intent = intent; + DrawableBrushMaterializer = s_drawableBrushMaterializer.Value; + RenderTargetLeaseSession = s_renderTargetLeaseSession.Value; if (density == 1f) { _baseTransform = Matrix.Identity; @@ -61,6 +126,32 @@ public ImmediateCanvas(RenderTarget renderTarget, float density = 1f, _renderTarget.BeginDraw(); } + private ImmediateCanvas(ImmediateCanvas parent) + { + parent.VerifyAccess(); + _dispatcher = Dispatcher.Current; + _flushOnDispose = false; + _renderTargetValue = parent._renderTargetValue; + Canvas = parent.Canvas; + DeviceSize = parent.DeviceSize; + DeviceOrigin = parent.DeviceOrigin; + LogicalSize = parent.LogicalSize; + SurfaceDensity = parent.SurfaceDensity; + _currentDensity = parent._currentDensity; + _directBlendMode = parent._directBlendMode; + _productRectangleCoverage = parent._productRectangleCoverage; + _allowDeferredSameContextSampling = parent._allowDeferredSameContextSampling; + MaxWorkingScale = parent.MaxWorkingScale; + Intent = parent.Intent; + DrawableBrushMaterializer = parent.DrawableBrushMaterializer; + RenderTargetLeaseSession = parent.RenderTargetLeaseSession; + _baseTransform = parent._currentBaseTransform; + _currentBaseTransform = parent._currentBaseTransform; + _baseSaveCount = Canvas.Save(); + _currentTransform = Canvas.TotalMatrix.ToMatrix(); + _renderTargetValue.BeginDraw(); + } + ~ImmediateCanvas() { // A finalizer must never throw — an unhandled exception on the finalizer thread aborts the process. @@ -88,6 +179,8 @@ public ImmediateCanvas(RenderTarget renderTarget, float density = 1f, /// The physical backing-surface size in device pixels (ceil(LogicalSize × SurfaceDensity)). public PixelSize DeviceSize { get; } + internal PixelPoint DeviceOrigin { get; } + /// /// Pixel density of the current coordinate space. Equals normally; /// 1 inside a block. @@ -103,6 +196,57 @@ public ImmediateCanvas(RenderTarget renderTarget, float density = 1f, /// Working-scale ceiling forwarded into nested pulls. +Inf = no ceiling. public float MaxWorkingScale { get; } + /// + /// Preview or delivery classification, inherited by brush intermediates and nested requests opened from + /// this canvas. degrades on an allocation failure; + /// fails instead of dropping the contribution. + /// + public RenderIntent Intent { get; } + + /// + /// Runtime hook that materializes a 's nested content into an + /// covering at device px per + /// logical unit. The executor sets it while a canvas is open and clears it when the canvas closes; + /// a null hook leaves DrawableBrush materialization unavailable and degrades the fill to transparent. + /// + internal DrawableBrushMaterializer? DrawableBrushMaterializer { get; set; } + + /// + /// The render pass's target lease session, or outside one. Brush-owned intermediates + /// allocate through it so a caller-supplied is honoured. + /// + internal RenderTargetLeaseSession? RenderTargetLeaseSession { get; set; } + + internal IDisposable PushRenderTargetLeaseSession(RenderTargetLeaseSession? leaseSession) + { + VerifyAccess(); + RenderTargetLeaseSession? previous = RenderTargetLeaseSession; + RenderTargetLeaseSession? previousAmbient = s_renderTargetLeaseSession.Value; + RenderTargetLeaseSession = leaseSession; + s_renderTargetLeaseSession.Value = leaseSession; + return new RenderTargetLeaseSessionScope(this, previous, previousAmbient); + } + + internal IDisposable PushDrawableBrushMaterializer(DrawableBrushMaterializer? materializer) + { + VerifyAccess(); + DrawableBrushMaterializer? previous = DrawableBrushMaterializer; + DrawableBrushMaterializer? previousAmbient = s_drawableBrushMaterializer.Value; + DrawableBrushMaterializer = materializer; + s_drawableBrushMaterializer.Value = materializer; + return new DrawableBrushMaterializerScope(this, previous, previousAmbient); + } + + /// + /// Creates a brush constructor bound to this canvas's current density, working-scale ceiling and + /// render intent, so a caller painting onto this canvas never has to restate them. + /// + /// The logical frame the brush maps onto. + /// The brush to paint with, or for no paint. + /// The blend mode to configure. + public BrushConstructor CreateBrushConstructor(Rect bounds, Brush.Resource? brush, BlendMode blendMode) + => new(bounds, brush, blendMode, _currentDensity, MaxWorkingScale, Intent, DrawableBrushMaterializer, RenderTargetLeaseSession); + public Matrix Transform { get { return _currentTransform; } @@ -119,27 +263,166 @@ internal set internal SKCanvas Canvas { get; } - public void Clear() + internal static ImmediateCanvas CreateExecutorManaged( + RenderTarget renderTarget, + float density, + float maxWorkingScale, + Size logicalSize, + RenderIntent intent, + PixelPoint deviceOrigin = default) + => new( + renderTarget, + density, + maxWorkingScale, + logicalSize, + intent, + flushOnDispose: false, + deviceOrigin); + + internal void ConfigureCustomEffectExecution() { VerifyAccess(); + if (_flushOnDispose) + { + throw new InvalidOperationException( + "Custom-effect execution requires an executor-managed canvas."); + } + + _allowDeferredSameContextSampling = true; + _submitOnDispose = true; + } + + internal static IDisposable ObserveFlushes(Action observer) + { + ArgumentNullException.ThrowIfNull(observer); + var scope = new FlushObserverScope(s_flushObserver.Value, observer); + s_flushObserver.Value = scope; + return scope; + } + + internal static IDisposable ObservePixelOperations(Action observer) + { + ArgumentNullException.ThrowIfNull(observer); + var scope = new PixelOperationObserverScope(s_pixelOperationObserver.Value, observer); + s_pixelOperationObserver.Value = scope; + return scope; + } + + internal RenderTarget _renderTarget + { + get + { + if (_callbackCapability is not null && !_isReplayingTargetScope) + { + throw new InvalidOperationException( + "The backing render target cannot be extracted from a guarded callback canvas."); + } + + return _renderTargetValue; + } + } + + public void Clear() + { + VerifyPixelOperation(isClear: true); + RecordPixelOperation(); Canvas.Clear(); } public void Clear(Color color) { - VerifyAccess(); + VerifyPixelOperation(isClear: true); + RecordPixelOperation(); Canvas.Clear(color.ToSKColor()); } + internal void ReplaceAffectedRegion(Color color) + { + VerifyPixelOperation(); + RecordPixelOperation(); + using var paint = new SKPaint + { + Color = color.ToSKColor(), + BlendMode = SKBlendMode.Src, + IsAntialias = false, + }; + Canvas.DrawPaint(paint); + } + public void ClipRect(Rect clip, ClipOperation operation = ClipOperation.Intersect) { VerifyAccess(); Canvas.ClipRect(clip.ToSKRect(), operation.ToSKClipOperation()); } + /// + /// Intersects the clip with every device pixel touches. + /// + /// + /// A non-antialiased Skia clip snaps to the nearest device pixel, so a rect that falls between + /// pixel centres cuts into the content it is meant to bound: an edge column loses its partial + /// coverage, and a footprint narrower than a pixel rounds away entirely. Widening the rect to whole + /// pixels first keeps a conservative bound conservative, which is the only direction it may err in. + /// Antialiasing the clip instead would be wrong, because the bound's own edge coverage would then + /// multiply the content's. + /// + internal void ClipRectCoveringDevicePixels(Rect clip) + { + VerifyAccess(); + SKMatrix transform = Canvas.TotalMatrix; + if (!transform.TryInvert(out SKMatrix inverse)) + { + ClipRect(clip); + return; + } + + if (transform.SkewX == 0 + && transform.SkewY == 0 + && transform.Persp0 == 0 + && transform.Persp1 == 0) + { + // An axis-aligned map takes a rect to a rect, so the covering pixels have an exact + // preimage. A bound already sitting on the device grid therefore stays untouched. + SKRect device = transform.MapRect(clip.ToSKRect()); + var covering = new SKRect( + MathF.Floor(device.Left), + MathF.Floor(device.Top), + MathF.Ceiling(device.Right), + MathF.Ceiling(device.Bottom)); + if (IsFinite(covering)) + { + SKRect local = inverse.MapRect(covering); + if (IsFinite(local)) + { + ClipRect(new Rect(local.Left, local.Top, local.Width, local.Height)); + return; + } + } + + ClipRect(clip); + return; + } + + // Rotation and skew take the rect to a parallelogram, which has no pixel-aligned preimage. + // Widening by whatever one device pixel measures along each local axis still covers the snap. + float horizontal = MathF.Abs(inverse.ScaleX) + MathF.Abs(inverse.SkewX); + float vertical = MathF.Abs(inverse.SkewY) + MathF.Abs(inverse.ScaleY); + ClipRect( + float.IsFinite(horizontal) && float.IsFinite(vertical) + ? clip.Inflate(new Thickness(horizontal, vertical)) + : clip); + + static bool IsFinite(SKRect rect) + => float.IsFinite(rect.Left) + && float.IsFinite(rect.Top) + && float.IsFinite(rect.Right) + && float.IsFinite(rect.Bottom); + } + public void ClipPath(Geometry.Resource geometry, ClipOperation operation = ClipOperation.Intersect) { VerifyAccess(); + VerifyCallbackResource(geometry, nameof(geometry)); Canvas.ClipPath(geometry.GetCachedPath(), operation.ToSKClipOperation(), true); } @@ -147,131 +430,355 @@ public void ClipPath(Geometry.Resource geometry, ClipOperation operation = ClipO private void Dispose(bool disposing) { - void DisposeCore() + if (_executionToken is not null && !IsDisposed) { - // Must suppress finalizer before GPU ops that might throw. - IsDisposed = true; - GC.SuppressFinalize(this); - try - { - // Flush GPU work while surface and paints are still alive. - GraphicsContextFactory.SharedContext?.SkiaContext.Flush(true, true); - - // Undo the base Save() (density != 1). Guard Canvas.Handle: SkiaSharp may have - // zeroed it during GrContext teardown; RestoreToCount on a zero Handle SIGSEGVs. - if (_baseSaveCount >= 0 && Canvas is not null && Canvas.Handle != IntPtr.Zero) - { - Canvas.RestoreToCount(_baseSaveCount); - } - } - catch - { - // Best-effort GPU-state cleanup; never abort disposal (or crash the finalizer thread) on it. - } - - _sharedFillPaint.Dispose(); - _sharedStrokePaint.Dispose(); + throw new InvalidOperationException( + "Executor-managed callback canvases cannot be disposed by callback code."); } - // Claimed here, not inside DisposeCore: Run can return with the cleanup still queued, so a + // Claimed here, not inside CloseCore: Run can return with the cleanup still queued, so a // second Dispose would see IsDisposed false and queue a rival one, double-disposing the paints. if (Interlocked.Exchange(ref _disposeClaimed, 1) != 0) { return; } - // A finalizer must not block on another thread, so it cannot take the bounded wait below. - if (!disposing && _dispatcher is { HasShutdownFinished: false } dispatcher && !dispatcher.CheckAccess()) + if (!disposing) { - dispatcher.Dispatch(DisposeCore); + GpuResourceRelease.DispatchFinalizer( + _dispatcher, + () => CloseCore(_flushOnDispose, _submitOnDispose)); return; } - GpuResourceRelease.Run(_dispatcher, DisposeCore); + GpuResourceRelease.Run( + _dispatcher, + () => CloseCore(_flushOnDispose, _submitOnDispose)); } public void DrawSurface(SKSurface surface, Point point) { + VerifyAccess(); + VerifyNativeTargetOperation(); _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); _sharedFillPaint.IsAntialias = true; + RecordPixelOperation(); Canvas.DrawSurface(surface, point.X, point.Y, _sharedFillPaint); - surface.Flush(true, true); + if (!CanConsumeWithoutFlush(surface)) + { + surface.Flush(true, true); + RecordFlush(ImmediateCanvasFlushKind.SourceSurface); + } } public void DrawRenderTarget(RenderTarget renderTarget, Point point) { + VerifyAccess(); + VerifyNativeTargetOperation(); // NOTE: renderTargetを保持しておいて次回Flushされたときに開放すると効率的 renderTarget.VerifyAccess(); + renderTarget.PrepareBackendForSkiaSampling(); _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); _sharedFillPaint.IsAntialias = true; + RecordPixelOperation(); Canvas.DrawSurface(renderTarget.Value, point.X, point.Y, _sharedFillPaint); - renderTarget.Value.Flush(true, true); + if (!CanConsumeWithoutFlush(renderTarget)) + { + renderTarget.Value.Flush(true, true); + RecordFlush(ImmediateCanvasFlushKind.SourceSurface); + } } - // Draw a buffer into a logical destination rect (Mitchell resample). + // Draw a buffer into a logical destination rect. public void DrawRenderTargetScaled(RenderTarget renderTarget, Rect dest) + => DrawRenderTargetScaledCore( + renderTarget, + dest, + flushSource: !CanConsumeWithoutFlush(renderTarget)); + + internal void DrawRenderTargetScaledWithoutFlush(RenderTarget renderTarget, Rect dest) + => DrawRenderTargetScaledCore(renderTarget, dest, flushSource: false); + + private bool CanConsumeWithoutFlush(RenderTarget renderTarget) + { + renderTarget.VerifyAccess(); + return CanConsumeWithoutFlush(renderTarget.RawValue); + } + + private bool CanConsumeWithoutFlush(SKSurface surface) + { + if (!_allowDeferredSameContextSampling || _flushOnDispose) + return false; + + GRRecordingContext? destinationContext = _renderTarget.RawValue.Context; + GRRecordingContext? sourceContext = surface.Context; + return destinationContext is null + ? sourceContext is null + : sourceContext is not null && destinationContext.Handle == sourceContext.Handle; + } + + internal bool CanDrawPixelAligned( + Rect dest, + float sourceDensity, + PixelSize sourceSize) + { + VerifyAccess(); + VerifyNativeTargetOperation(); + return TryGetPixelAlignedDeviceOrigin( + dest, + sourceDensity, + sourceSize, + _currentDensity, + _currentTransform, + out _); + } + + internal static bool CanDrawPixelAligned( + Rect dest, + float sourceDensity, + PixelSize sourceSize, + float destinationDensity, + Matrix destinationTransform) + => TryGetPixelAlignedDeviceOrigin( + dest, + sourceDensity, + sourceSize, + destinationDensity, + destinationTransform, + out _); + + private static bool TryGetPixelAlignedDeviceOrigin( + Rect dest, + float sourceDensity, + PixelSize sourceSize, + float destinationDensity, + Matrix destinationTransform, + out PixelPoint deviceOrigin) + { + deviceOrigin = default; + if (destinationDensity != sourceDensity + || destinationTransform.M11 != sourceDensity + || destinationTransform.M22 != sourceDensity + || destinationTransform.M12 != 0 + || destinationTransform.M13 != 0 + || destinationTransform.M21 != 0 + || destinationTransform.M23 != 0 + || destinationTransform.M33 != 1) + { + return false; + } + + PixelRect deviceBounds = PixelRect.FromRect(dest, sourceDensity); + if (deviceBounds.Size != sourceSize + || deviceBounds.ToRect(sourceDensity) != dest) + { + return false; + } + + Point mappedOrigin = dest.Position * destinationTransform; + int x = (int)MathF.Round(mappedOrigin.X); + int y = (int)MathF.Round(mappedOrigin.Y); + if (MathF.Abs(mappedOrigin.X - x) > 0.0001f + || MathF.Abs(mappedOrigin.Y - y) > 0.0001f) + { + return false; + } + + deviceOrigin = new PixelPoint(x, y); + return true; + } + + /// + /// Reports whether a buffer drawn at lands on + /// exact device pixels, so a nearest-sampled point blit reproduces it instead of snapping it. + /// + internal bool CanBlitLossless(Rect dest, PixelSize sourceSize) { VerifyAccess(); + VerifyNativeTargetOperation(); + return TryGetLosslessDeviceOrigin(dest, sourceSize, out _); + } + + internal void DrawRenderTargetPixelsWithoutFlush(RenderTarget renderTarget, int x, int y) + { + VerifyAccess(); + VerifyNativeTargetOperation(); renderTarget.VerifyAccess(); + renderTarget.PrepareBackendForSkiaSampling(); using SKImage image = renderTarget.Value.Snapshot(); - DrawImageScaled(image, dest); + _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); + _sharedFillPaint.IsAntialias = false; + var source = SKRect.Create(image.Width, image.Height); + var destination = SKRect.Create(x, y, image.Width, image.Height); + using (PushDeviceSpace()) + { + RecordPixelOperation(); + Canvas.DrawImage( + image, + source, + destination, + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + _sharedFillPaint); + } + } + + /// + /// Maps through the active transform and reports the device origin when the + /// mapping lands a buffer on exact device pixels, so a copy is lossless. + /// + private bool TryGetLosslessDeviceOrigin(Rect dest, PixelSize sourceSize, out PixelPoint deviceOrigin) + { + deviceOrigin = default; + Matrix transform = _currentTransform; + if (transform.M12 != 0 + || transform.M13 != 0 + || transform.M21 != 0 + || transform.M23 != 0 + || transform.M33 != 1) + { + return false; + } - renderTarget.Value.Flush(true, true); + Point mappedOrigin = dest.Position * transform; + Point mappedFar = new Point(dest.Right, dest.Bottom) * transform; + int x = (int)MathF.Round(mappedOrigin.X); + int y = (int)MathF.Round(mappedOrigin.Y); + if (MathF.Abs(mappedOrigin.X - x) > 0.0001f + || MathF.Abs(mappedOrigin.Y - y) > 0.0001f + || MathF.Abs(mappedFar.X - (x + sourceSize.Width)) > 0.0001f + || MathF.Abs(mappedFar.Y - (y + sourceSize.Height)) > 0.0001f) + { + return false; + } + + deviceOrigin = new PixelPoint(x, y); + return true; } - // Draw a pre-snapshotted image into a logical destination rect (Mitchell resample). - public void DrawImageScaled(SKImage image, Rect dest) + private void DrawRenderTargetScaledCore(RenderTarget renderTarget, Rect dest, bool flushSource) { VerifyAccess(); + VerifyNativeTargetOperation(); + renderTarget.VerifyAccess(); + renderTarget.PrepareBackendForSkiaSampling(); + + // Resampling a buffer that already lands on exact device pixels only softens and rings it. + if (TryGetLosslessDeviceOrigin( + dest, + new PixelSize(renderTarget.Width, renderTarget.Height), + out PixelPoint deviceOrigin)) + { + DrawRenderTargetPixelsWithoutFlush(renderTarget, deviceOrigin.X, deviceOrigin.Y); + } + else + { + using SKImage image = renderTarget.Value.Snapshot(); + DrawImageScaled(image, dest); + } + + if (flushSource) + { + renderTarget.Value.Flush(true, true); + RecordFlush(ImmediateCanvasFlushKind.SourceSurface); + } + } + + // Draw a pre-snapshotted image into a logical destination rect. + public void DrawImageScaled(SKImage image, Rect dest) + { + VerifyPixelOperation(); + VerifyCallbackResource(image, nameof(image)); _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); _sharedFillPaint.IsAntialias = true; var src = SKRect.Create(image.Width, image.Height); - Canvas.DrawImage(image, src, dest.ToSKRect(), new SKSamplingOptions(SKCubicResampler.Mitchell), _sharedFillPaint); + RecordPixelOperation(); + Canvas.DrawImage(image, src, dest.ToSKRect(), s_compositeSampling, _sharedFillPaint); } // Draw a surface into its own logical footprint (pixel size / density) at the given origin. public void DrawSurfaceScaled(SKSurface surface, Point origin, float scale) { VerifyAccess(); + VerifyNativeTargetOperation(); _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); _sharedFillPaint.IsAntialias = true; using SKImage image = surface.Snapshot(); var src = SKRect.Create(image.Width, image.Height); var dest = SKRect.Create((float)origin.X, (float)origin.Y, image.Width / scale, image.Height / scale); - Canvas.DrawImage(image, src, dest, new SKSamplingOptions(SKCubicResampler.Mitchell), _sharedFillPaint); + RecordPixelOperation(); + Canvas.DrawImage(image, src, dest, s_compositeSampling, _sharedFillPaint); - surface.Flush(true, true); + if (!CanConsumeWithoutFlush(surface)) + { + surface.Flush(true, true); + RecordFlush(ImmediateCanvasFlushKind.SourceSurface); + } } public void DrawDrawable(Drawable.Resource drawable) { + VerifyAccess(); + VerifyNestedExecutionOperation(); using var node = new DrawableRenderNode(drawable); using var context = new GraphicsContext2D(node, LogicalSize, _currentDensity); - drawable.GetOriginal().Render(context, drawable); - var processor = new RenderNodeProcessor(node, true, _currentDensity, MaxWorkingScale); - processor.Render(this); + drawable.GetOriginal()!.Render(context, drawable); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = Intent, + OutputScale = _currentDensity, + MaxWorkingScale = MaxWorkingScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + }, + }); + renderer.Render(this); } public void DrawNode(RenderNode node) { - var processor = new RenderNodeProcessor(node, true, _currentDensity, MaxWorkingScale); - processor.Render(this); + VerifyAccess(); + VerifyNestedExecutionOperation(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = Intent, + OutputScale = _currentDensity, + MaxWorkingScale = MaxWorkingScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + }, + }); + renderer.Render(this); } public void DrawBackdrop(IBackdrop backdrop) { + VerifyAccess(); + VerifyNestedExecutionOperation(); backdrop.Draw(this); } public IBackdrop Snapshot() { + VerifyAccess(); + VerifyNestedExecutionOperation(); // Use SurfaceDensity (not Density, which PushDeviceSpace lowers to 1) so the backdrop un-scales correctly. return new TmpBackdrop(_renderTarget.Snapshot(), SurfaceDensity); } @@ -283,13 +790,20 @@ public void DrawBitmap(Bitmap bmp, Brush.Resource? fill, Pen.Resource? pen) if (bmp.ByteCount <= 0) return; - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(bmp, nameof(bmp)); + VerifyCallbackResource(fill, nameof(fill)); + VerifyCallbackResource(pen, nameof(pen)); var size = new Size(bmp.Width, bmp.Height); ConfigureFillPaint(new(size), fill); using var img = SKImage.FromBitmap(bmp.SKBitmap); - Canvas.DrawImage(img, 0, 0, new SKSamplingOptions(SKCubicResampler.Mitchell), _sharedFillPaint); + RecordPixelOperation(); + SKSamplingOptions sampling = RenderScaleUtilities.IsExactIntegerReduction(SurfaceDensity) + ? s_compositeSampling + : s_bitmapSampling; + Canvas.DrawImage(img, 0, 0, sampling, _sharedFillPaint); } // Draw a bitmap into a logical destination rect (Mitchell resample). @@ -300,26 +814,42 @@ public void DrawBitmapScaled(Bitmap bmp, Rect dest, Brush.Resource? fill) if (bmp.ByteCount <= 0) return; - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(bmp, nameof(bmp)); + VerifyCallbackResource(fill, nameof(fill)); ConfigureFillPaint(new(dest.Size), fill); using var img = SKImage.FromBitmap(bmp.SKBitmap); var src = SKRect.Create(bmp.Width, bmp.Height); + RecordPixelOperation(); Canvas.DrawImage(img, src, dest.ToSKRect(), new SKSamplingOptions(SKCubicResampler.Mitchell), _sharedFillPaint); } public void DrawImageSource(ImageSource.Resource source, Brush.Resource? fill, Pen.Resource? pen) { + VerifyAccess(); + if (_executionToken is null) + VerifyNestedExecutionOperation(); + else + VerifyCallbackResource(source, nameof(source)); var bitmap = source.Bitmap; if (bitmap != null) { - DrawBitmap(bitmap, fill, pen); + if (_executionToken is null) + DrawBitmap(bitmap, fill, pen); + else + _executionToken.AuthorizeResource(bitmap, () => DrawBitmap(bitmap, fill, pen)); } } public void DrawVideoSource(VideoSource.Resource source, TimeSpan frame, Brush.Resource? fill, Pen.Resource? pen) { + VerifyAccess(); + if (_executionToken is null) + VerifyNestedExecutionOperation(); + else + VerifyCallbackResource(source, nameof(source)); Rational rate = source.FrameRate; double frameNum = frame.TotalSeconds * (rate.Numerator / (double)rate.Denominator); DrawVideoSource(source, (int)frameNum, fill, pen); @@ -327,27 +857,43 @@ public void DrawVideoSource(VideoSource.Resource source, TimeSpan frame, Brush.R public void DrawVideoSource(VideoSource.Resource source, int frame, Brush.Resource? fill, Pen.Resource? pen) { + VerifyAccess(); + if (_executionToken is null) + VerifyNestedExecutionOperation(); + else + VerifyCallbackResource(source, nameof(source)); if (source.Read(frame, out var bitmapRef)) { using (bitmapRef) { - if (source.ProxyResolution == null) + void DrawFrame() { - DrawBitmap(bitmapRef.Value, fill, pen); + if (source.ProxyResolution == null) + { + DrawBitmap(bitmapRef.Value, fill, pen); + } + else + { + var dest = new Rect(default, source.LogicalFrameSize.ToSize(1)); + DrawBitmapScaled(bitmapRef.Value, dest, fill); + } } + + if (_executionToken is null) + DrawFrame(); else - { - var dest = new Rect(default, source.LogicalFrameSize.ToSize(1)); - DrawBitmapScaled(bitmapRef.Value, dest, fill); - } + _executionToken.AuthorizeResource(bitmapRef.Value, DrawFrame); } } } public void DrawEllipse(Rect rect, Brush.Resource? fill, Pen.Resource? pen) { - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(fill, nameof(fill)); + VerifyCallbackResource(pen, nameof(pen)); ConfigureFillPaint(rect, fill); + RecordPixelOperation(); Canvas.DrawOval(rect.ToSKRect(), _sharedFillPaint); if (pen != null && pen.Thickness != 0) @@ -362,8 +908,11 @@ public void DrawEllipse(Rect rect, Brush.Resource? fill, Pen.Resource? pen) public void DrawRectangle(Rect rect, Brush.Resource? fill, Pen.Resource? pen) { - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(fill, nameof(fill)); + VerifyCallbackResource(pen, nameof(pen)); ConfigureFillPaint(rect, fill); + RecordPixelOperation(); Canvas.DrawRect(rect.ToSKRect(), _sharedFillPaint); if (pen != null && pen.Thickness != 0) @@ -378,7 +927,10 @@ public void DrawRectangle(Rect rect, Brush.Resource? fill, Pen.Resource? pen) public void DrawText(FormattedText text, Brush.Resource? fill, Pen.Resource? pen) { - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(text, nameof(text)); + VerifyCallbackResource(fill, nameof(fill)); + VerifyCallbackResource(pen, nameof(pen)); float density = _currentDensity; SKTextBlob? textBlob = text.GetTextBlob(density); if (textBlob is null) @@ -390,6 +942,7 @@ public void DrawText(FormattedText text, Brush.Resource? fill, Pen.Resource? pen if (density == 1f) { ConfigureFillPaint(text.Bounds, fill); + RecordPixelOperation(); Canvas.DrawText(textBlob, 0, 0, _sharedFillPaint); if (pen != null @@ -410,6 +963,7 @@ public void DrawText(FormattedText text, Brush.Resource? fill, Pen.Resource? pen // The blob is shaped at device density, so its glyphs already span Bounds * density // under this CTM. Pass scale 1 so the density isn't applied twice to brush patterns. ConfigureFillPaint(text.Bounds * density, fill, scale: 1f); + RecordPixelOperation(); Canvas.DrawText(textBlob, 0, 0, _sharedFillPaint); if (pen != null @@ -452,6 +1006,7 @@ internal void DrawSKPath(SKPath skPath, bool strokeOnly, Brush.Resource? fill, P if (!strokeOnly) { ConfigureFillPaint(rect, fill); + RecordPixelOperation(); Canvas.DrawPath(skPath, _sharedFillPaint); } @@ -460,18 +1015,24 @@ internal void DrawSKPath(SKPath skPath, bool strokeOnly, Brush.Resource? fill, P ConfigureStrokePaint(rect, pen); using SKPath strokePath = PenHelper.CreateStrokePath(skPath, pen, rect); + RecordPixelOperation(); Canvas.DrawPath(strokePath, _sharedStrokePaint); } } public void DrawGeometry(Geometry.Resource geometry, Brush.Resource? fill, Pen.Resource? pen) { - VerifyAccess(); + VerifyPixelOperation(); + VerifyCallbackResource(geometry, nameof(geometry)); + VerifyCallbackResource(fill, nameof(fill)); + VerifyCallbackResource(pen, nameof(pen)); SKPath skPath = geometry.GetCachedPath(); Rect rect = geometry.Bounds; ConfigureFillPaint(geometry.Bounds, fill); - Canvas.DrawPath(skPath, _sharedFillPaint); + RecordPixelOperation(); + if (!TryDrawProductCoverageRectangle(geometry, _sharedFillPaint)) + Canvas.DrawPath(skPath, _sharedFillPaint); if (pen != null && pen.Thickness > 0) { @@ -487,10 +1048,12 @@ public void DrawGeometry(Geometry.Resource geometry, Brush.Resource? fill, Pen.R public void Pop(int count = -1) { VerifyAccess(); + int stateFloor = _executionToken is null ? 0 : _callbackStateFloor; if (count < 0) { - while (count < 0 + while (_states.Count > stateFloor + && count < 0 && _states.TryPop(out CanvasPushedState? state)) { state.Pop(this); @@ -499,7 +1062,8 @@ public void Pop(int count = -1) } else { - while (_states.Count >= count + while (_states.Count > stateFloor + && _states.Count >= count && _states.TryPop(out CanvasPushedState? state)) { state.Pop(this); @@ -519,36 +1083,103 @@ public PushedState Push() public PushedState PushLayer(Rect limit = default) { VerifyAccess(); + VerifyHiddenLayerOperation(); int count; if (limit == default) { + RecordPixelOperation(); count = Canvas.SaveLayer(); } else { using (var paint = new SKPaint()) { + RecordPixelOperation(); count = Canvas.SaveLayer(limit.ToSKRect(), paint); } } - _states.Push(new CanvasPushedState.SKCanvasPushedState(count)); + _states.Push(new CanvasPushedState.LayerPushedState(count)); return new PushedState(this, _states.Count); } internal PushedState PushPaint(SKPaint paint, Rect? rect = null) { VerifyAccess(); + VerifyHiddenLayerOperation(); int count; if (rect.HasValue) + { + RecordPixelOperation(); count = Canvas.SaveLayer(rect.Value.ToSKRect(), paint); + } else + { + RecordPixelOperation(); count = Canvas.SaveLayer(paint); + } - _states.Push(new CanvasPushedState.SKCanvasPushedState(count)); + _states.Push(new CanvasPushedState.LayerPushedState(count)); return new PushedState(this, _states.Count); } + /// + /// Opens a filter's save layer over , widened so that every edge of + /// the content sits one device pixel inside the layer. + /// + /// + /// The bound keeps a spatial filter from sampling input pixels nobody wrote, but a layer whose + /// device bounds hug the content loses the coverage of content thinner than one device pixel — the + /// Ganesh backend keeps only (1 + w) / 2 of a w-device-pixel-wide feature. The apron restores + /// it by giving the rasterizer somewhere to put the antialiased spill of the content, so the replay + /// does write into the apron and a filter does sample it. What the layer guarantees is therefore a + /// bound, not an exclusion: no edge is more than one device pixel out from the content it bounds, + /// and the apron starts transparent because SaveLayer clears it, so it can never carry pixels + /// nobody wrote. A sheared basis needs a wider logical apron to buy that one perpendicular pixel, so + /// its device bounding box grows by more than a pixel along the sheared axis. + /// + internal PushedState PushFilterLayer(SKPaint paint, Rect contentBounds) + => PushPaint(paint, InflateByOneDevicePixel(contentBounds, _currentTransform)); + + /// + /// Widens so that carries every edge exactly + /// one device pixel away from the content, measured perpendicular to that edge. + /// + /// + /// Moving a vertical edge by one logical unit displaces it perpendicularly by + /// |det| / devicePerY device pixels, not by devicePerX, so the apron an axis needs is + /// the other axis's basis length over the determinant. For an orthogonal basis that is the + /// reciprocal of the axis's own basis length, which is what every scale and rotation reduces to; + /// only a sheared basis needs more. A transform that collapses the plane leaves the bounds alone. + /// + internal static Rect InflateByOneDevicePixel(Rect bounds, Matrix transform) + { + float devicePerX = MathF.Sqrt((transform.M11 * transform.M11) + (transform.M12 * transform.M12)); + float devicePerY = MathF.Sqrt((transform.M21 * transform.M21) + (transform.M22 * transform.M22)); + if (!float.IsFinite(devicePerX) || devicePerX <= 0 + || !float.IsFinite(devicePerY) || devicePerY <= 0) + { + return bounds; + } + + // Composing a rotation with an anisotropic scale leaves the basis orthogonal yet misses a zero + // dot product by up to ~1e-7 of the basis lengths, while the shallowest shear that can move a + // device pixel misses it by ~1e-3. Keeping the reciprocal form below that split holds every + // unsheared transform bit-identical instead of moving it by the rounding of the general form. + float obliqueness = MathF.Abs((transform.M11 * transform.M21) + (transform.M12 * transform.M22)); + if (obliqueness <= devicePerX * devicePerY * OrthogonalBasisTolerance) + return bounds.Inflate(new Thickness(1f / devicePerX, 1f / devicePerY)); + + float area = MathF.Abs((transform.M11 * transform.M22) - (transform.M12 * transform.M21)); + float horizontal = devicePerY / area; + float vertical = devicePerX / area; + // A singular basis has no area to divide by and drives the apron to infinity. + if (!float.IsFinite(horizontal) || !float.IsFinite(vertical)) + return bounds; + + return bounds.Inflate(new Thickness(horizontal, vertical)); + } + public PushedState PushClip(Rect clip, ClipOperation operation = ClipOperation.Intersect) { VerifyAccess(); @@ -562,6 +1193,7 @@ public PushedState PushClip(Rect clip, ClipOperation operation = ClipOperation.I public PushedState PushClip(Geometry.Resource geometry, ClipOperation operation = ClipOperation.Intersect) { VerifyAccess(); + VerifyCallbackResource(geometry, nameof(geometry)); int count = Canvas.Save(); ClipPath(geometry, operation); @@ -572,23 +1204,55 @@ public PushedState PushClip(Geometry.Resource geometry, ClipOperation operation public PushedState PushOpacity(float opacity) { VerifyAccess(); + VerifyHiddenLayerOperation(); float oldOpacity = Opacity; Opacity *= opacity; - var paint = new SKPaint(); - int count = Canvas.SaveLayer(paint); - paint.Color = new SKColor(0, 0, 0, (byte)(Opacity * 255)); - _states.Push(new CanvasPushedState.OpacityPushedState(oldOpacity, count, paint)); + RecordPixelOperation(); + if (oldOpacity == 1f && opacity == 1f) + { + // Skia sizes an isolation layer from the active clip, and rasterizing into that smaller + // surface changes antialiased coverage. A fully opaque group is SrcOver-associative, so + // the layer would only be an identity pass that perturbs coverage. + _states.Push(new CanvasPushedState.SKCanvasPushedState(Canvas.Save())); + return new PushedState(this, _states.Count); + } + + // The group opacity is applied by the layer's own color filter, which multiplies the premultiplied + // result by a float uniform. Skia's two idiomatic alternatives both quantize to 8 bits inside an + // otherwise 16-bit linear pipeline: a paint alpha on the SaveLayer paint goes through SkColor4f -> + // byte, and so does the DstIn mask this used to draw on pop. Both turn opacity 0.5 into 128/255 == + // 0.50195312 rather than 0.5. + // SaveLayer copies the paint, so neither it nor the filter has to outlive this call. + int count; + using (var paint = new SKPaint()) + using (SKColorFilter filter = CreateOpacityColorFilter(Opacity)) + { + paint.ColorFilter = filter; + count = Canvas.SaveLayer(paint); + } + + _states.Push(new CanvasPushedState.OpacityPushedState(oldOpacity, count)); return new PushedState(this, _states.Count); } public PushedState PushOpacityMask(Brush.Resource mask, Rect bounds, bool invert = false) { VerifyAccess(); + VerifyHiddenLayerOperation(); var paint = new SKPaint(); + RecordPixelOperation(); int count = Canvas.SaveLayer(paint); - new BrushConstructor(bounds, mask, (BlendMode)paint.BlendMode, _currentDensity, MaxWorkingScale).ConfigurePaint(paint); + new BrushConstructor( + bounds, + mask, + (BlendMode)paint.BlendMode, + _currentDensity, + MaxWorkingScale, + Intent, + DrawableBrushMaterializer, + RenderTargetLeaseSession).ConfigurePaint(paint); _states.Push(new CanvasPushedState.MaskPushedState(count, invert, paint)); return new PushedState(this, _states.Count); } @@ -646,13 +1310,36 @@ public PushedState PushDeviceSpace() public PushedState PushBlendMode(BlendMode blendMode) { VerifyAccess(); + VerifyHiddenLayerOperation(); BlendMode tmp = BlendMode; + bool previousProductRectangleCoverage = _productRectangleCoverage; BlendMode = blendMode; + _productRectangleCoverage = blendMode == BlendMode.DstIn; var paint = new SKPaint(); paint.BlendMode = (SKBlendMode)blendMode; + RecordPixelOperation(); int count = Canvas.SaveLayer(paint); - _states.Push(new CanvasPushedState.BlendModePushedState(tmp, count, paint)); + _states.Push(new CanvasPushedState.BlendModePushedState( + tmp, + previousProductRectangleCoverage, + count, + paint)); + return new PushedState(this, _states.Count); + } + + internal PushedState PushDirectBlendMode(BlendMode blendMode) + { + VerifyAccess(); + int count = Canvas.Save(); + BlendMode previousBlendMode = BlendMode; + BlendMode? previousDirectBlendMode = _directBlendMode; + BlendMode = blendMode; + _directBlendMode = blendMode; + _states.Push(new CanvasPushedState.DirectBlendModePushedState( + previousBlendMode, + previousDirectBlendMode, + count)); return new PushedState(this, _states.Count); } @@ -661,6 +1348,439 @@ internal void VerifyAccess() ObjectDisposedException.ThrowIf(IsDisposed, this); _dispatcher?.VerifyAccess(); + if (_executionToken is not null && !_executionToken.IsActiveCanvas(this)) + throw new InvalidOperationException("The executor-managed callback canvas is no longer active."); + } + + internal void ConfigureExecutionCallback( + RenderExecutionSessionToken token, + CallbackCanvasCapability capability) + { + ArgumentNullException.ThrowIfNull(token); + if (_executionToken is not null) + throw new InvalidOperationException("The canvas already has an execution capability."); + if (!token.IsActiveCanvas(this)) + throw new InvalidOperationException("The canvas must be active before a capability is attached."); + + _executionToken = token; + _callbackCapability = capability; + } + + /// + /// Attaches the guarded draw capability to this canvas for the duration of a direct replay, then detaches + /// it again. + /// + /// + /// A direct replay writes onto a canvas the executor keeps using afterwards, so the capability cannot be + /// ended by the way an execution view's is. Transform and clip are left + /// untouched: attaching the guard must not change a single pixel of what the replay draws. + /// + internal DirectExecutionScope BeginDirectExecution(RenderExecutionSessionToken token) + { + ArgumentNullException.ThrowIfNull(token); + VerifyAccess(); + int canvasSaveCount = Canvas.Save(); + var outer = new DirectExecutionScope( + this, + token, + _executionToken, + _callbackCapability, + _callbackStateFloor, + _isReplayingTargetScope, + canvasSaveCount); + try + { + token.EnterCanvas(this, facade: null); + _executionToken = token; + _callbackCapability = CallbackCanvasCapability.Draw; + _callbackStateFloor = _states.Count; + _isReplayingTargetScope = false; + return outer; + } + catch + { + Canvas.RestoreToCount(canvasSaveCount); + throw; + } + } + + private void EndDirectExecution( + RenderExecutionSessionToken token, + RenderExecutionSessionToken? outerToken, + CallbackCanvasCapability? outerCapability, + int outerStateFloor, + bool outerIsReplayingTargetScope, + int canvasSaveCount) + { + try + { + // The destination outlives the replay, so state the callback left pushed has to be unwound here; + // an execution view gets the same treatment from CloseWithoutFlush. + while (_states.Count > _callbackStateFloor && _states.TryPop(out CanvasPushedState? state)) + state.Pop(this); + } + finally + { + try + { + Canvas.RestoreToCount(canvasSaveCount); + } + finally + { + _executionToken = outerToken; + _callbackCapability = outerCapability; + _callbackStateFloor = outerStateFloor; + _isReplayingTargetScope = outerIsReplayingTargetScope; + token.ExitCanvas(this); + } + } + } + + internal readonly struct DirectExecutionScope( + ImmediateCanvas canvas, + RenderExecutionSessionToken token, + RenderExecutionSessionToken? outerToken, + CallbackCanvasCapability? outerCapability, + int outerStateFloor, + bool outerIsReplayingTargetScope, + int canvasSaveCount) : IDisposable + { + public void Dispose() => canvas.EndDirectExecution( + token, + outerToken, + outerCapability, + outerStateFloor, + outerIsReplayingTargetScope, + canvasSaveCount); + } + + internal ImmediateCanvas CreateExecutionView() + { + VerifyAccess(); + if (_executionToken is not null && !_isReplayingTargetScope) + { + throw new InvalidOperationException( + "An executor-managed callback canvas cannot create another execution view."); + } + + return new ImmediateCanvas(this); + } + + internal void ConfigureRawExecutionCallback(RenderExecutionSessionToken token) + { + ArgumentNullException.ThrowIfNull(token); + if (_executionToken is not null) + throw new InvalidOperationException("The canvas already has an execution capability."); + if (!token.IsActiveCanvas(this)) + throw new InvalidOperationException("The canvas must be active before a capability is attached."); + + _executionToken = token; + _callbackCapability = null; + _callbackStateFloor = _states.Count; + } + + internal void PinExecutionCallbackState() + { + VerifyAccess(); + if (_executionToken is null) + throw new InvalidOperationException("Only an execution callback canvas can pin its base state."); + + _callbackStateFloor = _states.Count; + } + + internal void CloseWithoutFlush() + { + if (IsDisposed) + return; + + if (_dispatcher == null) + { + CloseCore(flush: false, submit: false); + } + else + { + _dispatcher.Invoke(() => CloseCore(flush: false, submit: false)); + } + } + + internal void DrawExecutionInput( + SKImage image, + Rect destination, + SKPaint? paint = null, + SKSamplingOptions? sampling = null) + { + ArgumentNullException.ThrowIfNull(image); + VerifyPixelOperation(); + SKPaint effective = paint ?? _sharedFillPaint; + if (paint is null) + _sharedFillPaint.Reset(); + ApplyDirectBlendMode(effective); + effective.IsAntialias = true; + RecordPixelOperation(); + Canvas.DrawImage( + image, + SKRect.Create(image.Width, image.Height), + destination.ToSKRect(), + sampling ?? new SKSamplingOptions(SKCubicResampler.Mitchell), + effective); + } + + internal void DrawExecutionInputDeviceSpace(SKImage image, Point localDevicePoint) + { + ArgumentNullException.ThrowIfNull(image); + VerifyPixelOperation(); + using (PushDeviceSpace()) + { + _sharedFillPaint.Reset(); + ApplyDirectBlendMode(_sharedFillPaint); + _sharedFillPaint.IsAntialias = true; + RecordPixelOperation(); + Canvas.DrawImage( + image, + localDevicePoint.X, + localDevicePoint.Y, + new SKSamplingOptions(SKCubicResampler.Mitchell), + _sharedFillPaint); + } + } + + /// + /// Replays a target scope's recorded input, permitting nested render work for the replay's duration. + /// + internal void ReplayTargetScopeInput(Action replay) + { + ArgumentNullException.ThrowIfNull(replay); + VerifyAccess(); + if (_executionToken is null + || (_callbackCapability is not null and not CallbackCanvasCapability.TargetScope) + || _isReplayingTargetScope) + { + throw new InvalidOperationException("A target-scope replay is not active for this canvas."); + } + + _isReplayingTargetScope = true; + try + { + replay(this); + } + finally + { + _isReplayingTargetScope = false; + } + } + + private void CloseCore(bool flush, bool submit) + { + // Must suppress the finalizer before any backend operation that might throw. + IsDisposed = true; + GC.SuppressFinalize(this); + try + { + if (flush && GraphicsContextFactory.SharedContext is { } context) + { + context.SkiaContext.Flush(true, true); + RecordFlush(ImmediateCanvasFlushKind.CanvasClose); + GpuResourceReclaimQueue.DrainAfterContextSync(); + } + else if (submit && _renderTarget.RawValue.Context is GRContext submitContext) + { + submitContext.Flush(true, false); + RecordFlush(ImmediateCanvasFlushKind.CanvasSubmit); + } + + while (_states.TryPop(out CanvasPushedState? state)) + { + state.Pop(this); + } + + // Undo the base Save() (density != 1). Guard Canvas.Handle: SkiaSharp may have + // zeroed it during GrContext teardown; RestoreToCount on a zero Handle SIGSEGVs. + if (_baseSaveCount >= 0 && Canvas is not null && Canvas.Handle != IntPtr.Zero) + { + Canvas.RestoreToCount(_baseSaveCount); + } + } + catch + { + // Best-effort backend-state cleanup; disposal must still release managed paints. + } + finally + { + DrawableBrushMaterializer = null; + RenderTargetLeaseSession = null; + _sharedFillPaint.Dispose(); + _sharedStrokePaint.Dispose(); + } + } + + internal static void RecordFlush(ImmediateCanvasFlushKind kind) + { + for (FlushObserverScope? scope = s_flushObserver.Value; scope is not null; scope = scope.Parent) + { + try + { + scope.Observer(kind); + } + catch + { + // Test observation must never affect rendering or cleanup. + } + } + } + + private static void RecordPixelOperation() + { + for (PixelOperationObserverScope? scope = s_pixelOperationObserver.Value; + scope is not null; + scope = scope.Parent) + { + try + { + scope.Observer(); + } + catch + { + // Diagnostics observation must never affect rendering or cleanup. + } + } + } + + private sealed class FlushObserverScope( + FlushObserverScope? parent, + Action observer) : IDisposable + { + private bool _disposed; + + public FlushObserverScope? Parent { get; } = parent; + + public Action Observer { get; } = observer; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (!ReferenceEquals(s_flushObserver.Value, this)) + throw new InvalidOperationException("Immediate-canvas flush observers must be closed in LIFO order."); + s_flushObserver.Value = Parent; + } + } + + private sealed class PixelOperationObserverScope( + PixelOperationObserverScope? parent, + Action observer) : IDisposable + { + private bool _disposed; + + public PixelOperationObserverScope? Parent { get; } = parent; + + public Action Observer { get; } = observer; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (!ReferenceEquals(s_pixelOperationObserver.Value, this)) + throw new InvalidOperationException("Immediate-canvas pixel-operation observers must be closed in LIFO order."); + s_pixelOperationObserver.Value = Parent; + } + } + + private sealed class RenderTargetLeaseSessionScope( + ImmediateCanvas canvas, + RenderTargetLeaseSession? previous, + RenderTargetLeaseSession? previousAmbient) : IDisposable + { + private ImmediateCanvas? _canvas = canvas; + + public void Dispose() + { + ImmediateCanvas? owner = Interlocked.Exchange(ref _canvas, null); + if (owner is not null) + { + s_renderTargetLeaseSession.Value = previousAmbient; + if (!owner.IsDisposed) + owner.RenderTargetLeaseSession = previous; + } + } + } + + private sealed class DrawableBrushMaterializerScope( + ImmediateCanvas canvas, + DrawableBrushMaterializer? previous, + DrawableBrushMaterializer? previousAmbient) : IDisposable + { + private ImmediateCanvas? _canvas = canvas; + + public void Dispose() + { + ImmediateCanvas? owner = Interlocked.Exchange(ref _canvas, null); + if (owner is not null) + { + s_drawableBrushMaterializer.Value = previousAmbient; + if (!owner.IsDisposed) + owner.DrawableBrushMaterializer = previous; + } + } + } + + private void VerifyPixelOperation(bool isClear = false) + { + VerifyAccess(); + switch (_callbackCapability) + { + case CallbackCanvasCapability.TargetScope when !_isReplayingTargetScope: + throw new InvalidOperationException( + "A target-scope callback may only surround ReplayInput with transform and clip state."); + case CallbackCanvasCapability.TargetCommandEmpty: + throw new InvalidOperationException("An empty target command cannot perform pixel operations."); + case CallbackCanvasCapability.TargetCommandRegion when isClear: + throw new InvalidOperationException( + "The native clear operation is valid only for a full target command region."); + } + } + + private void VerifyHiddenLayerOperation() + { + if (_callbackCapability is not null && !_isReplayingTargetScope) + { + throw new InvalidOperationException( + "SaveLayer-backed state is not available on a guarded callback canvas."); + } + } + + private void VerifyNestedExecutionOperation() + { + if (_callbackCapability is not null && !_isReplayingTargetScope) + { + throw new InvalidOperationException( + "Nested render work, snapshots, and legacy raw callbacks are not available on a guarded callback canvas."); + } + } + + private void VerifyNativeTargetOperation() + { + if (_callbackCapability is not null && !_isReplayingTargetScope) + { + throw new InvalidOperationException( + "Raw surfaces and render targets are not available on a guarded callback canvas."); + } + } + + private void VerifyCallbackResource(object? resource, string parameterName) + { + if (resource is null + || _executionToken is null + || _callbackCapability is null + || _isReplayingTargetScope) + return; + + if (!_executionToken.IsResourceAuthorized(resource)) + { + throw new InvalidOperationException( + $"The resource passed as '{parameterName}' is not authorized in the active execution scope."); + } } private void ConfigureStrokePaint(Rect bounds, Pen.Resource? pen, BlendMode blendMode = BlendMode.SrcOver, float? scale = null) @@ -670,13 +1790,160 @@ private void ConfigureStrokePaint(Rect bounds, Pen.Resource? pen, BlendMode blen if (pen != null && pen.Thickness != 0) { _sharedStrokePaint.IsStroke = false; - new BrushConstructor(bounds, pen.Brush, blendMode, scale ?? _currentDensity, MaxWorkingScale).ConfigurePaint(_sharedStrokePaint); + new BrushConstructor( + bounds, + pen.Brush, + ResolvePaintBlendMode(blendMode), + scale ?? _currentDensity, + MaxWorkingScale, + Intent, + DrawableBrushMaterializer, + RenderTargetLeaseSession).ConfigurePaint(_sharedStrokePaint); } } private void ConfigureFillPaint(Rect bounds, Brush.Resource? brush, BlendMode blendMode = BlendMode.SrcOver, float? scale = null) { _sharedFillPaint.Reset(); - new BrushConstructor(bounds, brush, blendMode, scale ?? _currentDensity, MaxWorkingScale).ConfigurePaint(_sharedFillPaint); + new BrushConstructor( + bounds, + brush, + ResolvePaintBlendMode(blendMode), + scale ?? _currentDensity, + MaxWorkingScale, + Intent, + DrawableBrushMaterializer, + RenderTargetLeaseSession).ConfigurePaint(_sharedFillPaint); + } + + private BlendMode ResolvePaintBlendMode(BlendMode fallback) + => _directBlendMode ?? fallback; + + private void ApplyDirectBlendMode(SKPaint paint) + { + if (_directBlendMode is { } blendMode) + paint.BlendMode = (SKBlendMode)blendMode; + } + + private bool TryDrawProductCoverageRectangle(Geometry.Resource geometry, SKPaint paint) + { + // Skia's path antialiasing may publish one-axis coverage at a fractional rectangle corner. + // A Porter-Duff mask needs the geometric area product, because the same coverage is later + // applied to every pixel in the isolated target-layer domain. + if ((_directBlendMode != BlendMode.DstOut && !_productRectangleCoverage) + || geometry.GetOriginal() is not RectGeometry + || _currentTransform.M12 != 0 + || _currentTransform.M13 != 0 + || _currentTransform.M21 != 0 + || _currentTransform.M23 != 0 + || _currentTransform.M33 != 1) + { + return false; + } + + var rectangle = (RectGeometry.Resource)geometry; + if (rectangle.Width <= 0 || rectangle.Height <= 0) + { + return true; + } + + SKColor previousColor = paint.Color; + SKShader? previousShader = paint.Shader; + using SKShader? ownedSourceShader = previousShader is null + ? SKShader.CreateColor(new SKColor( + previousColor.Red, + previousColor.Green, + previousColor.Blue, + 255)) + : null; + SKShader sourceShader = previousShader ?? ownedSourceShader!; + using var uniforms = new SKRuntimeEffectUniforms(s_rectCoverageEffect.Value); + using var children = new SKRuntimeEffectChildren(s_rectCoverageEffect.Value); + uniforms["left"] = (float)geometry.Bounds.Left; + uniforms["top"] = (float)geometry.Bounds.Top; + uniforms["right"] = (float)geometry.Bounds.Right; + uniforms["bottom"] = (float)geometry.Bounds.Bottom; + uniforms["scaleX"] = MathF.Abs(_currentTransform.M11); + uniforms["scaleY"] = MathF.Abs(_currentTransform.M22); + children["src"] = sourceShader; + using SKShader coverageShader = s_rectCoverageEffect.Value.ToShader(uniforms, children); + bool previousAntialias = paint.IsAntialias; + try + { + paint.Color = new SKColor(255, 255, 255, previousColor.Alpha); + paint.Shader = coverageShader; + paint.IsAntialias = false; + Canvas.DrawPaint(paint); + } + finally + { + paint.Shader = previousShader; + paint.Color = previousColor; + paint.IsAntialias = previousAntialias; + } + + return true; + } + + private static SKRuntimeEffect CreateRectCoverageEffect() + { + const string source = + """ + uniform shader src; + uniform float left; + uniform float top; + uniform float right; + uniform float bottom; + uniform float scaleX; + uniform float scaleY; + + half4 main(float2 p) + { + float x = clamp((p.x - left) * scaleX + 0.5, 0.0, 1.0) + * clamp((right - p.x) * scaleX + 0.5, 0.0, 1.0); + float y = clamp((p.y - top) * scaleY + 0.5, 0.0, 1.0) + * clamp((bottom - p.y) * scaleY + 0.5, 0.0, 1.0); + return src.eval(p) * half(x * y); + } + """; + return SKRuntimeEffect.CreateShader(source, out string? errorText) + ?? throw new InvalidOperationException( + $"Failed to compile the rectangle coverage shader: {errorText}"); + } + + /// + /// Scales a premultiplied layer by a float opacity, without the 8-bit step Skia's own layer-alpha and + /// DstIn-mask paths both introduce. + /// + /// + /// Multiplying all four premultiplied components by the same factor is exactly what group opacity means, + /// and it leaves the straight color untouched. A color-matrix filter would express the same scale but + /// clamps its result to [0, 1], which would crush the out-of-range values an RGBA16F layer is allowed to + /// carry; a runtime color filter has no such clamp. + /// + private static SKRuntimeEffect CreateOpacityScaleEffect() + { + const string source = + """ + uniform half opacity; + + half4 main(half4 color) + { + return color * opacity; + } + """; + return SKRuntimeEffect.CreateColorFilter(source, out string? errorText) + ?? throw new InvalidOperationException( + $"Failed to compile the opacity scale color filter: {errorText}"); + } + + /// Builds the float-precision group-opacity color filter for one normalized opacity. + private static SKColorFilter CreateOpacityColorFilter(float opacity) + { + using var uniforms = new SKRuntimeEffectUniforms(s_opacityScaleEffect.Value) + { + { "opacity", opacity }, + }; + return s_opacityScaleEffect.Value.ToColorFilter(uniforms); } } diff --git a/src/Beutl.Engine/Graphics/Matrix.cs b/src/Beutl.Engine/Graphics/Matrix.cs index f2f6120d42..e7252b36c4 100644 --- a/src/Beutl.Engine/Graphics/Matrix.cs +++ b/src/Beutl.Engine/Graphics/Matrix.cs @@ -376,11 +376,18 @@ public override int GetHashCode() /// /// Determines if the current matrix contains perspective (non-affine) transforms (true) or only (affine) transforms that could be mapped into an 2x3 matrix (false). /// - private bool ContainsPerspective() + public bool ContainsPerspective() { return M13 != 0 || M23 != 0 || M33 != 1; } + /// + /// Returns the homogeneous divisor is divided by when it is transformed by this + /// matrix. It is affine in , so its extrema over a rectangle are at the corners. + /// A point whose divisor is zero lies on the camera plane and has no finite image. + /// + public float GetTransformDivisor(Point p) => (p.X * M13) + (p.Y * M23) + M33; + /// /// Returns a String representing this matrix instance. /// diff --git a/src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs b/src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs index d9bd36c4cc..51728d8f03 100644 --- a/src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs @@ -1,20 +1,27 @@ using Beutl.Engine; using Beutl.Graphics.Rendering; -using Beutl.Logging; using Beutl.Media; -using Microsoft.Extensions.Logging; using SkiaSharp; namespace Beutl.Graphics.Particles; internal sealed class ParticleRenderNode(ParticleEmitter.Resource particle) : RenderNode { - private static readonly ILogger s_logger = Log.CreateLogger("ParticleRenderNode"); - - private (RenderTarget RT, Drawable.Resource? Resource, int? Version, float Density)? _cachedRenderTarget; - private Rect _drawableBounds; - // Requested output density; a change invalidates the cached particle drawable. - private float _renderScale = 1f; + private const long MaximumFiniteLayerBytes = 1L * 1024 * 1024 * 1024; + private static readonly Rect s_drawableRecordingDomain = new(0, 0, 1920, 1080); + private static readonly Rect s_fallbackBounds = new(-5, -5, 10, 10); + private static readonly RenderResourceSlot s_particlesSlot = new(); + private static readonly RenderResourceSlot s_fallbackFillSlot = new(); + private static readonly OpaqueRenderDefinition s_fallbackDefinition = + OpaqueRenderDefinition.Create( + static (session, _) => session.UseResource( + s_fallbackFillSlot, + fill => DrawFallbackParticle(session, fill)), + OpaqueRenderBoundsContract.Source(s_fallbackBounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector, + resources: [s_fallbackFillSlot]); public (ParticleEmitter.Resource Resource, int Version)? Particle { get; private set; } = particle.Capture(); @@ -30,261 +37,262 @@ public bool Update(ParticleEmitter.Resource resource) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - if (!Particle.HasValue) return []; - var resource = Particle.Value.Resource; - var particles = resource.GetAliveParticles(); - if (particles.Length == 0) return []; - - float w = RenderNodeContext.ResolveWorkingScale([], context.OutputScale, context.MaxWorkingScale); - if (!_cachedRenderTarget.HasValue || - _renderScale != w || - !ReferenceEquals(_cachedRenderTarget.Value.Resource, resource.ParticleDrawable) || - _cachedRenderTarget.Value.Version != resource.ParticleDrawable?.Version) - { - _cachedRenderTarget?.RT.Dispose(); - _cachedRenderTarget = null; - _renderScale = w; + if (Particle is not { } snapshot) + return; - if (resource.ParticleDrawable is { } tracked) - { - _cachedRenderTarget = RenderDrawableToTarget(tracked, w, context.MaxWorkingScale, out _drawableBounds); - } - else - { - _cachedRenderTarget = RenderFallbackEllipse(w, context.MaxWorkingScale, out _drawableBounds); - } - } + ParticleEmitter.Resource resource = snapshot.Resource; + Particle[] particles = resource.GetAliveParticles().ToArray(); + if (particles.Length == 0) + return; + + RenderFragmentHandle? source = resource.ParticleDrawable is { } drawable + ? RecordDrawableSource(context, drawable) + : RecordFallbackSource(context); + if (source is null) + return; - if (_cachedRenderTarget == null) + if (!source.TryGetMetadata(out RenderFragmentMetadata sourceMetadata)) { - return []; + throw new InvalidOperationException( + "A particle source with symbolic metadata must be localized by an explicit finite Layer."); } - // Compute total bounds from all alive particles - Rect totalBounds = default; - var particlesSpan = particles.Span; - for (int i = 0; i < particles.Length; i++) - { - ref readonly Particle p = ref particlesSpan[i]; - if (!p.IsAlive) continue; + Rect sourceBounds = sourceMetadata.Bounds; + Rect totalBounds = CalculateParticleBounds(particles, sourceBounds); + if (totalBounds.Width <= 0 || totalBounds.Height <= 0) + return; + + bool requiresClippedLayer = context.TargetDomain is not null + && RequiresClippedLayer(totalBounds, context.OutputScale); + RenderResource particlesToken = context.Borrow(particles); + TargetCommandDefinition definition = + TargetCommandDefinition.Create( + static (session, _) => session.UseResource( + s_particlesSlot, + current => DrawParticles(session, current)), + affectedRegion: requiresClippedLayer + ? TargetRegion.Full + : TargetRegion.Region(totalBounds), + queryBounds: totalBounds, + hitTest: RenderHitTestContract.None, + resources: [s_particlesSlot]); + RenderFragmentHandle painter = context.TargetCommand( + [source], + definition.Call(default, [s_particlesSlot.Bind(particlesToken)])); + + // A union beyond the buffer budget is mostly off-target travel. Preserve the finite layer for + // ordinary emitters, but clip an oversized union to its owning target before allocation. + context.Publish(requiresClippedLayer + ? context.OwningTargetLayer([painter]) + : context.Layer([painter], totalBounds)); + } - float scale = p.CurrentSize / 10f; - if (scale <= 0) continue; + private static RenderFragmentHandle? RecordDrawableSource( + RenderNodeContext context, + Drawable.Resource drawable) + { + using var root = new DrawableRenderNode(drawable); + using (var graphics = new GraphicsContext2D( + root, + s_drawableRecordingDomain.Size, + context.OutputScale)) + { + // This only builds the child's RenderNode tree. Pixel execution remains in the parent + // request after RecordSubtree imports the complete child sequence. + drawable.GetOriginal()!.Render(graphics, drawable); + } - // Use a conservative square bounding box that safely encloses any rotation - float maxDim = MathF.Max((float)_drawableBounds.Width, (float)_drawableBounds.Height) * scale; - var particleBounds = new Rect( - p.X - maxDim / 2f, - p.Y - maxDim / 2f, - maxDim, - maxDim); + IReadOnlyList outputs = context.RecordSubtree(root); + Rect bounds = CalculateBounds(outputs, s_drawableRecordingDomain); + if (bounds.Width <= 0 || bounds.Height <= 0) + return null; - totalBounds = totalBounds.Union(particleBounds); - } + return context.Layer(outputs, bounds); + } - // Capture references for the lambda - RenderTarget cachedRT = _cachedRenderTarget.Value.RT; - float cachedDensity = _cachedRenderTarget.Value.Density; - Rect drawableBounds = _drawableBounds; - - return - [ - RenderNodeOperation.CreateLambda( - totalBounds, - canvas => DrawAllParticles(canvas, cachedRT, particles, drawableBounds, cachedDensity), - effectiveScale: EffectiveScale.At(cachedDensity)) - ]; + private static RenderFragmentHandle RecordFallbackSource(RenderNodeContext context) + { + Brush.Resource fill = Brushes.Resource.White; + RenderResource fillToken = context.Borrow(fill); + return context.OpaqueSource( + s_fallbackDefinition.Call( + default, + [s_fallbackFillSlot.Bind(fillToken)])); } - private static void DrawAllParticles( - ImmediateCanvas canvas, - RenderTarget cachedRT, - ReadOnlyMemory particles, - Rect drawableBounds, - float w) + private static void DrawFallbackParticle(OpaqueRenderSession session, Brush.Resource fill) { - // Snapshot once and reuse across the loop (w == 1 uses point-blit instead). - SKImage? cachedImage = null; - if (w != 1f) - { - cachedRT.VerifyAccess(); - cachedImage = cachedRT.Value.Snapshot(); - } + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(canvas => canvas.DrawEllipse(s_fallbackBounds, fill, null)); + session.Publish(output); + } - try + /// + /// The particle's own transform scales and rotates the source, so the blit has to resample it. + /// + /// + /// Point sampling here reduces a particle to whichever texels its sample points land on, which is visible + /// as stair-stepped edges on every particle whose size is not exactly the source's. Mitchell is the same + /// resampler the canvas applies to any other scaled bitmap. + /// + private static readonly SKSamplingOptions s_particleSampling = new(SKCubicResampler.Mitchell); + + private static void DrawParticles(TargetCommandSession session, Particle[] particles) + { + session.Canvas.Use(canvas => { - var particlesSpan = particles.Span; - for (int i = 0; i < particles.Length; i++) + foreach (RenderExecutionInput input in session.Inputs) { - ref readonly Particle p = ref particlesSpan[i]; - if (!p.IsAlive) continue; - - float scale = p.CurrentSize / 10f; - float opacity = p.CurrentOpacity / 100f; - if (opacity <= 0 || scale <= 0) continue; - - float rotRad = p.Rotation * MathF.PI / 180f; - Matrix transform = Matrix.CreateScale(scale, scale) - * Matrix.CreateRotation(rotRad) - * Matrix.CreateTranslation(p.X, p.Y); - - using (canvas.PushTransform(transform)) - using (canvas.PushOpacity(opacity)) - { - Color color = p.CurrentColor; - if (color != Colors.White) - { - using var colorFilter = SKColorFilter.CreateBlendMode( - new SKColor(color.R, color.G, color.B, color.A), - SKBlendMode.Modulate); - using var paint = new SKPaint(); - paint.ColorFilter = colorFilter; - - using (canvas.PushPaint(paint)) - { - DrawCached(canvas, cachedRT, cachedImage, drawableBounds, w); - } - } - else - { - DrawCached(canvas, cachedRT, cachedImage, drawableBounds, w); - } - } + DrawParticleInput(canvas, input, particles); } - } - finally - { - cachedImage?.Dispose(); - } + }); } - // Blit the cached particle buffer: point-blit at w == 1, scaled image at w != 1. - private static void DrawCached( - ImmediateCanvas canvas, RenderTarget cachedRT, SKImage? cachedImage, Rect drawableBounds, float w) + private static void DrawParticleInput( + ImmediateCanvas canvas, + RenderExecutionInput input, + Particle[] particles) { - var offset = new Point(-drawableBounds.Width / 2, -drawableBounds.Height / 2); - if (w == 1f) - { - canvas.DrawRenderTarget(cachedRT, offset); - } - else + Point center = input.Bounds.Center; + for (int i = 0; i < particles.Length; i++) { - canvas.DrawImageScaled(cachedImage!, - new Rect(offset.X, offset.Y, drawableBounds.Width, drawableBounds.Height)); + ref readonly Particle particle = ref particles[i]; + if (!particle.IsAlive) + continue; + + float scale = particle.CurrentSize / 10f; + float opacity = particle.CurrentOpacity / 100f; + if (!float.IsFinite(scale) + || !float.IsFinite(opacity) + || scale <= 0 + || opacity <= 0) + { + continue; + } + + float rotation = particle.Rotation * MathF.PI / 180f; + Matrix transform = Matrix.CreateTranslation(-center.X, -center.Y) + * Matrix.CreateScale(scale, scale) + * Matrix.CreateRotation(rotation) + * Matrix.CreateTranslation(particle.X, particle.Y); + Color color = particle.CurrentColor; + using SKColorFilter? colorFilter = color == Colors.White + ? null + : SKColorFilter.CreateBlendMode( + new SKColor(color.R, color.G, color.B, color.A), + SKBlendMode.Modulate); + using (canvas.PushTransform(transform)) + using (var paint = new SKPaint + { + IsAntialias = true, + ColorFilter = colorFilter, + Color = SKColors.White.WithAlpha( + (byte)Math.Clamp(MathF.Round(opacity * byte.MaxValue), 0, byte.MaxValue)), + }) + { + // The particle transform already carries the resampling, so the blit itself stays + // unfiltered -- the same footprint the source would have drawn under that transform. + input.Draw(canvas, paint, s_particleSampling); + } } } - private static (RenderTarget, Drawable.Resource, int, float)? RenderDrawableToTarget( - Drawable.Resource drawable, - float nominalScale, - float maxWorkingScale, - out Rect bounds) + /// + /// The union of the axis-aligned extents each live particle draws into. + /// + /// + /// Every particle is scaled and then rotated about the source's own centre, so its extent is the rotated + /// source rectangle's bounding box, not a square of the source's longer side: a 20x20 source turned 45 + /// degrees reaches about 4.14 further along each axis than that square. This is the rectangle the layer + /// buffer is allocated from, so anything it misses is clipped away rather than merely mismeasured. + /// + private static Rect CalculateParticleBounds(ReadOnlySpan particles, Rect sourceBounds) { - using var node = new DrawableRenderNode(drawable); - // 1920x1080 is only the logical measurement canvas; actual buffer is sized from drawable bounds. - using (var gctx = new GraphicsContext2D(node, new Size(1920, 1080), nominalScale)) + Rect totalBounds = Rect.Empty; + bool hasBounds = false; + var sourceWidth = (float)sourceBounds.Width; + var sourceHeight = (float)sourceBounds.Height; + for (int i = 0; i < particles.Length; i++) { - drawable.GetOriginal().Render(gctx, drawable); - } + ref readonly Particle particle = ref particles[i]; + if (!particle.IsAlive) + continue; - var processor = new RenderNodeProcessor(node, false, nominalScale, maxWorkingScale); - var ops = processor.PullToRoot(); + float scale = particle.CurrentSize / 10f; + if (!float.IsFinite(scale) || scale <= 0) + continue; - bounds = ops.Aggregate(Rect.Empty, (a, n) => a.Union(n.Bounds)); - // Clamp density so oversized buffers degrade instead of failing to allocate. - float w = nominalScale > 1f - ? RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, nominalScale) - : nominalScale; - var rect = w == 1f ? PixelRect.FromRect(bounds) : PixelRect.FromRect(bounds, w); + float radians = particle.Rotation * MathF.PI / 180f; + if (!float.IsFinite(radians)) + continue; - if (rect.Width <= 0 || rect.Height <= 0) - { - foreach (var op in ops) - op.Dispose(); - return null; + float cos = MathF.Abs(MathF.Cos(radians)); + float sin = MathF.Abs(MathF.Sin(radians)); + float width = ((sourceWidth * cos) + (sourceHeight * sin)) * scale; + float height = ((sourceWidth * sin) + (sourceHeight * cos)) * scale; + if (!float.IsFinite(width) || !float.IsFinite(height) || width <= 0 || height <= 0) + continue; + + var particleBounds = new Rect( + particle.X - (width / 2f), + particle.Y - (height / 2f), + width, + height); + totalBounds = hasBounds ? totalBounds.Union(particleBounds) : particleBounds; + hasBounds = true; } - var renderTarget = RenderTarget.Create(rect.Width, rect.Height); - if (renderTarget == null) + return hasBounds ? totalBounds : Rect.Empty; + } + + private static bool RequiresClippedLayer(Rect bounds, float scale) + { + PixelRect footprint = PixelRect.FromRect(bounds, scale); + if (footprint.Width > RenderScaleUtilities.MaxBufferDimension + || footprint.Height > RenderScaleUtilities.MaxBufferDimension) { - foreach (var op in ops) - op.Dispose(); - s_logger.LogWarning( - "Particle drawable buffer allocation failed ({Width}x{Height} px, density {Scale}, bounds {Bounds}); particles will be omitted from this frame.", - rect.Width, rect.Height, w, bounds); - return null; + return true; } - int consumed = 0; try { - using var canvas = new ImmediateCanvas(renderTarget, w, maxWorkingScale, logicalSize: bounds.Size); - canvas.Clear(); - using (canvas.PushTransform(Matrix.CreateTranslation(-bounds.X, -bounds.Y))) - { - foreach (var op in ops) - { - op.Render(canvas); - consumed++; - op.Dispose(); - } - } + long bytes = checked((long)footprint.Width * footprint.Height * 8); + return bytes > MaximumFiniteLayerBytes; } - catch + catch (OverflowException) { - // renderTarget is not yet owned by a caller; release it with the un-rendered ops. Its - // GPU-native teardown can itself throw, so swallow that so the original render failure wins. - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - try - { - renderTarget.Dispose(); - } - catch - { - // ignored - } - throw; + return true; } - - return (renderTarget, drawable, drawable.Version, w); } - private static (RenderTarget, Drawable.Resource?, int?, float)? RenderFallbackEllipse( - float w, float maxWorkingScale, out Rect bounds) + private static Rect CalculateBounds( + IReadOnlyList fragments, + Rect symbolicOwnerDomain) { - bounds = new Rect(-5, -5, 10, 10); - w = w > 1f - ? RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, w) - : w; - - int dim = w == 1f ? 10 : (int)MathF.Ceiling(10 * w); - var renderTarget = RenderTarget.Create(dim, dim); - if (renderTarget == null) - { - s_logger.LogWarning( - "Fallback particle buffer allocation failed ({Width}x{Height} px, density {Scale}); particles will be omitted from this frame.", - dim, dim, w); - return null; - } - - using (var canvas = new ImmediateCanvas(renderTarget, w, maxWorkingScale, logicalSize: bounds.Size)) + Rect bounds = Rect.Empty; + bool hasSymbolicMetadata = false; + foreach (RenderFragmentHandle fragment in fragments) { - canvas.Clear(); - using (canvas.PushTransform(Matrix.CreateTranslation(5, 5))) + if (!fragment.TryGetMetadata(out RenderFragmentMetadata metadata)) { - canvas.DrawEllipse(bounds, Brushes.Resource.White, null); + hasSymbolicMetadata = true; + continue; } + + bounds = bounds.Union(metadata.Bounds); } - return (renderTarget, null, null, w); + return hasSymbolicMetadata ? bounds.Union(symbolicOwnerDomain) : bounds; } protected override void OnDispose(bool disposing) { - _cachedRenderTarget?.RT.Dispose(); - _cachedRenderTarget = null; Particle = null; } + + private readonly record struct ParticleCommandState; + + private readonly record struct ParticleFallbackState; } diff --git a/src/Beutl.Engine/Graphics/Rect.cs b/src/Beutl.Engine/Graphics/Rect.cs index 3bd408552c..71a5683222 100644 --- a/src/Beutl.Engine/Graphics/Rect.cs +++ b/src/Beutl.Engine/Graphics/Rect.cs @@ -28,6 +28,44 @@ public readonly struct Rect IDivisionOperators, ITupleConvertible { + /// + /// The homogeneous divisor clips at by default. This is a + /// pragmatic bound, not the rasterizer's: it sits 820x in front of , + /// so content whose divisor falls between the two is drawn but is not covered by the box the + /// default returns. A Rotation3DTransform's divisor is 1 + z / Depth, so the default + /// gives up whatever comes closer to the eye than one twentieth of the transform's projection depth. + /// + /// + /// + /// Clipping at the rasterizer's own near plane would cover everything drawn but is unusable as a + /// default: a 1200x54 layer at the default Depth of 500 rotated 60 degrees about Y then declares a + /// box 4.73 million px wide, and RenderScaleUtilities.ClampWorkingScaleToBufferBudget — which + /// the planner feeds the declared output bounds unintersected — divides the working scale by ~289 to + /// fit its 16384 px budget. 0.05 leaves that ordinary case unclamped at a 2x preview scale, which + /// anything below ~0.035 would not. + /// + /// + /// The residual loss is real and reachable at ordinary depths. The clipped box's far edge sits at + /// centre - (1 / nearPlane - 1) * Depth * cot(angle), so it lands inside the frame whenever + /// that term drops below the half-frame width — which is what a near-edge-on card flip does. A + /// 1200x54 layer at Depth 500 in a 256x144 frame loses none of the 13824 pixels it draws at 60 + /// degrees, 6480 of 18188 at 89.5 degrees, and 13680 of 18340 at 89.8 degrees; a 124x58 layer at + /// Depth 10 loses 2592 of 18232 at 60 degrees. A caller that knows where its output is delivered gives + /// up none of that: see , which is what the built-in transforms + /// declare. + /// + /// + public const float DefaultNearPlane = 0.05f; + + /// + /// The homogeneous divisor the rasterizer itself clips perspective geometry at — Skia's + /// SkPathPriv::kW0PlaneDistance, 1 / (1 << 14), applied by both + /// SkPathPriv::PerspectiveClip and Ganesh's GrQuadUtils::ClipToW0. A box clipped here + /// contains every pixel that can be drawn; see for why that exactness + /// is not affordable as the default. + /// + public const float RasterizerNearPlane = 1f / 16384f; + /// /// An empty rectangle. /// @@ -403,11 +441,17 @@ public bool Intersects(Rect rect) } /// - /// Returns the axis-aligned bounding box of a transformed rectangle. + /// The box around the four mapped corners, with no camera-plane handling. /// - /// The transform. - /// The bounding box - public Rect TransformToAABB(Matrix matrix) + /// + /// A projective map takes lines to lines, so this box is exact — but only while the rectangle stays + /// on one side of 's w = 0 plane. A rectangle that crosses that + /// plane has an unbounded image and its behind-plane corners are point-reflected through the origin, + /// so the box lands on the wrong side of the image rather than containing it. That is why this is + /// not the public answer: establishes the precondition and then calls + /// this. + /// + internal Rect TransformToMappedCornerAABB(Matrix matrix) { ReadOnlySpan points = [ @@ -433,6 +477,111 @@ public Rect TransformToAABB(Matrix matrix) return new Rect(new Point(left, top), new Point(right, bottom)); } + /// + /// Returns an axis-aligned bounding box of this rectangle transformed by , + /// with the part behind the matrix's camera plane clipped away first. + /// + /// The transform. + /// + /// The smallest homogeneous divisor kept. It is what makes the answer finite: a point whose divisor + /// approaches zero escapes to infinity, so a plane-crossing rectangle has no finite bounding box at + /// all. Lowering it widens the box without bound; raising it starts cutting geometry that would + /// still be drawn, which already does by design. + /// On an inverted matrix it is a far cutoff in source space rather than a near one, because + /// the inverse divisor is the reciprocal of the forward one: it drops source content further from + /// the eye than 1 / nearPlane times the projection depth, reachable only where the rectangle's + /// half-extent exceeds (1 / nearPlane - 1) times that depth. + /// + /// + /// The bounding box, or when no part of the rectangle reaches the near plane — + /// which, at , does not mean the rasterizer draws none of it. + /// Identical to the plain mapped-corner box whenever the rectangle does not cross the camera + /// plane, which is every case those corners already answer exactly. + /// + /// + /// Maps this rectangle through covering every pixel the rasterizer can draw, + /// then keeps whatever of that box either reaches or would have been + /// declared at . + /// + /// + /// + /// A perspective-mapped rectangle's exact box runs to millions of pixels as the near edge tips towards + /// the eye, and pays for that in + /// working density. bought the density back by giving up a wedge the + /// rasterizer still draws - and, when the wedge falls inside the frame, that wedge is content the viewer + /// should have seen. + /// + /// + /// The union is what makes both affordable: inside the box is exact, so + /// nothing drawn there is given up, and outside it the box is never larger than + /// already made it, so the density it costs is unchanged. Without a + /// delivery region there is no output clip to be exact against and the pragmatic box is returned. + /// + /// + public Rect TransformToDeliveredAABB(Matrix matrix, Rect? deliveredTo) + { + Rect pragmatic = TransformToAABB(matrix); + if (deliveredTo is not { } delivered) + return pragmatic; + + return TransformToAABB(matrix, RasterizerNearPlane).Intersect(pragmatic.Union(delivered)); + } + + public Rect TransformToAABB(Matrix matrix, float nearPlane = DefaultNearPlane) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(nearPlane); + if (!matrix.ContainsPerspective()) + return TransformToMappedCornerAABB(matrix); + + ReadOnlySpan corners = [TopLeft, TopRight, BottomRight, BottomLeft]; + Span divisors = stackalloc float[4]; + float min = float.MaxValue; + float max = float.MinValue; + for (int i = 0; i < corners.Length; i++) + { + float w = matrix.GetTransformDivisor(corners[i]); + divisors[i] = w; + if (w < min) min = w; + if (w > max) max = w; + } + + // The divisor is affine over the rectangle, so a single sign at the corners means no interior + // point reaches the plane and the mapped-corner box is already exact. + if (min > 0 || max < 0) + return TransformToMappedCornerAABB(matrix); + + Span clipped = stackalloc Point[8]; + int count = 0; + for (int i = 0; i < corners.Length; i++) + { + int next = (i + 1) % corners.Length; + float w = divisors[i]; + float nextW = divisors[next]; + if (w >= nearPlane) + clipped[count++] = corners[i]; + if ((w < nearPlane) != (nextW < nearPlane)) + clipped[count++] = corners[i] + ((corners[next] - corners[i]) * ((nearPlane - w) / (nextW - w))); + } + + if (count == 0) + return Empty; + + float left = float.MaxValue; + float right = float.MinValue; + float top = float.MaxValue; + float bottom = float.MinValue; + for (int i = 0; i < count; i++) + { + Point p = clipped[i].Transform(matrix); + if (p.X < left) left = p.X; + if (p.X > right) right = p.X; + if (p.Y < top) top = p.Y; + if (p.Y > bottom) bottom = p.Y; + } + + return new Rect(new Point(left, top), new Point(right, bottom)); + } + /// /// Translates the rectangle by an offset. /// diff --git a/src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs index b616ae8520..144ed235f6 100644 --- a/src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs @@ -16,18 +16,27 @@ public bool Update(BlendMode blendMode) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - context.IsRenderCacheEnabled = BlendMode == BlendMode.SrcOver; - return context.Input.Select(r => - { - return RenderNodeOperation.CreateDecorator(r, canvas => - { - using (canvas.PushBlendMode(BlendMode)) - { - r.Render(canvas); - } - }); - }).ToArray(); + BlendMode blendMode = BlendMode; + if (blendMode != BlendMode.SrcOver) + context.DisableRenderCache(); + + context.PublishMappedInputs( + blendMode, + static (context, input, value) => value == BlendMode.SrcOver + ? input + : context.Blend(input, value)); + } + + internal static bool RequiresFullTargetRegion(BlendMode blendMode) + { + return blendMode is BlendMode.Clear + or BlendMode.Src + or BlendMode.SrcIn + or BlendMode.DstIn + or BlendMode.SrcOut + or BlendMode.DstATop + or BlendMode.Modulate; } } diff --git a/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCache.cs b/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCache.cs index 913c0facb2..fe142ad414 100644 --- a/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCache.cs +++ b/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCache.cs @@ -1,135 +1,346 @@ -using System.Runtime.InteropServices; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using Beutl.Media; using Microsoft.Extensions.Logging; namespace Beutl.Graphics.Rendering.Cache; -public sealed class RenderNodeCache(RenderNode node) : IDisposable +/// Stores reusable render outputs for one render node. +/// +/// Cache access is serialized by the owning render lifetime and render thread. +/// This type does not provide independent synchronization and must not be accessed concurrently. +/// +internal sealed class RenderNodeCache(RenderNode node) : IDisposable { private readonly WeakReference _node = new(node); - private readonly List<(RenderTarget, Rect)> _cache = new(1); + private CacheStorage _storage = CacheStorage.Empty; - public const int Count = 3; + internal const int StableRequestCount = 3; - private int _count; - - // Set when CreateDefaultCache refuses this subtree; cleared on node change or invalidation. - private bool _cacheRejected; + private int _successfulStableRequests; ~RenderNodeCache() { - if (!IsDisposed) - Dispose(); + Dispose(disposing: false); } - public bool IsCached => _cache.Count != 0; + internal bool IsCached => _storage.Identity is not null || _storage.Values.Length != 0; + + internal int CacheCount => _storage.Values.Length; + + internal float IdentityDensity => _storage.IdentityDensity; + + internal Type? NodeType => _node.TryGetTarget(out RenderNode? node) ? node.GetType() : null; + + internal bool IsDisposed { get; private set; } - public int CacheCount => _cache.Count; + internal int SuccessfulStableRequestCount => _successfulStableRequests; + + internal bool CanCapture => _successfulStableRequests >= StableRequestCount; /// - /// The pixel density the cached tiles were rasterized at. Replay re-tags tiles at this density. + /// The dependency closure's change-version stamp at the request that published this cache, or 0 when + /// unstamped. Root-independent, so a change another root already consumed from + /// still shows up here. /// - public float Density { get; private set; } = 1f; + internal long DependencySignature { get; set; } + + internal void RecordSuccessfulStableRequest() + { + if (!IsDisposed && _successfulStableRequests < StableRequestCount) + _successfulStableRequests++; + } + + internal void Reset() + { + if (IsDisposed) + return; - public bool IsDisposed { get; private set; } + _successfulStableRequests = 0; + DependencySignature = 0; + InvalidateStorage(); + } - public void ReportRenderCount(int count) + private void InvalidateStorage() { - _count = count; + CacheStorage previous = DetachStorage(); + if (previous.Identity is not null || previous.Values.Length != 0) + { + RenderNodeCacheHelper._logger.LogInformation("Invalidating Cache for {Node}", + _node.TryGetTarget(out RenderNode? node) ? node : null); + } + + DisposeStorage(previous); } - public void IncrementRenderCount() + public void Dispose() { - if (_node.TryGetTarget(out RenderNode? node) && !node.HasChanges) + try { - _count++; + Dispose(disposing: true); } - else + finally { - _count = 0; - Invalidate(); + GC.SuppressFinalize(this); } } - public bool CanCache() + private void Dispose(bool disposing) { - return _count >= Count; - } + if (IsDisposed) + return; - /// True once cache creation was refused; stops re-attempts each frame. - public bool IsCacheRejected => _cacheRejected; + IsDisposed = true; + DependencySignature = 0; + CacheStorage storage = DetachStorage(); + if (disposing) + { + DisposeStorage(storage); + return; + } - public void RejectCache() - { - _cacheRejected = true; + try + { + DisposeStorage(storage); + } + catch + { + // Finalizers must never let cleanup failures terminate the process. + } } - public void Invalidate() + internal RenderTarget UseCache(out Rect bounds) { - if (_cache.Count != 0) + if (_storage.Values.Length == 0) { - RenderNodeCacheHelper._logger.LogInformation("Invalidating Cache for {Node}", - _node.TryGetTarget(out RenderNode? node) ? node : null); + throw new InvalidOperationException("No cached render target is available."); } - foreach ((RenderTarget, Rect) item in _cache) + RenderNodeCachedValue value = _storage.Values[0]; + bounds = value.Bounds; + return value.Target.ShallowCopy(); + } + + internal IEnumerable<(RenderTarget RenderTarget, Rect Bounds)> UseCache() + { + return _storage.Values + .Select(static value => (value.Target.ShallowCopy(), value.Bounds)) + .ToArray(); + } + + internal bool TryGetCachedOutput( + RenderOutputCacheIdentity identity, + out RenderNodeCachedOutput? output) + { + ArgumentNullException.ThrowIfNull(identity); + if (IsDisposed || _storage.Identity is null || !_storage.Identity.Equals(identity)) { - item.Item1.Dispose(); + output = null; + return false; } - _cache.Clear(); - _cacheRejected = false; + output = new RenderNodeCachedOutput(_storage.Values); + return true; } - public void Dispose() + internal static IReadOnlyList PublishAtomically( + IReadOnlyList publications) { - if (!IsDisposed) + ArgumentNullException.ThrowIfNull(publications); + if (publications.Count == 0) + return []; + + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var prepared = new (RenderNodeCache Cache, CacheStorage Storage)[publications.Count]; + for (int index = 0; index < publications.Count; index++) { - foreach ((RenderTarget, Rect) item in _cache) + RenderNodeCachePublication publication = publications[index] + ?? throw new ArgumentException("A cache-publication batch cannot contain null entries.", nameof(publications)); + RenderNodeCache cache = publication.Cache; + ObjectDisposedException.ThrowIf(cache.IsDisposed, cache); + if (!seen.Add(cache)) { - item.Item1.Dispose(); + throw new InvalidOperationException( + "One atomic cache-publication batch cannot replace the same node cache twice."); } - _cache.Clear(); - IsDisposed = true; + RenderNodeCachedValue[] values = publication.Values.ToArray(); + foreach (RenderNodeCachedValue value in values) + { + ArgumentNullException.ThrowIfNull(value); + ObjectDisposedException.ThrowIf(value.Target.IsDisposed, value.Target); + if (!RenderRectValidation.IsFiniteNonNegative(value.Bounds) + || value.EffectiveScale.IsUnbounded + || value.DeviceBounds.Size != new PixelSize(value.Target.Width, value.Target.Height)) + { + throw new InvalidOperationException( + "A cache publication requires finite bounds, a concrete density, and matching device bounds."); + } + } - GC.SuppressFinalize(this); + prepared[index] = ( + cache, + new CacheStorage(publication.Identity, values, publication.Identity.Density)); } - } - public RenderTarget UseCache(out Rect bounds) - { - if (_cache.Count == 0) + var previous = new CacheStorage[prepared.Length]; + for (int index = 0; index < prepared.Length; index++) + previous[index] = prepared[index].Cache._storage; + + // Validation and allocation are complete. These reference assignments are the + // publication commit point and cannot invoke user cleanup code or partially fail. + foreach ((RenderNodeCache cache, CacheStorage storage) in prepared) + cache._storage = storage; + + List? failures = null; + for (int index = previous.Length - 1; index >= 0; index--) { - throw new Exception("キャッシュはありません"); + try + { + DisposeStorage(previous[index]); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } } - (RenderTarget, Rect) c = _cache[0]; - bounds = c.Item2; - return c.Item1.ShallowCopy(); + return failures ?? []; + } + + private CacheStorage DetachStorage() + { + CacheStorage result = _storage; + _storage = CacheStorage.Empty; + return result; } - public void StoreCache(RenderTarget renderTarget, Rect bounds, float density = 1f) + private static void DisposeStorage(CacheStorage storage) { - Invalidate(); + List? failures = null; + for (int index = storage.Values.Length - 1; index >= 0; index--) + { + try + { + storage.Values[index].Target.Dispose(); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } - _cache.Add((renderTarget.ShallowCopy(), bounds)); - Density = density; + if (failures is null) + return; + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + throw new AggregateException("One or more render-cache targets failed to dispose.", failures); } - public IEnumerable<(RenderTarget RenderTarget, Rect Bounds)> UseCache() + private sealed record CacheStorage( + RenderOutputCacheIdentity? Identity, + RenderNodeCachedValue[] Values, + float IdentityDensity) { - return _cache.Select(i => (i.Item1.ShallowCopy(), i.Item2)); + public static CacheStorage Empty { get; } = new(null, [], 1); } +} - public void StoreCache(ReadOnlySpan<(RenderTarget RenderTarget, Rect Bounds)> items, float density = 1f) +internal sealed record RenderNodeCachedValue +{ + public RenderNodeCachedValue( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale) + : this( + target, + bounds, + effectiveScale, + CreateDeviceBounds(target, bounds, effectiveScale)) { - Invalidate(); + } - foreach ((RenderTarget renderTarget, Rect bounds) in items) + public RenderNodeCachedValue( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset = default) + { + ArgumentNullException.ThrowIfNull(target); + if (!RenderRectValidation.IsFiniteNonNegative(bounds)) + throw new ArgumentException("Cached value bounds must be finite and non-negative.", nameof(bounds)); + if (effectiveScale.IsUnbounded) + throw new ArgumentException("A cached value requires a concrete density.", nameof(effectiveScale)); + if (deviceBounds.Width < 0 || deviceBounds.Height < 0) + throw new ArgumentException("Cached value device bounds cannot have negative dimensions.", nameof(deviceBounds)); + if (deviceBounds.Size != new PixelSize(target.Width, target.Height)) + { + throw new ArgumentException( + "Cached value device bounds must match the backing target size.", + nameof(deviceBounds)); + } + PixelRect semanticDeviceBounds = PixelRect.FromRect( + bounds.Translate(deviceGridOffset), + effectiveScale.Value); + if (deviceBounds.X > semanticDeviceBounds.X + || deviceBounds.Y > semanticDeviceBounds.Y + || deviceBounds.Right < semanticDeviceBounds.Right + || deviceBounds.Bottom < semanticDeviceBounds.Bottom) { - _cache.Add((renderTarget.ShallowCopy(), bounds)); + throw new ArgumentException( + "Cached value device bounds must contain its semantic bounds.", + nameof(deviceBounds)); } - Density = density; + Target = target; + Bounds = bounds; + CompleteBounds = bounds; + EffectiveScale = effectiveScale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + } + + public RenderTarget Target { get; } + + public Rect Bounds { get; } + + public Rect CompleteBounds { get; init; } + + public EffectiveScale EffectiveScale { get; } + + public PixelRect DeviceBounds { get; } + + public Vector DeviceGridOffset { get; } + + public Rect RasterBounds + => DeviceBounds + .ToRect(EffectiveScale.Value) + .Translate(-DeviceGridOffset); + + private static PixelRect CreateDeviceBounds( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale) + { + ArgumentNullException.ThrowIfNull(target); + PixelRect canonical = PixelRect.FromRect(bounds, effectiveScale.Value); + return new PixelRect(canonical.Position, new PixelSize(target.Width, target.Height)); + } +} + +internal sealed class RenderNodeCachedOutput +{ + public RenderNodeCachedOutput(IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + Values = values; } + + public IReadOnlyList Values { get; } } + +internal sealed record RenderNodeCachePublication( + RenderNodeCache Cache, + RenderOutputCacheIdentity Identity, + IReadOnlyList Values); diff --git a/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs b/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs index b9874a5df4..d65c5305ec 100644 --- a/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs +++ b/src/Beutl.Engine/Graphics/Rendering/Cache/RenderNodeCacheHelper.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; using Beutl.Configuration; using Beutl.Logging; using Beutl.Media; @@ -6,141 +7,296 @@ namespace Beutl.Graphics.Rendering.Cache; -public static class RenderNodeCacheHelper +internal static class RenderNodeCacheHelper { internal static readonly ILogger _logger = Log.CreateLogger("RenderNodeCache"); - public static bool CanCacheRecursive(RenderNode node) + internal static RenderNodeCacheLifecycle BeginLifecycle(RenderNode root) { - RenderNodeCache cache = node.Cache; - if (!cache.CanCache()) - return false; + ArgumentNullException.ThrowIfNull(root); + return RenderNodeCacheLifecycle.Create(root); + } + + /// Resets the cache of and of every node it owns. + /// + /// Ownership, not : a merely referenced node is shared with other + /// live entries, so tearing down one holder must not drop caches the others still rely on. + /// + internal static void ClearOwnedCaches(RenderNode node) + { + node.Cache.Reset(); - if (node is ContainerRenderNode container) + if (node is not ContainerRenderNode containerNode) return; + + foreach (RenderNode item in containerNode.Children) { - for (int i = 0; i < container.Children.Count; i++) + ClearOwnedCaches(item); + } + } +} + +internal sealed class RenderNodeCacheLifecycle +{ + private readonly NodeSnapshot[] _nodes; + private bool _completed; + + private RenderNodeCacheLifecycle(NodeSnapshot[] nodes) + { + _nodes = nodes; + } + + internal static RenderNodeCacheLifecycle Create(RenderNode root) + { + var snapshots = new Dictionary(ReferenceEqualityComparer.Instance); + Collect(root, snapshots); + ResolveSignatures(snapshots.Values); + + var ancestors = new HashSet(); + foreach (NodeSnapshot snapshot in snapshots.Values) + { + // WasDirty alone cannot carry a shared child's change: HasChanges is one flag per node, and + // whichever root completes first clears it for the others. The signature is root-independent. + if (!snapshot.WasDirty && !HasStaleSignature(snapshot)) + continue; + + MarkNodeAndAncestors(snapshot, ancestors); + } + + foreach (NodeSnapshot snapshot in snapshots.Values) + { + if (snapshot.IsInvalidated && !snapshot.Node.IsDisposed) { - RenderNode current = container.Children[i]; - if (!CanCacheRecursive(current)) - { - return false; - } + snapshot.Node.Cache.Reset(); } } - return true; + return new RenderNodeCacheLifecycle([.. snapshots.Values]); } - // nodeの子要素だけ調べる。node自体は調べない - // MakeCacheで使う - public static bool CanCacheRecursiveChildrenOnly(RenderNode node) + internal void CompleteSuccessfully(bool advanceWarmup) { - if (node is ContainerRenderNode containerNode) + if (_completed) + throw new InvalidOperationException("A render-node cache lifecycle can complete only once."); + + var changedDuringRequest = new List(); + foreach (NodeSnapshot snapshot in _nodes) + { + if (snapshot.Node.HasChanges + && (!snapshot.WasDirty || snapshot.Node.ChangeVersion != snapshot.ObservedChangeVersion)) + { + changedDuringRequest.Add(snapshot); + } + } + + if (changedDuringRequest.Count != 0) { - foreach (RenderNode item in containerNode.Children) + var ancestors = new HashSet(); + foreach (NodeSnapshot snapshot in changedDuringRequest) + { + MarkNodeAndAncestors(snapshot, ancestors); + } + + foreach (NodeSnapshot snapshot in ancestors) { - if (!CanCacheRecursive(item)) + if (!snapshot.Node.IsDisposed) { - return false; + snapshot.Node.Cache.Reset(); } } } - return true; - } + foreach (NodeSnapshot snapshot in _nodes) + { + if (snapshot.WasDirty) + { + snapshot.Node.ClearChanges(snapshot.ObservedChangeVersion); + } + } - public static void ClearCache(RenderNode node) - { - node.Cache.Invalidate(); + RestampSignatures(); - if (node is not ContainerRenderNode containerNode) return; + if (advanceWarmup) + { + foreach (NodeSnapshot snapshot in _nodes) + { + if (!snapshot.IsInvalidated + && !snapshot.Node.IsDisposed + && !snapshot.Node.HasChanges + && snapshot.Node.ChangeVersion == snapshot.ObservedChangeVersion) + { + snapshot.Node.Cache.RecordSuccessfulStableRequest(); + } + } + } - foreach (RenderNode item in containerNode.Children) + _completed = true; + } + + private void RestampSignatures() + { + ResolveSignatures(_nodes, useCurrentChangeVersion: true); + foreach (NodeSnapshot snapshot in _nodes) { - ClearCache(item); + if (!snapshot.Node.IsDisposed) + snapshot.Node.Cache.DependencySignature = snapshot.Signature; } } - // 再帰呼び出し - public static void MakeCache(RenderNode node, RenderCacheOptions cacheOptions, - float outputScale = 1f, float maxWorkingScale = float.PositiveInfinity) + private static bool HasStaleSignature(NodeSnapshot snapshot) { - if (!cacheOptions.IsEnabled) - return; + RenderNodeCache cache = snapshot.Node.Cache; + return cache.IsCached + && cache.DependencySignature != 0 + && cache.DependencySignature != snapshot.Signature; + } - RenderNodeCache cache = node.Cache; - // ここでのnodeは途中まで、キャッシュしても良い - // CanCacheRecursive内で再帰呼び出ししているのはすべてキャッシュできる必要がある - if (CanCacheRecursive(node)) + private static void ResolveSignatures( + IEnumerable all, + bool useCurrentChangeVersion = false) + { + var resolved = new HashSet(); + var stack = new Stack<(NodeSnapshot Snapshot, bool ChildrenResolved)>(); + foreach (NodeSnapshot start in all) { - if (!cache.IsCached && !cache.IsCacheRejected) + stack.Push((start, false)); + while (stack.Count != 0) { - CreateDefaultCache(node, cacheOptions, outputScale, maxWorkingScale); + (NodeSnapshot snapshot, bool childrenResolved) = stack.Pop(); + if (childrenResolved) + { + long changeVersion = useCurrentChangeVersion + ? snapshot.Node.ChangeVersion + : snapshot.ObservedChangeVersion; + snapshot.Signature = ComputeSignature(snapshot, changeVersion); + continue; + } + + if (!resolved.Add(snapshot)) + continue; + + stack.Push((snapshot, true)); + foreach (NodeSnapshot child in snapshot.Children) + stack.Push((child, false)); } } - else if (node is ContainerRenderNode containerNode) + } + + // The child's runtime identity is mixed in alongside its signature: two freshly built children both sit at + // change version 0, so a container that swaps one for the other would otherwise reproduce the parent's + // previous signature exactly and keep serving the replaced child's cached pixels. + private static long ComputeSignature(NodeSnapshot snapshot, long changeVersion) + { + unchecked { - cache.Invalidate(); - foreach (RenderNode item in containerNode.Children) + const ulong Basis = 14695981039346656037UL; + ulong hash = Mix(Basis, (ulong)changeVersion); + foreach (NodeSnapshot child in snapshot.Children) { - MakeCache(item, cacheOptions, outputScale, maxWorkingScale); + hash = Mix(hash, (ulong)(uint)RuntimeHelpers.GetHashCode(child.Node)); + hash = Mix(hash, (ulong)child.Signature); } + + // 0 is the unstamped marker. + return hash == 0 ? 1 : (long)hash; } } - public static void CreateDefaultCache(RenderNode node, RenderCacheOptions cacheOptions, - float outputScale = 1f, float maxWorkingScale = float.PositiveInfinity) + private static ulong Mix(ulong hash, ulong value) { - // Rasterize the cache at the renderer's density under its working-scale ceiling. - var processor = new RenderNodeProcessor(node, false, outputScale, maxWorkingScale); - var ops = processor.PullToRoot(); + unchecked + { + const ulong Prime = 1099511628211UL; + for (int shift = 0; shift < 64; shift += 8) + { + hash ^= (value >> shift) & 0xFF; + hash *= Prime; + } - // Refuse to cache a subtree whose supply density exceeds outputScale: caching would - // discard the extra detail and silently lower downstream working scales. - if (ops.Any(o => !o.EffectiveScale.IsUnbounded && o.EffectiveScale.Value > outputScale)) + return hash; + } + } + + private static NodeSnapshot Collect( + RenderNode node, + IDictionary snapshots) + { + if (snapshots.TryGetValue(node, out NodeSnapshot? existing)) { - foreach (var op in ops) - op.Dispose(); - node.Cache.RejectCache(); - return; + if (existing.IsVisiting) + { + throw new InvalidOperationException( + "A render-node ChildNodes cycle was detected while preparing cache lifecycle state."); + } + + return existing; } - var list = processor.RasterizeToRenderTargets(ops); - long pixels = list.Sum(i => + var snapshot = new NodeSnapshot(node, node.HasChanges, node.ChangeVersion) { - var pr = outputScale == 1f ? PixelRect.FromRect(i.Bounds) : PixelRect.FromRect(i.Bounds, outputScale); - return (long)pr.Width * pr.Height; - }); - if (!cacheOptions.Rules.Match(pixels)) + IsVisiting = true, + }; + snapshots.Add(node, snapshot); + try { - // Release rasterized tiles on the reject path to avoid leaking RenderTarget surfaces. - foreach (var i in list) - i.RenderTarget.Dispose(); - node.Cache.RejectCache(); - return; + ReadOnlySpan children = node.ChildNodes; + for (int i = 0; i < children.Length; i++) + { + RenderNode child = children[i]; + ArgumentNullException.ThrowIfNull(child); + NodeSnapshot childSnapshot = Collect(child, snapshots); + snapshot.Children.Add(childSnapshot); + childSnapshot.Parents.Add(snapshot); + } + } + finally + { + snapshot.IsVisiting = false; } - // nodeの子要素のキャッシュをすべて削除 - ClearCache(node); - - var arr = list.Select(i => (i.RenderTarget, i.Bounds)).ToArray(); - node.Cache.StoreCache(arr, outputScale); + return snapshot; + } - _logger.LogInformation("Created cache for node {Node}.", node); + private static void MarkNodeAndAncestors(NodeSnapshot node, ISet visited) + { + if (!visited.Add(node)) + return; - // 参照のデクリメント - foreach ((RenderTarget target, Rect _) in arr) + node.IsInvalidated = true; + foreach (NodeSnapshot parent in node.Parents) { - target.Dispose(); + MarkNodeAndAncestors(parent, visited); } } + + private sealed class NodeSnapshot( + RenderNode node, + bool wasDirty, + long observedChangeVersion) + { + public RenderNode Node { get; } = node; + + public bool WasDirty { get; } = wasDirty; + + public long ObservedChangeVersion { get; } = observedChangeVersion; + + public List Children { get; } = []; + + public List Parents { get; } = []; + + public long Signature { get; set; } + + public bool IsVisiting { get; set; } + + public bool IsInvalidated { get; set; } + } } [JsonSerializable(typeof(RenderCacheOptions))] public record RenderCacheOptions(bool IsEnabled, RenderCacheRules Rules) { - public static readonly RenderCacheOptions Default = new(true, RenderCacheRules.Default); public static readonly RenderCacheOptions Disabled = new(false, RenderCacheRules.Default); + public static readonly RenderCacheOptions Enabled = new(true, RenderCacheRules.Default); + public static readonly RenderCacheOptions Default = Disabled; public static RenderCacheOptions CreateFromGlobalConfiguration() { diff --git a/src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs index 477da19bee..bf78dcffd1 100644 --- a/src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs @@ -4,6 +4,13 @@ namespace Beutl.Graphics.Rendering; public sealed class ClearRenderNode(Color color) : RenderNode { + private static readonly TargetCommandDefinition s_definition = + TargetCommandDefinition.Create( + static (session, state) => session.Canvas.Use(canvas => canvas.Clear(state)), + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None); + public Color Color { get; private set; } = color; public bool Update(Color color) @@ -17,8 +24,8 @@ public bool Update(Color color) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return [RenderNodeOperation.CreateLambda(Rect.Empty, canvas => canvas.Clear(Color))]; + context.Publish(context.TargetCommand([], s_definition.Call(Color))); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs index 0f8abd7e65..e8cf2afe3b 100644 --- a/src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs @@ -8,35 +8,44 @@ public class ContainerRenderNode : RenderNode public IReadOnlyList Children => _children; - public override void PrepareForProcess(ImmediateCanvas canvas) - { - foreach (RenderNode child in _children) - { - child.PrepareForProcess(canvas); - } - } + public override ReadOnlySpan ChildNodes => CollectionsMarshal.AsSpan(_children); public void AddChild(RenderNode item) { ArgumentNullException.ThrowIfNull(item); _children.Add(item); + HasChanges = true; } public void RemoveChild(RenderNode item) { ArgumentNullException.ThrowIfNull(item); - _children.Remove(item); + if (_children.Remove(item)) + HasChanges = true; } public void RemoveRange(int index, int count) { _children.RemoveRange(index, count); + if (count > 0) + HasChanges = true; } + /// Replaces the child at and disposes the one it replaced. + /// + /// Passing the child already at that index is a no-op rather than a self-replacement: disposing the + /// previous child after storing the new one would otherwise leave a disposed node in the container. + /// public void SetChild(int index, RenderNode item) { - _children[index]?.Dispose(); + ArgumentNullException.ThrowIfNull(item); + RenderNode? previous = _children[index]; + if (ReferenceEquals(previous, item)) + return; + _children[index] = item; + HasChanges = true; + previous?.Dispose(); } public void BringFrom(ContainerRenderNode containerNode) @@ -45,11 +54,13 @@ public void BringFrom(ContainerRenderNode containerNode) _children.AddRange(containerNode._children); containerNode._children.Clear(); + HasChanges = true; + containerNode.HasChanges = true; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return context.Input; + context.PassThrough(); } protected override void OnDispose(bool disposing) diff --git a/src/Beutl.Engine/Graphics/Rendering/DeviceGridAlignment.cs b/src/Beutl.Engine/Graphics/Rendering/DeviceGridAlignment.cs new file mode 100644 index 0000000000..3fb1f7afcc --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/DeviceGridAlignment.cs @@ -0,0 +1,101 @@ +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +internal static class DeviceGridAlignment +{ + public static Vector ResolveLogicalOffset(ImmediateCanvas canvas) + { + Matrix transform = canvas.Transform; + float density = canvas.Density; + // Only translation commutes with every translation-invariant filter. Rotation, scale, + // skew, and perspective retain the established drawable-local rasterization path. + if (!HasTranslationOnlyLinearPart(transform, density)) + return default; + + return new Vector( + (transform.M31 + canvas.DeviceOrigin.X) / density, + (transform.M32 + canvas.DeviceOrigin.Y) / density); + } + + /// + /// Maps 's own surface pixels back into its current logical space. + /// Unlike , which selects a grid phase and therefore only + /// recognizes a translation, this stays exact under any invertible transform. + /// + /// + /// when the canvas transform is singular. The whole surface then collapses + /// onto a point, so no logical region has a preimage of non-zero area and there is nothing to sample. + /// + public static bool TryResolveSurfaceToLogical(ImmediateCanvas canvas, out Matrix surfaceToLogical) + => canvas.Transform.TryInvert(out surfaceToLogical); + + /// + /// Surface pixels per unit of 's current logical space. + /// counts pixels per unit of the canvas's own base space, so a + /// scaling transform pushed on top of it leaves the two apart: reading the surface back at + /// under a 2x transform would allocate half the target's supply. + /// + public static float ResolveLocalDensity(ImmediateCanvas canvas) + => ResolveAffineDensity(canvas.Transform, canvas.Density); + + /// Whether has a perspective part, making its density positional. + public static bool IsPerspective(Matrix transform) + => transform.M13 != 0 || transform.M23 != 0 || transform.M33 != 1; + + public static float ResolveAffineDensity(Matrix transform, float fallbackDensity) + { + if (IsPerspective(transform)) + { + throw new NotSupportedException( + "PreserveTargetSupply cannot represent the position-dependent density of a perspective transform."); + } + + // The operator norm is the only scalar affine supply that stays lossless under shear as well as + // anisotropic scale. A maximum basis-vector length can still underestimate an oblique direction. + double a = transform.M11; + double b = transform.M12; + double c = transform.M21; + double d = transform.M22; + double squaredFrobenius = (a * a) + (b * b) + (c * c) + (d * d); + double determinant = (a * d) - (b * c); + double discriminant = Math.Max( + 0d, + (squaredFrobenius * squaredFrobenius) - (4d * determinant * determinant)); + double largestEigenvalue = (squaredFrobenius + Math.Sqrt(discriminant)) / 2d; + float density = (float)Math.Sqrt(largestEigenvalue); + return float.IsFinite(density) && density > 0f ? density : fallbackDensity; + } + + public static Vector NormalizePhase(Vector logicalOffset, float density) + { + static float Normalize(float offset, float activeDensity) + { + float deviceOffset = offset * activeDensity; + return (deviceOffset - MathF.Floor(deviceOffset)) / activeDensity; + } + + return new Vector( + Normalize(logicalOffset.X, density), + Normalize(logicalOffset.Y, density)); + } + + public static Vector ResolveRasterTranslation( + PixelRect deviceBounds, + Vector logicalOffset, + float density) + { + return new Vector( + ((logicalOffset.X * density) - deviceBounds.X) / density, + ((logicalOffset.Y * density) - deviceBounds.Y) / density); + } + + private static bool HasTranslationOnlyLinearPart(Matrix transform, float linearScale) + => transform.M11 == linearScale + && transform.M12 == 0 + && transform.M13 == 0 + && transform.M21 == 0 + && transform.M22 == linearScale + && transform.M23 == 0 + && transform.M33 == 1; +} diff --git a/src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs index f3aac5b05e..9ed2a66486 100644 --- a/src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs @@ -2,6 +2,8 @@ public class DrawBackdropRenderNode(IBackdrop backdrop, Rect bounds) : RenderNode() { + private static readonly RenderResourceSlot s_backdropSlot = new(); + public IBackdrop Backdrop { get; private set; } = backdrop; public Rect Bounds { get; private set; } = bounds; @@ -19,12 +21,44 @@ public bool Update(IBackdrop backdrop, Rect bounds) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - context.IsRenderCacheEnabled = false; - return - [ - RenderNodeOperation.CreateLambda(Bounds, canvas => Backdrop.Draw(canvas), Bounds.Contains) - ]; + context.DisableRenderCache(); + + IBackdrop backdrop = Backdrop; + Rect bounds = Bounds; + // A zero-area canvas replays no pixels, so the backdrop covers no point a query can reach. + RenderHitTestContract hitTest = bounds.Width > 0 && bounds.Height > 0 + ? RenderHitTestContract.OutputBounds + : RenderHitTestContract.None; + if (context.TryBuiltInBackdrop(backdrop, out RenderFragmentHandle? capture)) + { + TargetCommandDefinition captureCommand = + TargetCommandDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => session.Inputs[0].Draw(canvas)), + TargetRegion.Region(bounds), + bounds, + hitTest); + context.Publish(context.TargetCommand([capture!], captureCommand.Call(default))); + return; + } + + RenderResource resource = context.Borrow(backdrop); + RawTargetCommandDefinition rawCommand = + RawTargetCommandDefinition.Create( + static (session, state) => session.UseResource( + state.Resource, + value => value.Draw(session.Canvas)), + bounds, + hitTest, + resources: [s_backdropSlot]); + context.Publish(context.RawTargetCommand( + rawCommand.Call( + new RawBackdropCommandState(resource), + [s_backdropSlot.Bind(resource)]))); } + + private readonly record struct BackdropCaptureState; + + private readonly record struct RawBackdropCommandState(RenderResource Resource); } diff --git a/src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs b/src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs index a1ffcc6a61..81116e36bd 100644 --- a/src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs +++ b/src/Beutl.Engine/Graphics/Rendering/EffectiveScale.cs @@ -1,10 +1,13 @@ namespace Beutl.Graphics.Rendering; /// -/// The supply density an operation's backing pixels actually exist at. Flows bottom-up from each -/// so the compositor can reconcile mixed scales. -/// default is (vector / re-rasterizable). +/// Describes the device-pixel density supplied by a recorded fragment value. /// +/// +/// The value flows through recorded fragments and values so planning can reconcile mixed densities +/// without executing them. default is , which denotes a value that can +/// be rasterized at a later selected density. +/// public readonly record struct EffectiveScale { // Inverted flag: struct default (false) must mean Unbounded, not At(0). @@ -18,13 +21,21 @@ private EffectiveScale(float value, bool bounded) _bounded = bounded; } - /// Vector / lossless sentinel: re-rasterizable at any scale. Equal to default. + /// Gets the re-rasterizable sentinel, which is equal to default. public static EffectiveScale Unbounded => default; /// - /// A concrete bitmap density (device px per logical unit). Must be positive-finite; throws otherwise. - /// Use when the density is derived from animatable geometry that can go degenerate. + /// Creates a concrete bitmap density in device pixels per logical unit. /// + /// A positive finite density. + /// A concrete effective scale. + /// + /// is non-finite, zero, or negative. + /// + /// + /// Use when a density derived from animatable geometry may be + /// non-finite or non-positive. + /// public static EffectiveScale At(float scale) => float.IsFinite(scale) && scale > 0f ? new(scale, bounded: true) @@ -32,15 +43,22 @@ public static EffectiveScale At(float scale) nameof(scale), scale, "EffectiveScale.At requires a positive finite density."); /// - /// Non-throwing companion to : returns for - /// non-finite or non-positive . + /// Creates a concrete density when possible, or returns for an invalid density. /// + /// The candidate density in device pixels per logical unit. + /// + /// A concrete effective scale when is positive and finite; + /// otherwise . + /// public static EffectiveScale AtOrUnbounded(float scale) => float.IsFinite(scale) && scale > 0f ? new(scale, bounded: true) : Unbounded; - /// True for the (vector) sentinel. + /// Gets whether this value is the sentinel. public bool IsUnbounded => !_bounded; - /// The concrete density, or 1f when . + /// + /// Gets the concrete density, or 1f for when a numeric fallback is required. + /// + /// Check before treating this value as a declared concrete supply. public float Value => _bounded ? _value : 1f; } diff --git a/src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs index e62fcc1f29..fc680ee43d 100644 --- a/src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs @@ -1,4 +1,5 @@ -using Beutl.Media; +using Beutl.Engine; +using Beutl.Media; namespace Beutl.Graphics.Rendering; @@ -21,67 +22,94 @@ public bool Update(Rect rect, Brush.Resource? fill, Pen.Resource? pen) changed = true; } + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return - [ - RenderNodeOperation.CreateLambda( - PenHelper.GetBounds(Rect, Pen?.Resource), - canvas => canvas.DrawEllipse(Rect, Fill?.Resource, Pen?.Resource), - HitTest - ) - ]; + Rect rect = Rect; + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + Rect bounds = PenHelper.GetBounds(rect, pen); + if (bounds.Width == 0 || bounds.Height == 0) + return; + + var hitTestState = new EllipseHitTestState( + rect, + fill is not null, + pen?.StrokeAlignment ?? StrokeAlignment.Center, + pen?.Thickness ?? 0); + + var state = (rect, hitTestState); + context.Publish(context.PaintedSource( + state, + draw: static (canvas, fill, pen, state) => + canvas.DrawEllipse(state.rect, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.Custom((_, point) => hitTestState.HitTest(point)), + scale: RenderScaleContract.Vector)); } //https://github.com/AvaloniaUI/Avalonia/blob/release/0.10.21/src/Avalonia.Visuals/Rendering/SceneGraph/EllipseNode.cs - private bool HitTest(Point point) + private readonly record struct EllipseHitTestState( + Rect Rect, + bool HasFill, + StrokeAlignment StrokeAlignment, + float Thickness) { - Point center = Rect.Center; + public bool HitTest(Point point) + { + Point center = Rect.Center; - float thickness = Pen?.Resource.Thickness ?? 0; - StrokeAlignment alignment = Pen?.Resource.StrokeAlignment ?? StrokeAlignment.Center; - float realThickness = PenHelper.GetRealThickness(alignment, thickness); + float realThickness = PenHelper.GetRealThickness(StrokeAlignment, Thickness); - float rx = Rect.Width / 2 + realThickness; - float ry = Rect.Height / 2 + realThickness; + float rx = Rect.Width / 2 + realThickness; + float ry = Rect.Height / 2 + realThickness; - float dx = point.X - center.X; - float dy = point.Y - center.Y; + float dx = point.X - center.X; + float dy = point.Y - center.Y; - if (Math.Abs(dx) > rx || Math.Abs(dy) > ry) - { - return false; - } + if (Math.Abs(dx) > rx || Math.Abs(dy) > ry) + { + return false; + } - if (Fill != null) - { - return Contains(rx, ry); - } - else if (thickness > 0) - { - bool inStroke = Contains(rx, ry); + if (HasFill) + { + return Contains(rx, ry); + } + else if (Thickness > 0) + { + bool inStroke = Contains(rx, ry); - rx = Rect.Width / 2 - realThickness; - ry = Rect.Height / 2 - realThickness; + rx = Rect.Width / 2 - realThickness; + ry = Rect.Height / 2 - realThickness; - bool inInner = Contains(rx, ry); + bool inInner = Contains(rx, ry); - return inStroke && !inInner; - } + return inStroke && !inInner; + } - bool Contains(double radiusX, double radiusY) - { - double rx2 = radiusX * radiusX; - double ry2 = radiusY * radiusY; + bool Contains(double radiusX, double radiusY) + { + double rx2 = radiusX * radiusX; + double ry2 = radiusY * radiusY; - double distance = ry2 * dx * dx + rx2 * dy * dy; + double distance = ry2 * dx * dx + rx2 * dy * dy; - return distance <= rx2 * ry2; - } + return distance <= rx2 * ry2; + } - return false; + return false; + } } } diff --git a/src/Beutl.Engine/Graphics/Rendering/EngineResourceIdentity.cs b/src/Beutl.Engine/Graphics/Rendering/EngineResourceIdentity.cs new file mode 100644 index 0000000000..2589174e8a --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/EngineResourceIdentity.cs @@ -0,0 +1,52 @@ +using System.Runtime.CompilerServices; + +using Beutl.Engine; + +namespace Beutl.Graphics.Rendering; + +/// +/// Derives an equality-stable identity of an for engine-only metadata use. +/// +/// +/// +/// This is the only safe way to key on an engine resource. is +/// null for a resource that never went through ; comparing those missing +/// backing ids directly would make any two detached resources compare equal. +/// +/// +/// The derivation is renderer-wide rather than a recorder or effect responsibility: nodes, brushes, filter +/// effects, and 3D all consult the same resources when evaluating engine-only metadata. Public render-node +/// authoring uses declared values instead. +/// +/// +internal static class EngineResourceIdentity +{ + private static readonly ConditionalWeakTable s_detached = new(); + + /// Gets the equality-stable identity of . + /// The non-null resource to identify. + /// + /// The backing , or a synthesized for a resource that has no + /// backing object. + /// + /// + /// A synthesized identity is stable per instance and held weakly, so a + /// caller that reallocates the resource every frame gets a new identity every frame. Returning + /// rather than avoids boxing in engine metadata paths. + /// + /// is . + public static Guid Of(EngineObject.Resource resource) + { + ArgumentNullException.ThrowIfNull(resource); + EngineObject? original = resource.GetOriginal(); + if (original is not null) + return original.Id; + + return s_detached.GetValue(resource, static _ => new DetachedIdentityHolder(Guid.NewGuid())).Value; + } + + private sealed class DetachedIdentityHolder(Guid value) + { + public Guid Value { get; } = value; + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs index 412ed5f379..d416efbf96 100644 --- a/src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs @@ -1,6 +1,5 @@ using Beutl.Engine; using Beutl.Graphics.Effects; -using SkiaSharp; namespace Beutl.Graphics.Rendering; @@ -20,77 +19,272 @@ public bool Update(FilterEffect.Resource? fe) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + /// + /// Gets an optional declarative scale contract for this effect's working inputs. + /// + /// + /// A scale contract applied after the base node has isolated target-dependent inputs, or + /// to use the standard supply-driven working scale. + /// + /// + /// Override this hook for working-scale customization instead of replacing . The returned + /// contract is folded into the first authored shader, geometry, or legacy operation. Its callback receives one + /// surviving branch at a time, with one item and that branch's + /// isolated effect-input bounds as . Legacy multi-input operations + /// aggregate the densest concrete branch result and fall back to + /// only when every branch remains unbounded. Allocation clamping is independent of callback cardinality: it + /// covers each branch's local-origin footprint and every intermediate legacy materialization. The forced Flush + /// immediately before a custom callback removes renderer-owned aprons and presents each branch through the + /// historical dimension-sized local backing. Because that callback may then combine, split, move, or shrink + /// targets without declaring topology, its results collapse to their union and later footprints conservatively + /// use that aggregate domain while retaining physical backing produced by the callback. + /// The callback may be evaluated again after symbolic + /// input metadata is resolved, so it must be deterministic and side-effect-free. An effect that authors no + /// operations creates no isolation or contract fragment and remains a true pass-through. The hook and resolver + /// stay lazy and are not evaluated for such an effect unless its ApplyTo implementation explicitly probes + /// or . + /// + protected virtual RenderScaleContract? GetWorkingScaleContract() => null; + + public override void Process(RenderNodeContext context) { - if (FilterEffect == null || !FilterEffect.Value.Resource.IsEnabled) + if (FilterEffect is not { } effectSnapshot || !effectSnapshot.Resource.IsEnabled) { - return context.Input; + context.PassThrough(); + return; } - // Resolve working scale from the densest concrete input, capped by the global ceiling. - Span inputScales = context.Input.Length <= 16 - ? stackalloc EffectiveScale[context.Input.Length] - : new EffectiveScale[context.Input.Length]; - for (int i = 0; i < context.Input.Length; i++) + if (context.Inputs.Count == 0) + return; + + bool hasConcreteInputMetadata = context.TryCalculateInputBounds(out Rect inputBounds); + Rect recordedInputBounds = hasConcreteInputMetadata + ? inputBounds + : context.CalculateRecordedInputBoundsHint(); + IReadOnlyList effectInputs = context.Inputs; + bool requiresInputIsolation = effectInputs.Any(static input => !input.CanBeUsedAsValueInput); + bool hasFiniteIsolationDomain = false; + Rect isolationDomain = default; + RenderFragmentMetadata[] authorInputMetadata; + if (requiresInputIsolation) { - inputScales[i] = context.Input[i].EffectiveScale; - } + if (context.TryCalculateFiniteIsolationDomain(out isolationDomain)) + { + if (isolationDomain.Width == 0 || isolationDomain.Height == 0) + { + context.PassThrough(); + return; + } - float workingScale = RenderNodeContext.ResolveWorkingScale( - inputScales, context.OutputScale, context.MaxWorkingScale); + hasFiniteIsolationDomain = true; + inputBounds = isolationDomain; + hasConcreteInputMetadata = true; + recordedInputBounds = isolationDomain; + } + else + { + inputBounds = default; + hasConcreteInputMetadata = false; + } - // Clamp w to keep ceil(bounds * w) within GPU/memory limits. - Rect bounds = context.CalculateBounds(); - workingScale = RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, workingScale); + authorInputMetadata = + [ + new RenderFragmentMetadata(recordedInputBounds, EffectiveScale.Unbounded), + ]; + } + else + { + authorInputMetadata = effectInputs + .Select(context.GetRecordedMetadataHint) + .ToArray(); + } + float outputScale = context.OutputScale; + float maxWorkingScale = context.MaxWorkingScale; - using var feContext = new FilterEffectContext(bounds, context.OutputScale, workingScale); - FilterEffect.Value.Resource.GetOriginal().ApplyTo(feContext, FilterEffect.Value.Resource); - var effectTargets = new EffectTargets(); - effectTargets.AddRange(context.Input.Select(i => new EffectTarget(i))); + FilterEffectWorkingScalePolicy? workingScalePolicy = null; + FilterEffectWorkingScalePolicy GetOrCreateWorkingScalePolicy() + => workingScalePolicy ??= new FilterEffectWorkingScalePolicy( + GetWorkingScaleContract() ?? RenderScaleContract.MaterializeAtWorkingScale); - using (var builder = new SKImageFilterBuilder()) - using (var activator = new FilterEffectActivator( - effectTargets, builder, context.OutputScale, workingScale, context.MaxWorkingScale)) + FilterEffectContext recordingContext = new( + hasConcreteInputMetadata ? inputBounds : Rect.Invalid, + context.OutputScale, + () => ResolveWorkingScale( + authorInputMetadata, + authorInputMetadata.Select(static item => item.Bounds).ToArray(), + outputScale, + maxWorkingScale, + GetOrCreateWorkingScalePolicy()), + context, + hasResolvedWorkingScale: hasConcreteInputMetadata && authorInputMetadata.Length == 1); + try { - activator.Apply(feContext); + FilterEffect.Resource effectResource = effectSnapshot.Resource; + recordingContext.ApplyTransactional(effectResource.GetOriginal()!, effectResource); + IReadOnlyList items = recordingContext.GetOrderedItems(); + if (items.Count == 0) + { + context.PassThrough(); + return; + } + + FilterEffectWorkingScalePolicy resolvedWorkingScalePolicy = GetOrCreateWorkingScalePolicy(); + if (requiresInputIsolation) + { + effectInputs = hasFiniteIsolationDomain + ? [context.Layer(effectInputs, isolationDomain)] + : [context.OwningTargetLayer(effectInputs)]; + } + + IReadOnlyList current = effectInputs; + FilterEffectWorkingScalePolicy? pendingWorkingScalePolicy = resolvedWorkingScalePolicy; + var legacyItems = new List(); + Rect legacyBounds = default; + bool legacyBoundsInitialized = false; + bool opaqueTail = false; - if (builder.HasFilter()) + void AppendLegacyItem(IFEItem item, int itemIndex) { - var imageFilter = builder.GetFilter(); - return activator.CurrentTargets.Select(t => + if (!legacyBoundsInitialized) { - var paint = new SKPaint(); - paint.ImageFilter = imageFilter; - return RenderNodeOperation.CreateLambda( - bounds: t.Bounds, - render: canvas => - { - using (canvas.PushBlendMode(BlendMode.SrcOver)) - using (canvas.PushTransform(Matrix.CreateTranslation( - t.Bounds.X - t.OriginalBounds.X, - t.Bounds.Y - t.OriginalBounds.Y))) - using (canvas.PushPaint(paint)) - { - t.Draw(canvas); - } - }, - hitTest: t.Bounds.Contains, - onDispose: () => - { - t.Dispose(); - paint.Dispose(); - }, - effectiveScale: t.Scale - ); - }).ToArray(); + legacyBounds = CalculateRecordedBoundsHint(context, current); + legacyBoundsInitialized = true; + } + + legacyItems.Add(item); + // A deferred-bound item resolves at execution time; authoring it against the + // provisional hint would freeze the wrong matrix, so the segment stays symbolic. + if (!legacyBounds.IsInvalid && item is not IFEItem_Skia { ResolveBoundsAtExecutionTime: true }) + legacyBounds = item.TransformBounds(legacyBounds); + opaqueTail |= legacyBounds.IsInvalid; } - else + + void FlushLegacyItems() { - return activator.CurrentTargets.Select(i => - i.NodeOperation ?? - RenderNodeOperation.CreateFromRenderTarget(i.Bounds, i.Bounds.Position, i.RenderTarget!, i.Scale)) + if (legacyItems.Count == 0 || current.Count == 0) + return; + + Rect segmentInputBounds = CalculateRecordedBoundsHint(context, current); + RenderFragmentMetadata[] segmentInputMetadata = current + .Select(context.GetRecordedMetadataHint) .ToArray(); + Rect[] segmentBufferBounds = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + segmentInputMetadata.Select(static item => item.Bounds).ToArray(), + legacyItems, + legacyBounds.IsInvalid ? segmentInputBounds : legacyBounds); + FilterEffectContext? segment = FilterEffectContext.CreateLegacySegment( + segmentInputBounds, + context.OutputScale, + ResolveWorkingScale( + segmentInputMetadata, + segmentBufferBounds, + outputScale, + maxWorkingScale, + pendingWorkingScalePolicy), + legacyItems); + try + { + Rect segmentOutputBounds = segment.Bounds; + bool requiresOwningTargetDomain = segmentOutputBounds.IsInvalid; + if (requiresOwningTargetDomain) + segmentOutputBounds = segmentInputBounds; + RenderResource segmentResource = context.Own(segment); + segment = null; + current = + [ + context.FilterEffectSegment( + current, + segmentResource, + segmentOutputBounds, + requiresOwningTargetDomain, + legacyItems, + pendingWorkingScalePolicy), + ]; + pendingWorkingScalePolicy = null; + } + finally + { + segment?.Dispose(); + legacyItems.Clear(); + legacyBounds = default; + legacyBoundsInitialized = false; + opaqueTail = false; + } + } + + for (int itemIndex = 0; itemIndex < items.Count; itemIndex++) + { + IFEItem item = items[itemIndex]; + switch (item) + { + case FEItem_Shader shader when !opaqueTail: + FlushLegacyItems(); + current = current + .Select(input => context.Shader( + input, + shader.Description, + pendingWorkingScalePolicy)) + .ToArray(); + pendingWorkingScalePolicy = null; + break; + case FEItem_Geometry geometry when !opaqueTail: + FlushLegacyItems(); + current = current + .Select(input => context.Geometry( + input, + geometry.Description, + pendingWorkingScalePolicy)) + .ToArray(); + pendingWorkingScalePolicy = null; + break; + default: + AppendLegacyItem(item, itemIndex); + break; + } } + + FlushLegacyItems(); + context.PublishRange(current); + recordingContext.TransferResources(); + } + finally + { + recordingContext.Dispose(); } } + + private static Rect CalculateRecordedBoundsHint( + RenderNodeContext context, + IReadOnlyList inputs) + { + Rect result = default; + foreach (RenderFragmentHandle input in inputs) + result = result.Union(context.GetRecordedMetadataHint(input).Bounds); + return result; + } + + private static float ResolveWorkingScale( + IReadOnlyList metadata, + IReadOnlyList bufferBounds, + float outputScale, + float maxWorkingScale, + FilterEffectWorkingScalePolicy? workingScalePolicy = null) + { + if (workingScalePolicy is { } policy) + { + return policy.Resolve( + metadata.Select(static item => item.EffectiveScale).ToArray(), + metadata.Select(static item => item.Bounds).ToArray(), + bufferBounds, + outputScale, + maxWorkingScale).Value; + } + + return FilterEffectWorkingScalePolicy.ResolveMaterialized( + metadata.Select(static item => item.EffectiveScale).ToArray(), + bufferBounds, + outputScale, + maxWorkingScale).Value; + } + } diff --git a/src/Beutl.Engine/Graphics/Rendering/FilterEffectWorkingScalePolicy.cs b/src/Beutl.Engine/Graphics/Rendering/FilterEffectWorkingScalePolicy.cs new file mode 100644 index 0000000000..4848eda13f --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/FilterEffectWorkingScalePolicy.cs @@ -0,0 +1,396 @@ +using Beutl.Graphics.Effects; +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +internal readonly record struct FilterEffectWorkingScalePolicy +{ + public FilterEffectWorkingScalePolicy(RenderScaleContract scale) + { + scale.ThrowIfUninitialized(nameof(scale)); + Scale = scale; + } + + public RenderScaleContract Scale { get; } + + public object StructuralIdentity => Scale.StructuralIdentity; + + public EffectiveScale Resolve( + IReadOnlyList inputs, + Rect outputBounds, + float outputScale, + float maxWorkingScale) + { + ArgumentNullException.ThrowIfNull(inputs); + return Resolve( + inputs.Select(static input => input.EffectiveScale).ToArray(), + inputs.Select(static input => input.Bounds).ToArray(), + outputBounds, + outputScale, + maxWorkingScale); + } + + public EffectiveScale Resolve( + IReadOnlyList inputSupplies, + IReadOnlyList inputBounds, + Rect outputBounds, + float outputScale, + float maxWorkingScale) + => Resolve( + inputSupplies, + inputBounds, + Enumerable.Repeat(outputBounds, inputSupplies.Count).ToArray(), + outputScale, + maxWorkingScale); + + public EffectiveScale Resolve( + IReadOnlyList inputSupplies, + IReadOnlyList inputBounds, + IReadOnlyList bufferBounds, + float outputScale, + float maxWorkingScale) + { + ArgumentNullException.ThrowIfNull(inputSupplies); + ArgumentNullException.ThrowIfNull(inputBounds); + ArgumentNullException.ThrowIfNull(bufferBounds); + if (inputSupplies.Count == 0) + throw new InvalidOperationException("A filter-effect working-scale policy requires at least one input."); + if (inputSupplies.Count != inputBounds.Count) + throw new ArgumentException("Filter-effect input supplies and bounds must have matching cardinality."); + if (bufferBounds.Count == 0) + throw new ArgumentException("A filter-effect operation requires at least one buffer footprint."); + + EffectiveScale[] mappedSupplies = new EffectiveScale[inputSupplies.Count]; + for (int index = 0; index < inputSupplies.Count; index++) + { + mappedSupplies[index] = Scale.Resolve( + [inputSupplies[index]], + inputBounds[index], + outputScale, + maxWorkingScale); + } + + float workingScale = 0; + bool hasConcreteScale = false; + foreach (EffectiveScale mappedSupply in mappedSupplies) + { + if (mappedSupply.IsUnbounded) + continue; + + workingScale = hasConcreteScale + ? MathF.Max(workingScale, mappedSupply.Value) + : mappedSupply.Value; + hasConcreteScale = true; + } + + if (!hasConcreteScale) + { + workingScale = MathF.Min( + outputScale, + RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale)); + } + else + { + workingScale = MathF.Min( + workingScale, + RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale)); + } + + return EffectiveScale.At(ClampToBufferBudgets(bufferBounds, workingScale)); + } + + internal static EffectiveScale ResolveMaterialized( + IReadOnlyList inputSupplies, + IReadOnlyList bufferBounds, + float outputScale, + float maxWorkingScale) + { + ArgumentNullException.ThrowIfNull(inputSupplies); + ArgumentNullException.ThrowIfNull(bufferBounds); + if (inputSupplies.Count == 0) + throw new InvalidOperationException("A materialized filter-effect operation requires at least one input."); + if (bufferBounds.Count == 0) + throw new ArgumentException("A materialized filter-effect operation requires at least one buffer footprint."); + + float workingScale = RenderScaleUtilities.ResolveWorkingScale( + inputSupplies.ToArray(), + outputScale, + maxWorkingScale); + return EffectiveScale.At(ClampToBufferBudgets(bufferBounds, workingScale)); + } + + internal static Rect[] CalculateLegacyBufferBounds( + IReadOnlyList inputBounds, + IReadOnlyList boundsItems, + Rect fallbackBounds) + { + ArgumentNullException.ThrowIfNull(inputBounds); + ArgumentNullException.ThrowIfNull(boundsItems); + var result = new List(); + int firstCustomIndex = -1; + for (int index = 0; index < boundsItems.Count; index++) + { + if (boundsItems[index] is IFEItem_Custom) + { + firstCustomIndex = index; + break; + } + } + + int branchItemCount = firstCustomIndex >= 0 ? firstCustomIndex : boundsItems.Count; + Rect preCustomAggregateBounds = default; + var preCustomBranchStates = new List(inputBounds.Count); + foreach (Rect input in inputBounds) + { + LegacyFootprintState branchState = CollectLegacyFootprints( + input, + boundsItems, + startIndex: 0, + itemCount: branchItemCount, + fallbackBounds, + result); + preCustomAggregateBounds = preCustomAggregateBounds.Union(branchState.SemanticBounds); + preCustomBranchStates.Add(branchState); + } + + if (firstCustomIndex >= 0) + { + var preCustomRetainedBackingOffsets = new List(); + foreach (LegacyFootprintState branchState in preCustomBranchStates) + { + foreach (Rect offset in branchState.RetainedBackingOffsets) + { + Rect physicalBounds = offset.Translate(branchState.SemanticBounds.Position); + preCustomRetainedBackingOffsets.Add(physicalBounds.Translate(new Point( + -preCustomAggregateBounds.X, + -preCustomAggregateBounds.Y))); + } + } + + // A legacy Custom callback can combine or split the complete target list. Collapse from the union of + // the actual per-target semantic results, not TransformBounds(inputUnion): arbitrary pure mappings need + // not distribute over Union. + CollectLegacyFootprints( + preCustomAggregateBounds, + boundsItems, + firstCustomIndex, + boundsItems.Count - firstCustomIndex, + fallbackBounds, + result, + preCustomRetainedBackingOffsets, + skipInitialCustomPreFlush: true); + } + + if (result.Count == 0) + result.Add(ToLocalLegacyFootprint(fallbackBounds, fallbackBounds)); + return result.ToArray(); + } + + private static LegacyFootprintState CollectLegacyFootprints( + Rect initialSemanticBounds, + IReadOnlyList boundsItems, + int startIndex, + int itemCount, + Rect fallbackBounds, + List result, + IReadOnlyList? initialRetainedBackingOffsets = null, + bool skipInitialCustomPreFlush = false) + { + Rect semanticBounds = initialSemanticBounds; + Rect allocationBounds = ToLocalLegacyFootprint(semanticBounds, fallbackBounds); + Rect[] retainedBackingOffsets = initialRetainedBackingOffsets?.ToArray() + ?? [CreateInitialRetainedBackingOffset(semanticBounds, fallbackBounds)]; + bool hasPendingSkiaWork = false; + int endIndex = checked(startIndex + itemCount); + for (int index = startIndex; index < endIndex; index++) + { + IFEItem item = boundsItems[index]; + switch (item) + { + case IFEItem_Skia: + Rect previousSemanticBounds = semanticBounds; + if (item is IFEItem_Skia { ResolveBoundsAtExecutionTime: true }) + { + // A deferred-bound item resolves at execution time; its authoring-time + // footprint is unknown, so the segment stays symbolic and conservative. + semanticBounds = Rect.Invalid; + allocationBounds = Rect.Invalid; + } + else + { + if (!semanticBounds.IsInvalid) + semanticBounds = item.TransformBounds(semanticBounds); + if (!allocationBounds.IsInvalid) + allocationBounds = item.TransformBounds(allocationBounds); + } + retainedBackingOffsets = TransformRetainedBackingOffsets( + retainedBackingOffsets, + previousSemanticBounds, + semanticBounds, + item, + fallbackBounds); + hasPendingSkiaWork = true; + break; + case IFEItem_Custom: + if (!(skipInitialCustomPreFlush && index == startIndex)) + { + result.Add(NormalizeLegacyAllocationBounds(allocationBounds, fallbackBounds)); + AddRetainedBackingFootprints( + result, + semanticBounds, + retainedBackingOffsets, + fallbackBounds); + } + if (!semanticBounds.IsInvalid) + semanticBounds = item.TransformBounds(semanticBounds); + allocationBounds = ToLocalLegacyFootprint(semanticBounds, fallbackBounds); + result.Add(allocationBounds); + AddRetainedBackingFootprints( + result, + semanticBounds, + retainedBackingOffsets, + fallbackBounds); + hasPendingSkiaWork = false; + break; + case FEItem_Shader: + case FEItem_Geometry: + if (hasPendingSkiaWork) + { + result.Add(NormalizeLegacyAllocationBounds(allocationBounds, fallbackBounds)); + AddRetainedBackingFootprints( + result, + semanticBounds, + retainedBackingOffsets, + fallbackBounds); + } + if (!semanticBounds.IsInvalid) + semanticBounds = item.TransformBounds(semanticBounds); + allocationBounds = NormalizeLegacySemanticBounds(semanticBounds, fallbackBounds); + retainedBackingOffsets = + [CreateInitialRetainedBackingOffset(semanticBounds, fallbackBounds)]; + result.Add(allocationBounds); + hasPendingSkiaWork = false; + break; + default: + Rect previousDefaultSemanticBounds = semanticBounds; + if (!semanticBounds.IsInvalid) + semanticBounds = item.TransformBounds(semanticBounds); + if (!allocationBounds.IsInvalid) + allocationBounds = item.TransformBounds(allocationBounds); + retainedBackingOffsets = TransformRetainedBackingOffsets( + retainedBackingOffsets, + previousDefaultSemanticBounds, + semanticBounds, + item, + fallbackBounds); + result.Add(NormalizeLegacyAllocationBounds(allocationBounds, fallbackBounds)); + AddRetainedBackingFootprints( + result, + semanticBounds, + retainedBackingOffsets, + fallbackBounds); + hasPendingSkiaWork = false; + break; + } + } + + Rect normalizedAllocationBounds = NormalizeLegacyAllocationBounds(allocationBounds, fallbackBounds); + Rect normalizedSemanticBounds = NormalizeLegacySemanticBounds(semanticBounds, fallbackBounds); + result.Add(normalizedAllocationBounds); + result.Add(normalizedSemanticBounds); + result.Add(new Rect( + normalizedSemanticBounds.Position, + new Size( + Math.Max(normalizedAllocationBounds.Width, normalizedSemanticBounds.Width), + Math.Max(normalizedAllocationBounds.Height, normalizedSemanticBounds.Height)))); + AddRetainedBackingFootprints( + result, + normalizedSemanticBounds, + retainedBackingOffsets, + fallbackBounds); + return new LegacyFootprintState(normalizedSemanticBounds, retainedBackingOffsets); + } + + private static Rect CreateInitialRetainedBackingOffset( + Rect semanticBounds, + Rect fallbackBounds) + { + Rect normalizedSemanticBounds = NormalizeLegacySemanticBounds(semanticBounds, fallbackBounds); + Rect scaleOneRasterBounds = PixelRect.FromRect(normalizedSemanticBounds, 1).ToRect(1); + return scaleOneRasterBounds.Translate(new Point( + -normalizedSemanticBounds.X, + -normalizedSemanticBounds.Y)); + } + + private static Rect[] TransformRetainedBackingOffsets( + IReadOnlyList retainedBackingOffsets, + Rect previousSemanticBounds, + Rect semanticBounds, + IFEItem item, + Rect fallbackBounds) + { + Rect previous = NormalizeLegacySemanticBounds(previousSemanticBounds, fallbackBounds); + Rect current = NormalizeLegacySemanticBounds(semanticBounds, fallbackBounds); + var result = new Rect[retainedBackingOffsets.Count]; + bool deferred = item is IFEItem_Skia { ResolveBoundsAtExecutionTime: true }; + for (int index = 0; index < retainedBackingOffsets.Count; index++) + { + Rect physicalBounds = retainedBackingOffsets[index].Translate(previous.Position); + // A deferred-bound item resolves at execution time; its authoring-time footprint is + // unknown, so the retained backing stays symbolic and conservative. + Rect transformed = deferred + ? Rect.Invalid + : item.TransformBounds(physicalBounds); + Rect normalized = NormalizeLegacyAllocationBounds(transformed, fallbackBounds); + result[index] = normalized.Translate(new Point(-current.X, -current.Y)); + } + + return result; + } + + private static void AddRetainedBackingFootprints( + List result, + Rect semanticBounds, + IReadOnlyList retainedBackingOffsets, + Rect fallbackBounds) + { + Rect normalizedSemanticBounds = NormalizeLegacySemanticBounds(semanticBounds, fallbackBounds); + foreach (Rect offset in retainedBackingOffsets) + { + result.Add(NormalizeLegacyAllocationBounds( + offset.Translate(normalizedSemanticBounds.Position), + fallbackBounds)); + } + } + + private static Rect NormalizeLegacySemanticBounds(Rect bounds, Rect fallbackBounds) + => bounds.IsInvalid ? fallbackBounds : bounds; + + private static Rect ToLocalLegacyFootprint(Rect bounds, Rect fallbackBounds) + { + Rect normalized = NormalizeLegacySemanticBounds(bounds, fallbackBounds); + return new Rect(default(Point), normalized.Size); + } + + private static Rect NormalizeLegacyAllocationBounds(Rect bounds, Rect fallbackBounds) + => bounds.IsInvalid ? new Rect(default(Point), fallbackBounds.Size) : bounds; + + private static float ClampToBufferBudgets( + IReadOnlyList bufferBounds, + float workingScale) + { + float result = workingScale; + foreach (Rect bounds in bufferBounds) + { + result = MathF.Min( + result, + RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, workingScale)); + } + + return result; + } + + private readonly record struct LegacyFootprintState( + Rect SemanticBounds, + IReadOnlyList RetainedBackingOffsets); +} diff --git a/src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs index 0daa0f57ac..5e143ab2dc 100644 --- a/src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs @@ -5,6 +5,9 @@ namespace Beutl.Graphics.Rendering; public sealed class GeometryClipRenderNode(Geometry.Resource clip, ClipOperation operation) : ContainerRenderNode { + private static readonly RenderResourceSlot s_geometrySlot = new(); + private static readonly RenderResourceSlot s_hitTestSlot = new(); + public (Geometry.Resource Resource, int Version)? Clip { get; private set; } = clip.Capture(); public ClipOperation Operation { get; private set; } = operation; @@ -24,27 +27,54 @@ public bool Update(Geometry.Resource clip, ClipOperation operation) changed = true; } - HasChanges = true; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - if (Clip == null) + if (Clip is not { } clip) { - return context.Input; + context.PassThrough(); + return; } + if (context.Inputs.Count == 0) + return; - return context.Input.Select(r => - { - return RenderNodeOperation.CreateDecorator(r, canvas => - { - using (canvas.PushClip(Clip.Value.Resource, Operation)) + ClipOperation operation = Operation; + var boundsMetadata = new GeometryClipBoundsMetadata(clip.Resource.Bounds, operation); + RenderResource resource = context.Borrow(clip.Resource); + var hitTestState = new GeometryClipHitTestState(clip.Resource, operation); + RenderResource hitTestResource = context.Borrow(hitTestState); + TargetScopeDefinition definition = TargetScopeDefinition.Create( + static (session, state) => session.UseResource(s_geometrySlot, geometry => + session.Canvas.Use(canvas => { - r.Render(canvas); - } - }); - }).ToArray(); + using (canvas.PushClip(geometry, state)) + { + session.ReplayInput(); + } + })), + RenderBoundsContract.Create( + boundsMetadata.TransformBounds, + boundsMetadata.GetRequiredInputBounds), + RenderHitTestContract.FromResource( + hitTestResource, + static (state, hitTest, point) => state.HitTest(hitTest, point)), + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent, + deviceGridMapping: RenderDeviceGridMapping.Preserved, + resources: [s_geometrySlot, s_hitTestSlot]); + + context.PublishMappedInputs( + definition.Call( + operation, + [s_geometrySlot.Bind(resource), s_hitTestSlot.Bind(hitTestResource)]), + static (context, input, value) => context.TargetScope(input, value)); } protected override void OnDispose(bool disposing) @@ -52,4 +82,25 @@ protected override void OnDispose(bool disposing) base.OnDispose(disposing); Clip = null!; } + + private readonly record struct GeometryClipBoundsMetadata(Rect Bounds, ClipOperation Operation) + { + public Rect TransformBounds(Rect value) + => Operation == ClipOperation.Intersect ? value.Intersect(Bounds) : value; + + public Rect GetRequiredInputBounds(Rect value) + => Operation == ClipOperation.Intersect ? value.Intersect(Bounds) : value; + } + + private sealed class GeometryClipHitTestState( + Geometry.Resource geometry, + ClipOperation operation) + { + public bool HitTest(RenderHitTestContext context, Point point) + { + bool insideClip = geometry.FillContains(point); + bool clipAcceptsPoint = operation == ClipOperation.Intersect ? insideClip : !insideClip; + return clipAcceptsPoint && context.Inputs.Any(input => input.HitTest(point)); + } + } } diff --git a/src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs index 371c43acb1..bd75776ea9 100644 --- a/src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs @@ -17,20 +17,51 @@ public bool Update(Geometry.Resource geometry, Brush.Resource? fill, Pen.Resourc changed = true; } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return - [ - RenderNodeOperation.CreateLambda( - bounds: PenHelper.CalculateBoundsWithStrokeCap(Geometry!.Value.Resource.GetRenderBounds(Pen?.Resource), Pen?.Resource), - render: canvas => canvas.DrawGeometry(Geometry!.Value.Resource, Fill?.Resource, Pen?.Resource), - hitTest: HitTest - ) - ]; + if (Geometry is not { } geometrySnapshot) + return; + + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + Geometry.Resource geometry = geometrySnapshot.Resource; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + Rect strokeBounds = PenHelper.CalculateBoundsWithStrokeCap( + geometry.GetRenderBounds(pen), + pen); + // DrawGeometry always paints the whole fill path, so a pen whose stroke sits inside the fill + // (negative Offset, a trimmed or dashed outline) must not shrink the declared output. + Rect bounds = fill is null ? strokeBounds : strokeBounds.Union(geometry.Bounds); + if (bounds.Width == 0 || bounds.Height == 0) + return; + + RenderResource geometryResource = context.Borrow(geometry); + var hitTestState = new GeometryHitTestState(geometry, fill, pen); + RenderResource hitTestResource = context.Borrow(hitTestState); + + context.Publish(context.PaintedSource( + state: geometry, + draw: static (canvas, fill, pen, state) => + canvas.DrawGeometry(state, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.FromResource( + hitTestResource, + static (state, point) => state.HitTest(point)), + scale: RenderScaleContract.Vector, + resources: DeferredOpaqueSource.Resources( + geometryResource, + hitTestResource))); } protected override void OnDispose(bool disposing) @@ -39,9 +70,28 @@ protected override void OnDispose(bool disposing) Geometry = null!; } - private bool HitTest(Point point) + private sealed class GeometryHitTestState( + Geometry.Resource geometry, + Brush.Resource? fill, + Pen.Resource? pen) + { + public bool HitTest(Point point) + { + return (fill is not null && geometry.FillContains(point)) + || (pen is not null && geometry.StrokeContains(pen, point)); + } + } + +} + +internal static class DeferredOpaqueSource +{ + public static IReadOnlyList Resources(params RenderResource?[] resources) { - return (Fill != null && Geometry!.Value.Resource.FillContains(point)) - || (Pen != null && Geometry!.Value.Resource.StrokeContains(Pen?.Resource, point)); + return resources + .Where(static resource => resource is not null) + .Select(static resource => resource!) + .DistinctBy(static resource => resource.SlotIdentity) + .ToArray(); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/GpuResourceReclaimQueue.cs b/src/Beutl.Engine/Graphics/Rendering/GpuResourceReclaimQueue.cs new file mode 100644 index 0000000000..2906fb2b06 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/GpuResourceReclaimQueue.cs @@ -0,0 +1,127 @@ +using Beutl.Graphics.Backend; + +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +/// +/// Holds GPU-backed render-target resources between their last managed reference going away and the +/// backend finishing the commands that still read them. +/// +/// +/// A GPU wraps an that Beutl owns and Skia only +/// borrows, so recording a draw from one target into another leaves the source image referenced by +/// work Skia has not submitted yet. Destroying the source in that window hands the driver a freed +/// image. Deferring the destruction until a context-wide flush has submitted and synchronized closes +/// the window without paying for a flush after every draw. +/// +internal static class GpuResourceReclaimQueue +{ + /// Drains early once deferred resources outgrow this, so a render that never reads back stays bounded. + private const long PendingByteBudget = 256L * 1024 * 1024; + + private static readonly List s_pending = []; + private static long s_pendingBytes; + private static bool s_draining; + + /// The number of resources waiting for the backend, for diagnostics and tests. + internal static int PendingCount => s_pending.Count; + + /// + /// Takes ownership of until the next drain. + /// + /// + /// when the queue cannot take it — the caller destroys it itself. + /// + public static bool TryDefer(IDisposable resource, long approximateBytes) + { + if (s_draining + || !RenderThread.Dispatcher.CheckAccess() + || GraphicsContextFactory.SharedContext is null) + { + return false; + } + + s_pending.Add(resource); + s_pendingBytes += Math.Max(0, approximateBytes); + + if (s_pendingBytes > PendingByteBudget) + { + FlushAndDrain(); + } + + return true; + } + + /// + /// Destroys everything queued so far. The caller guarantees a context-wide flush that submitted + /// and CPU-synchronized every recorded command has completed. + /// + public static void DrainAfterContextSync() + { + if (RenderThread.Dispatcher.CheckAccess()) + { + Drain(); + } + } + + /// + /// Submits and synchronizes the shared context, then destroys everything queued so far. + /// + /// + /// The context whose own flush the caller wants to skip, or when the caller only + /// wants the queue drained and is not about to sample anything. + /// + /// + /// only when the context-wide flush covered + /// itself. The queue is drained either way; a caller told still has to submit + /// its own surface, because only the shared context is flushed here and a target from a caller-supplied + /// factory can live on another one — skipping its flush would let a snapshot read work never submitted. + /// + public static bool FlushAndDrain(GRRecordingContext? samplingContext = null) + { + if (s_pending.Count == 0 || s_draining || !RenderThread.Dispatcher.CheckAccess()) + { + return false; + } + + bool flushedSamplingContext = false; + if (GraphicsContextFactory.SharedContext is { } context) + { + GRContext shared = context.SkiaContext; + shared.Flush(true, true); + flushedSamplingContext = samplingContext is null || ReferenceEquals(shared, samplingContext); + } + + Drain(); + return flushedSamplingContext; + } + + private static void Drain() + { + if (s_pending.Count == 0 || s_draining) return; + + s_draining = true; + try + { + // Destroy in queue order so a surface is released before the texture it wraps. + for (int i = 0; i < s_pending.Count; i++) + { + try + { + s_pending[i].Dispose(); + } + catch + { + // A backend teardown failure must not strand the remaining resources. + } + } + } + finally + { + s_pending.Clear(); + s_pendingBytes = 0; + s_draining = false; + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/GpuResourceRelease.cs b/src/Beutl.Engine/Graphics/Rendering/GpuResourceRelease.cs index 9c129990cd..9e7ebb0260 100644 --- a/src/Beutl.Engine/Graphics/Rendering/GpuResourceRelease.cs +++ b/src/Beutl.Engine/Graphics/Rendering/GpuResourceRelease.cs @@ -31,14 +31,54 @@ public static void Run(Dispatcher? dispatcher, Action release) return; } - int claimed = 0; int started = 0; + Action? pendingRelease = release; + EventHandler? shutdownHandler = null; + void RemoveShutdownHandler() + { + EventHandler? handler = Interlocked.Exchange(ref shutdownHandler, null); + if (handler is not null) + { + dispatcher.ShutdownFinished -= handler; + } + } + void Once() { Volatile.Write(ref started, 1); - if (Interlocked.Exchange(ref claimed, 1) == 0) + Action? claimedRelease = Interlocked.Exchange(ref pendingRelease, null); + if (claimedRelease is null) { - release(); + return; + } + + RemoveShutdownHandler(); + claimedRelease(); + } + + void RegisterShutdownFallback() + { + EventHandler handler = (_, _) => + { + try + { + Once(); + } + catch (Exception ex) + { + s_logger.LogWarning(ex, "A GPU resource release failed after dispatcher shutdown"); + } + }; + dispatcher.ShutdownFinished += handler; + if (Interlocked.CompareExchange(ref shutdownHandler, handler, null) is not null) + { + dispatcher.ShutdownFinished -= handler; + return; + } + + if (Volatile.Read(ref pendingRelease) is null) + { + RemoveShutdownHandler(); } } @@ -84,6 +124,13 @@ void Once() return; } + RegisterShutdownFallback(); + if (dispatcher.HasShutdownFinished) + { + Once(); + return; + } + s_logger.LogDebug( "GPU resource release is still queued after {Deadline}; leaving it to the render thread.", s_deadline); @@ -97,4 +144,135 @@ void Once() TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + + public static void RunRequired(Dispatcher dispatcher, Action operation) + => RunRequired(dispatcher, () => + { + operation(); + return true; + }); + + public static T RunRequired(Dispatcher dispatcher, Func operation) + { + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(operation); + + if (dispatcher.HasShutdownStarted) + { + throw new InvalidOperationException("The render dispatcher is shutting down."); + } + + if (dispatcher.CheckAccess()) + { + return operation(); + } + + using var cancellation = new CancellationTokenSource(); + int claim = 0; + Func? pendingOperation = operation; + Task queued = dispatcher.InvokeAsync(() => + { + if (Interlocked.CompareExchange(ref claim, 1, 0) != 0) + { + return default!; + } + + Func claimedOperation = Interlocked.Exchange(ref pendingOperation, null)!; + return claimedOperation(); + }, ct: cancellation.Token); + + while (true) + { + if (((IAsyncResult)queued).AsyncWaitHandle.WaitOne(s_slice)) + { + return queued.GetAwaiter().GetResult(); + } + + if (Volatile.Read(ref claim) == 1) + { + return queued.GetAwaiter().GetResult(); + } + + if (dispatcher.HasShutdownStarted) + { + if (Interlocked.CompareExchange(ref claim, 2, 0) != 0) + { + return queued.GetAwaiter().GetResult(); + } + + Interlocked.Exchange(ref pendingOperation, null); + cancellation.Cancel(); + throw new InvalidOperationException("The render dispatcher shut down before the operation started."); + } + } + } + + public static void DispatchFinalizer(Dispatcher? dispatcher, Action release) + { + ArgumentNullException.ThrowIfNull(release); + + if (dispatcher is null || dispatcher.CheckAccess() || dispatcher.HasShutdownFinished) + { + ReleaseFromFinalizer(release); + return; + } + + Action? pendingRelease = release; + EventHandler? shutdownHandler = null; + void Once() + { + Action? claimedRelease = Interlocked.Exchange(ref pendingRelease, null); + if (claimedRelease is null) + { + return; + } + + if (shutdownHandler is not null) + { + dispatcher.ShutdownFinished -= shutdownHandler; + shutdownHandler = null; + } + + ReleaseFromFinalizer(claimedRelease); + } + + shutdownHandler = (_, _) => Once(); + dispatcher.ShutdownFinished += shutdownHandler; + + if (dispatcher.HasShutdownFinished) + { + Once(); + return; + } + + if (dispatcher.HasShutdownStarted) + { + return; + } + + try + { + dispatcher.Dispatch(Once, ct: CancellationToken.None); + } + catch (Exception ex) + { + s_logger.LogDebug(ex, "Could not dispatch finalizer-driven GPU resource cleanup"); + if (dispatcher.HasShutdownFinished) + { + Once(); + } + } + } + + private static void ReleaseFromFinalizer(Action release) + { + try + { + release(); + } + catch (Exception ex) + { + s_logger.LogDebug(ex, "Finalizer-driven GPU resource cleanup failed"); + } + } } diff --git a/src/Beutl.Engine/Graphics/Rendering/GraphicsContext2D.cs b/src/Beutl.Engine/Graphics/Rendering/GraphicsContext2D.cs index 9ede62829f..18d00bfac8 100644 --- a/src/Beutl.Engine/Graphics/Rendering/GraphicsContext2D.cs +++ b/src/Beutl.Engine/Graphics/Rendering/GraphicsContext2D.cs @@ -1,8 +1,10 @@ using Beutl.Graphics.Effects; using Beutl.Graphics.Transformation; +using Beutl.Logging; using Beutl.Media; using Beutl.Media.Source; using Beutl.Media.TextFormatting; +using Microsoft.Extensions.Logging; namespace Beutl.Graphics.Rendering; @@ -12,13 +14,15 @@ public sealed class GraphicsContext2D( float outputScale = 1f) : IDisposable, IPopable { - private readonly Stack<(ContainerRenderNode, int)> _nodes = []; + private readonly Stack<(ContainerRenderNode Container, int OperationIndex, bool HasChanges)> _nodes = []; private int _drawOperationindex; + private readonly ContainerRenderNode _rootContainer = container; private ContainerRenderNode _container = container; - // 下位のノードで変更があったとき、上位に伝搬するためのフィールド。Pop時に上位ノードのHasChangesを変更する用。 + // Belongs to the innermost open scope, not to the pass: it accumulates until that scope closes. private bool _hasChanges; + private bool _faulted; /// The logical viewport size (float, not rounded to device pixels). public Size Size => canvasSize; @@ -40,14 +44,44 @@ private void Untracked(RenderNode? node) private void Add(RenderNode node) { - if (_drawOperationindex < _container.Children.Count) + RenderNode? previous = null; + bool replacementAttempted = false; + try { - Untracked(_container.Children[_drawOperationindex]); - _container.SetChild(_drawOperationindex, node); + if (_drawOperationindex < _container.Children.Count) + { + previous = _container.Children[_drawOperationindex]; + replacementAttempted = true; + _container.SetChild(_drawOperationindex, node); + Untracked(previous); + } + else + { + _container.AddChild(node); + } } - else + catch { - _container.AddChild(node); + // SetChild installs before it disposes, so an installed replacement means the previous child is + // already torn down — and RenderNode.Dispose sets IsDisposed only after OnDispose and + // Cache.Dispose return, so a failed teardown leaves a node the pipeline's guards wave through. + bool ownershipTransferred = replacementAttempted + && ReferenceEquals(_container.Children[_drawOperationindex], node); + + if (!ownershipTransferred) + { + try + { + node.Dispose(); + } + catch (Exception cleanupFailure) + { + // Preserve the recording failure that prevented ownership transfer. + ReportCleanupFailure(cleanupFailure, "disposing a rejected render node"); + } + } + + throw; } _hasChanges = true; @@ -61,10 +95,25 @@ private void AddAndPush(ContainerRenderNode node) private void Push(ContainerRenderNode node) { - _nodes.Push((_container, _drawOperationindex + 1)); + _nodes.Push((_container, _drawOperationindex + 1, _hasChanges)); _drawOperationindex = 0; _container = node; + _hasChanges = false; + } + + private void CloseScope(in (ContainerRenderNode Container, int OperationIndex, bool HasChanges) state) + { + if (!_faulted) + { + TrimTrailingNodes(_container, _drawOperationindex); + _container.HasChanges |= _hasChanges; + } + + bool scopeChanges = _hasChanges; + _container = state.Container; + _drawOperationindex = state.OperationIndex; + _hasChanges = state.HasChanges | scopeChanges; } private T? Next() where T : RenderNode @@ -90,17 +139,97 @@ private void Push(ContainerRenderNode node) public void Dispose() { - _container.RemoveRange(_drawOperationindex, _container.Children.Count - _drawOperationindex); + if (_faulted) + return; + + TrimTrailingNodes(_container, _drawOperationindex); + _container.HasChanges |= _hasChanges; + } + + private bool BeginRecordingOperation() + { + bool wasFaulted = _faulted; + _faulted = true; + return wasFaulted; + } + + private void CompleteRecordingOperation(bool wasFaulted) + { + _faulted = wasFaulted; + } + + private void TrimTrailingNodes(ContainerRenderNode container, int start) + { + RenderNode[] removed; + try + { + int count = container.Children.Count - start; + if (count == 0) + return; + + removed = [.. container.Children.Skip(start)]; + container.RemoveRange(start, count); + container.HasChanges = true; + _hasChanges = true; + } + catch (Exception cleanupFailure) + { + // Recording cleanup must not replace an exception already leaving the caller. + ReportCleanupFailure(cleanupFailure, "detaching trailing render nodes"); + return; + } + + foreach (RenderNode node in removed) + { + try + { + node.Dispose(); + } + catch (Exception cleanupFailure) + { + // Continue discharging every detached node. + ReportCleanupFailure(cleanupFailure, "disposing a detached render node"); + } + + try + { + Untracked(node); + } + catch (Exception cleanupFailure) + { + // Untracking notifications are cleanup and must not escape Dispose/Pop. + ReportCleanupFailure(cleanupFailure, "notifying a detached render node"); + } + } + } + + private static void ReportCleanupFailure(Exception exception, string operation) + { + try + { + Log.CreateLogger().LogWarning( + exception, + "GraphicsContext2D cleanup failed while {Operation}; continuing cleanup.", + operation); + } + catch + { + // Logging must not replace either the recording failure or the cleanup failure being suppressed. + } } public void Reset() { _drawOperationindex = 0; _nodes.Clear(); + _container = _rootContainer; + _faulted = false; + _hasChanges = false; } public MemoryNode UseMemory(T defaultValue) { + bool wasFaulted = BeginRecordingOperation(); MemoryNode? next = Next>(); if (next == null) @@ -110,6 +239,7 @@ public MemoryNode UseMemory(T defaultValue) } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); return next; } @@ -120,6 +250,7 @@ public MemoryNode UseMemory(T defaultValue) public void Clear() { + bool wasFaulted = BeginRecordingOperation(); ClearRenderNode? next = Next(); if (next == null || !next.Equals(default)) @@ -128,10 +259,12 @@ public void Clear() } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void Clear(Color color) { + bool wasFaulted = BeginRecordingOperation(); ClearRenderNode? next = Next(); if (next == null || !next.Equals(color)) @@ -140,10 +273,12 @@ public void Clear(Color color) } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawImageSource(ImageSource.Resource source, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); ArgumentNullException.ThrowIfNull(source); @@ -157,14 +292,16 @@ public void DrawImageSource(ImageSource.Resource source, Brush.Resource? fill, P } else { - _hasChanges = next.Update(source, fill, pen); + _hasChanges |= next.Update(source, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawVideoSource(VideoSource.Resource source, TimeSpan frame, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); ArgumentNullException.ThrowIfNull(source); @@ -173,10 +310,12 @@ public void DrawVideoSource(VideoSource.Resource source, TimeSpan frame, Brush.R Rational rate = source.FrameRate; double frameNum = frame.TotalSeconds * (rate.Numerator / (double)rate.Denominator); DrawVideoSource(source, (int)frameNum, fill, pen); + CompleteRecordingOperation(wasFaulted); } public void DrawVideoSource(VideoSource.Resource source, int frame, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); ArgumentNullException.ThrowIfNull(source); @@ -190,14 +329,16 @@ public void DrawVideoSource(VideoSource.Resource source, int frame, Brush.Resour } else { - _hasChanges = next.Update(source, frame, fill, pen); + _hasChanges |= next.Update(source, frame, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawEllipse(Rect rect, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); @@ -209,14 +350,16 @@ public void DrawEllipse(Rect rect, Brush.Resource? fill, Pen.Resource? pen) } else { - _hasChanges = next.Update(rect, fill, pen); + _hasChanges |= next.Update(rect, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawGeometry(Geometry.Resource geometry, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); ArgumentNullException.ThrowIfNull(geometry); @@ -230,14 +373,16 @@ public void DrawGeometry(Geometry.Resource geometry, Brush.Resource? fill, Pen.R } else { - _hasChanges = next.Update(geometry, fill, pen); + _hasChanges |= next.Update(geometry, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawRectangle(Rect rect, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); @@ -249,14 +394,16 @@ public void DrawRectangle(Rect rect, Brush.Resource? fill, Pen.Resource? pen) } else { - _hasChanges = next.Update(rect, fill, pen); + _hasChanges |= next.Update(rect, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawText(FormattedText text, Brush.Resource? fill, Pen.Resource? pen) { + bool wasFaulted = BeginRecordingOperation(); if (fill != null) ObjectDisposedException.ThrowIf(fill.IsDisposed, fill); if (pen != null) ObjectDisposedException.ThrowIf(pen.IsDisposed, pen); ArgumentNullException.ThrowIfNull(text); @@ -269,17 +416,21 @@ public void DrawText(FormattedText text, Brush.Resource? fill, Pen.Resource? pen } else { - _hasChanges = next.Update(text, fill, pen); + _hasChanges |= next.Update(text, fill, pen); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawDrawable(Drawable.Resource drawable) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(drawable); ObjectDisposedException.ThrowIf(drawable.IsDisposed, drawable); + ContainerRenderNode parent = _container; + int operationIndex = _drawOperationindex; DrawableRenderNode? next = Next(); if (next == null) @@ -288,16 +439,23 @@ public void DrawDrawable(Drawable.Resource drawable) } else { - _hasChanges = next.Update(drawable); + _hasChanges |= next.Update(drawable); Push(next); } int count = _nodes.Count; + CompleteRecordingOperation(wasFaulted); try { - var obj = drawable.GetOriginal(); + var obj = drawable.GetOriginal()!; obj.Render(this, drawable); } + catch + { + _faulted = true; + TrimTrailingNodes(parent, operationIndex); + throw; + } finally { Pop(count); @@ -306,6 +464,7 @@ public void DrawDrawable(Drawable.Resource drawable) public void DrawNode(RenderNode node) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(node); ObjectDisposedException.ThrowIf(node.IsDisposed, node); @@ -317,32 +476,44 @@ public void DrawNode(RenderNode node) } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawNode(in TParams parameters, Func createNode, Func updateNode) where TNode : RenderNode { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(createNode); ArgumentNullException.ThrowIfNull(updateNode); TNode? next = Next(); - if (next == null) + try { - TNode node = createNode(parameters); - Add(node); + if (next == null) + { + TNode node = createNode(parameters); + Add(node); + } + else + { + _hasChanges |= updateNode(next, parameters); + } } - else + catch { - _hasChanges = updateNode(next, parameters); + _faulted = true; + throw; } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public void DrawBackdrop(IBackdrop backdrop) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(backdrop); DrawBackdropRenderNode? next = Next(); @@ -354,14 +525,16 @@ public void DrawBackdrop(IBackdrop backdrop) } else { - _hasChanges = next.Update(backdrop, b); + _hasChanges |= next.Update(backdrop, b); } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); } public IBackdrop Snapshot() { + bool wasFaulted = BeginRecordingOperation(); SnapshotBackdropRenderNode? next = Next(); if (next == null) @@ -370,6 +543,7 @@ public IBackdrop Snapshot() } ++_drawOperationindex; + CompleteRecordingOperation(wasFaulted); return next; } @@ -378,47 +552,25 @@ public void Pop(int count = -1) if (count < 0) { while (count < 0 - && _nodes.TryPop(out (ContainerRenderNode, int) state)) + && _nodes.TryPop(out (ContainerRenderNode, int, bool) state)) { - foreach (RenderNode node in _container.Children.Take(_drawOperationindex..)) - { - _hasChanges = true; - node.Dispose(); - Untracked(node); - } - - _container.RemoveRange(_drawOperationindex, _container.Children.Count - _drawOperationindex); - - _container = state.Item1; - _container.HasChanges = _container.HasChanges || _hasChanges; - _drawOperationindex = state.Item2; - + CloseScope(state); count++; } } else { while (_nodes.Count >= count - && _nodes.TryPop(out (ContainerRenderNode, int) state)) + && _nodes.TryPop(out (ContainerRenderNode, int, bool) state)) { - foreach (RenderNode node in _container.Children.Take(_drawOperationindex..)) - { - _hasChanges = true; - node.Dispose(); - Untracked(node); - } - - _container.RemoveRange(_drawOperationindex, _container.Children.Count - _drawOperationindex); - - _container = state.Item1; - _container.HasChanges = _container.HasChanges || _hasChanges; - _drawOperationindex = state.Item2; + CloseScope(state); } } } public PushedState Push() { + bool wasFaulted = BeginRecordingOperation(); PushRenderNode? next = Next(); if (next == null) @@ -430,11 +582,14 @@ public PushedState Push() Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushLayer(Rect limit = default) { + bool wasFaulted = BeginRecordingOperation(); LayerRenderNode? next = Next(); if (next == null) @@ -443,15 +598,18 @@ public PushedState PushLayer(Rect limit = default) } else { - _hasChanges = next.Update(limit); + _hasChanges |= next.Update(limit); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushBlendMode(BlendMode blendMode) { + bool wasFaulted = BeginRecordingOperation(); BlendModeRenderNode? next = Next(); if (next == null) @@ -460,15 +618,18 @@ public PushedState PushBlendMode(BlendMode blendMode) } else { - _hasChanges = next.Update(blendMode); + _hasChanges |= next.Update(blendMode); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushClip(Rect clip, ClipOperation operation = ClipOperation.Intersect) { + bool wasFaulted = BeginRecordingOperation(); RectClipRenderNode? next = Next(); if (next == null) @@ -477,15 +638,18 @@ public PushedState PushClip(Rect clip, ClipOperation operation = ClipOperation.I } else { - _hasChanges = next.Update(clip, operation); + _hasChanges |= next.Update(clip, operation); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushClip(Geometry.Resource geometry, ClipOperation operation = ClipOperation.Intersect) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(geometry); ObjectDisposedException.ThrowIf(geometry.IsDisposed, geometry); @@ -497,15 +661,18 @@ public PushedState PushClip(Geometry.Resource geometry, ClipOperation operation } else { - _hasChanges = next.Update(geometry, operation); + _hasChanges |= next.Update(geometry, operation); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushOpacity(float opacity) { + bool wasFaulted = BeginRecordingOperation(); OpacityRenderNode? next = Next(); if (next == null) @@ -514,18 +681,22 @@ public PushedState PushOpacity(float opacity) } else { - _hasChanges = next.Update(opacity); + _hasChanges |= next.Update(opacity); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushFilterEffect(FilterEffect.Resource effect) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(effect); ObjectDisposedException.ThrowIf(effect.IsDisposed, effect); + PushedState result; switch (effect) { case FilterEffectGroup.Resource group: @@ -535,14 +706,20 @@ public PushedState PushFilterEffect(FilterEffect.Resource effect) PushFilterEffect(item); } - return new(this, _nodes.Count); + result = new PushedState(this, _nodes.Count); + break; default: - return effect.Push(this); + result = effect.Push(this); + break; } + + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushOpacityMask(Brush.Resource mask, Rect bounds, bool invert = false) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(mask); ObjectDisposedException.ThrowIf(mask.IsDisposed, mask); @@ -554,15 +731,18 @@ public PushedState PushOpacityMask(Brush.Resource mask, Rect bounds, bool invert } else { - _hasChanges = next.Update(mask, bounds, invert); + _hasChanges |= next.Update(mask, bounds, invert); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushTransform(Matrix matrix, TransformOperator transformOperator = TransformOperator.Prepend) { + bool wasFaulted = BeginRecordingOperation(); TransformRenderNode? next = Next(); if (next == null) @@ -571,16 +751,19 @@ public PushedState PushTransform(Matrix matrix, TransformOperator transformOpera } else { - _hasChanges = next.Update(matrix, transformOperator); + _hasChanges |= next.Update(matrix, transformOperator); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushTransform(Transform.Resource transform, TransformOperator transformOperator = TransformOperator.Prepend) { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(transform); ObjectDisposedException.ThrowIf(transform.IsDisposed, transform); @@ -592,17 +775,20 @@ public PushedState PushTransform(Transform.Resource transform, } else { - _hasChanges = next.Update(matrix, transformOperator); + _hasChanges |= next.Update(matrix, transformOperator); Push(next); } - return new(this, _nodes.Count); + var result = new PushedState(this, _nodes.Count); + CompleteRecordingOperation(wasFaulted); + return result; } public PushedState PushNode(in TParams parameters, Func createNode, Func updateNode) where TNode : ContainerRenderNode { + bool wasFaulted = BeginRecordingOperation(); ArgumentNullException.ThrowIfNull(createNode); ArgumentNullException.ThrowIfNull(updateNode); @@ -615,10 +801,12 @@ public PushedState PushNode(in TParams parameters, FuncGets the current-frame bounds for drawables at the specified z-index. + /// + /// Call this method on the render thread. Implementations may resolve and cache deferred render-graph metadata + /// when the bounds are first queried after or . + /// + /// The caller does not have render-thread access. Rect[] GetBoundaries(int zIndex); + /// Gets the current-frame bounds for one drawable, or when it is not present. + /// Implementations that evaluate deferred metadata require render-thread access. + /// The caller does not have render-thread access. Rect? GetBoundary(Drawable drawable) => null; DrawableRenderNode? FindRenderNode(Drawable drawable); diff --git a/src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs index f0f7e9f58b..eeccc9fc70 100644 --- a/src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs @@ -25,45 +25,69 @@ public bool Update(ImageSource.Resource source, Brush.Resource? fill, Pen.Resour Bounds = PenHelper.GetBounds(new Rect(default, Source.Value.Resource.FrameSize.ToSize(1)), Pen?.Resource); } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - if (!Source.HasValue) return []; + if (Source is not { } sourceSnapshot) + return; - return - [ - RenderNodeOperation.CreateLambda( - bounds: Bounds, - render: canvas => - { - canvas.DrawImageSource(Source.Value.Resource, Fill?.Resource, Pen?.Resource); - }, - hitTest: HitTest, - // Bitmap at native 1:1 density; downstream transforms re-scale accordingly. - effectiveScale: EffectiveScale.At(1f) - ) - ]; + Rect bounds = Bounds; + if (bounds.Width == 0 || bounds.Height == 0) + return; + + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + ImageSource.Resource source = sourceSnapshot.Resource; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + RenderResource sourceResource = context.Borrow(source); + var hitTestState = new ImageHitTestState( + bounds, + fill is not null, + pen?.StrokeAlignment ?? StrokeAlignment.Inside, + pen?.Thickness ?? 0); + + context.Publish(context.PaintedSource( + state: source, + draw: static (canvas, fill, pen, state) => + canvas.DrawImageSource(state, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.Custom(hitTestState.Evaluate), + scale: RenderScaleContract.Custom(static _ => 1f), + directReplayAtExactIntegerReduction: true, + resources: [sourceResource])); } - private bool HitTest(Point point) + private readonly record struct ImageHitTestState( + Rect Bounds, + bool HasFill, + StrokeAlignment StrokeAlignment, + float Thickness) { - StrokeAlignment alignment = Pen?.Resource.StrokeAlignment ?? StrokeAlignment.Inside; - float thickness = Pen?.Resource.Thickness ?? 0; - thickness = PenHelper.GetRealThickness(alignment, thickness); - - if (Fill != null) + public bool HitTest(Point point) { - Rect rect = Bounds.Inflate(thickness); - return rect.ContainsExclusive(point); - } - else - { - Rect borderRect = Bounds.Inflate(thickness); - Rect emptyRect = Bounds.Deflate(thickness); + float realThickness = PenHelper.GetRealThickness(StrokeAlignment, Thickness); + + if (HasFill) + { + Rect rect = Bounds.Inflate(realThickness); + return rect.ContainsExclusive(point); + } + + Rect borderRect = Bounds.Inflate(realThickness); + Rect emptyRect = Bounds.Deflate(realThickness); return borderRect.ContainsExclusive(point) && !emptyRect.ContainsExclusive(point); } + + public bool Evaluate(RenderHitTestContext _, Point point) => HitTest(point); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs index 74a57ef866..1c97da2650 100644 --- a/src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs @@ -1,6 +1,5 @@ namespace Beutl.Graphics.Rendering; -// TODO: Limitがdefaultの場合、CalculateBoundsを使うようにする public class LayerRenderNode(Rect limit) : ContainerRenderNode { public Rect Limit { get; private set; } = limit; @@ -17,32 +16,11 @@ public bool Update(Rect limit) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - // SaveLayer flatten with no owned buffer; reports Unbounded since it re-rasterizes at any scale. - return - [ - RenderNodeOperation.CreateLambda( - bounds: context.CalculateBounds(), - render: canvas => - { - using (canvas.PushLayer(Limit)) - { - foreach (RenderNodeOperation op in context.Input) - { - op.Render(canvas); - } - } - }, - hitTest: p => context.Input.Any(n => n.HitTest(p)), - onDispose: () => - { - foreach (RenderNodeOperation op in context.Input) - { - op.Dispose(); - } - }, - effectiveScale: EffectiveScale.Unbounded) - ]; + RenderFragmentHandle layer = Limit == default + ? context.TargetLayerScope(context.Inputs, TargetRegion.Full) + : context.Layer(context.Inputs, Limit); + context.Publish(layer); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs b/src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs index be8950c295..90858dcc60 100644 --- a/src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs @@ -4,8 +4,7 @@ public class MemoryNode(T value) : RenderNode { public T Value { get; set; } = value; - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return []; } } diff --git a/src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs index 0c11bd8032..576c764bc7 100644 --- a/src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs @@ -40,19 +40,23 @@ public bool Update(Brush.Resource? mask, Rect maskBounds, bool invert) return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return context.Input.Select(r => + if (Mask is not { } mask) { - return RenderNodeOperation.CreateDecorator(r, canvas => - { - if (!Mask.HasValue) return; - using (canvas.PushOpacityMask(Mask.Value.Resource, MaskBounds, Invert)) - { - r.Render(canvas); - } - }); - }).ToArray(); + return; + } + + Rect maskBounds = MaskBounds; + bool invert = Invert; + RenderResource maskResource = context.Borrow(mask.Resource); + context.PublishMappedInputs( + (maskResource, maskBounds, invert), + static (context, input, state) => context.OpacityMask( + input, + state.maskResource, + state.maskBounds, + state.invert)); } protected override void OnDispose(bool disposing) diff --git a/src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs index e170cda78c..8ddc9494d7 100644 --- a/src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs @@ -1,7 +1,42 @@ -namespace Beutl.Graphics.Rendering; +using System.Collections.Concurrent; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; public sealed class OpacityRenderNode(float opacity) : ContainerRenderNode { + private const string FusionSource = + "uniform float opacity; half4 apply(half4 color) { return color * opacity; }"; + + private const string SpirvFragmentSource = + """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D src; + layout(push_constant) uniform PushConstants { + layout(offset = 0) ivec4 sourceTexelOffset; + layout(offset = 16) float opacity; + } constants; + layout(location = 0) out vec4 outColor; + + void main() + { + ivec2 sourceCoord = ivec2(gl_FragCoord.xy) + constants.sourceTexelOffset.xy; + outColor = texelFetch(src, sourceCoord, 0) * constants.opacity; + } + """; + + private const int MaximumCachedDescriptions = 256; + + private static readonly SkslSource s_fusionSource = new(FusionSource, ShaderDescriptionKind.CurrentPixel); + + private static readonly SpirvShaderLowering s_spirvLowering = new( + SpirvFragmentSource, + [new SpirvPushConstantBinding("opacity", 16)], + supportsBitExactSkiaHandoff: false); + + private static readonly ConcurrentDictionary s_fusionDescriptions = new(); + public float Opacity { get; private set; } = opacity; public bool Update(float opacity) @@ -16,17 +51,45 @@ public bool Update(float opacity) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return context.Input.Select(r => - { - return RenderNodeOperation.CreateDecorator(r, canvas => - { - using (canvas.PushOpacity(Opacity)) - { - r.Render(canvas); - } - }); - }).ToArray(); + float opacity = Opacity; + context.PublishMappedInputs( + opacity, + static (context, input, value) => context.Opacity(input, value)); + } + + /// Returns the shared immutable fusion description for one normalized opacity. + /// + /// Recording allocates one opacity fragment per drawable per pass while the SkSL text is a compile-time + /// constant, so the source is parsed and validated once and every distinct normalized opacity keeps its + /// description. Sharing an instance only avoids repeated construction; retained-output reuse is controlled by + /// the owning node's lifecycle. + /// + internal static ShaderDescription CreateFusionDescription(float opacity) + { + opacity = Normalize(opacity); + int key = BitConverter.SingleToInt32Bits(opacity); + if (s_fusionDescriptions.TryGetValue(key, out ShaderDescription? cached)) + return cached; + + ShaderDescription created = ShaderDescription.CurrentPixel( + s_fusionSource, + s_spirvLowering, + bindings => bindings.Uniform("opacity", opacity)); + + // An animated opacity mints a new key every frame, so the memo is bounded rather than evicted per entry. + if (s_fusionDescriptions.Count >= MaximumCachedDescriptions) + s_fusionDescriptions.Clear(); + + return s_fusionDescriptions.GetOrAdd(key, created); + } + + internal static float Normalize(float opacity) + { + if (!float.IsFinite(opacity)) + throw new ArgumentOutOfRangeException(nameof(opacity), opacity, "Opacity must be finite."); + + return Math.Clamp(opacity, 0, 1); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs deleted file mode 100644 index 8b9a982e76..0000000000 --- a/src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Beutl.Media.Source; - -namespace Beutl.Graphics.Rendering; - -public class OperationWrapperRenderNode : RenderNode -{ - private Ref[] _operations = []; - - public void SetOperations(RenderNodeOperation[] operations) - { - foreach (var r in _operations) - r.Dispose(); - - var refs = new Ref[operations.Length]; - for (int i = 0; i < operations.Length; i++) - refs[i] = Ref.Create(operations[i]); - - _operations = refs; - HasChanges = true; - } - - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - var result = new RenderNodeOperation[_operations.Length]; - for (int i = 0; i < _operations.Length; i++) - result[i] = new RefCountedProxy(_operations[i].Clone()); - - return result; - } - - protected override void OnDispose(bool disposing) - { - base.OnDispose(disposing); - if (disposing) - { - foreach (var r in _operations) - r.Dispose(); - _operations = []; - } - } - - private sealed class RefCountedProxy(Ref inner) : RenderNodeOperation - { - public override Rect Bounds => inner.Value.Bounds; - - // Forward the wrapped op's supply density verbatim. - public override EffectiveScale EffectiveScale => inner.Value.EffectiveScale; - - public override void Render(ImmediateCanvas canvas) => inner.Value.Render(canvas); - - public override bool HitTest(Point point) => inner.Value.HitTest(point); - - protected override void OnDispose(bool disposing) - { - if (disposing) - inner.Dispose(); - } - } -} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/MaterializedInputDescription.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/MaterializedInputDescription.cs new file mode 100644 index 0000000000..8be5c107da --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/MaterializedInputDescription.cs @@ -0,0 +1,105 @@ +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +public sealed class MaterializedInputDescription +{ + private MaterializedInputDescription( + RenderResource target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset, + RenderHitTestContract hitTest) + { + Target = target; + Bounds = bounds; + EffectiveScale = effectiveScale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + HitTest = hitTest; + } + + public Rect Bounds { get; } + + public EffectiveScale EffectiveScale { get; } + + public PixelRect DeviceBounds { get; } + + public Vector DeviceGridOffset { get; } + + public Rect RasterBounds => DeviceBounds + .ToRect(EffectiveScale.Value) + .Translate(-DeviceGridOffset); + + internal RenderResource Target { get; } + + internal RenderHitTestContract HitTest { get; } + + public static MaterializedInputDescription FromRenderTarget( + RenderResource target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset, + RenderHitTestContract hitTest) + { + ArgumentNullException.ThrowIfNull(target); + if (target.RegistrationState == RenderResourceRegistrationState.Released) + throw new ArgumentException("A released render-target resource cannot be materialized.", nameof(target)); + + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(bounds, nameof(bounds)); + if (effectiveScale.IsUnbounded) + { + throw new ArgumentException( + "A materialized input requires a concrete positive effective scale.", + nameof(effectiveScale)); + } + + hitTest.ThrowIfUninitialized(nameof(hitTest)); + if (hitTest.Kind == RenderHitTestContractKind.AnyInput) + { + throw new ArgumentException( + "A materialized source has no logical inputs and cannot use AnyInput hit testing.", + nameof(hitTest)); + } + + if (deviceBounds.Width <= 0 || deviceBounds.Height <= 0) + { + throw new ArgumentException( + "A materialized input must resolve to a non-empty device allocation.", + nameof(deviceBounds)); + } + if (!float.IsFinite(deviceGridOffset.X) || !float.IsFinite(deviceGridOffset.Y)) + throw new ArgumentException("A materialized input requires a finite device-grid offset.", nameof(deviceGridOffset)); + + Rect rasterBounds = deviceBounds + .ToRect(effectiveScale.Value) + .Translate(-deviceGridOffset); + if (!RenderDescriptionValidation.Contains(rasterBounds, bounds)) + { + throw new ArgumentException( + "The materialized input's physical footprint must contain its semantic bounds on the declared device grid.", + nameof(deviceBounds)); + } + + return new MaterializedInputDescription( + target, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset, + hitTest); + } + + internal void ValidateTargetDeviceSize(RenderTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (target.Width != DeviceBounds.Width || target.Height != DeviceBounds.Height) + { + throw new ArgumentException( + "The render target device size must exactly match the materialized input's declared device bounds.", + nameof(target)); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/OpaqueRenderDescription.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/OpaqueRenderDescription.cs new file mode 100644 index 0000000000..371a48100f --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/OpaqueRenderDescription.cs @@ -0,0 +1,1836 @@ +using System.Collections.ObjectModel; +using System.Reflection; +using Beutl.Graphics.Effects; +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +/// +/// Declares whether an opaque description's pixels depend on where the composition-device pixel grid falls. +/// +/// +/// The renderer reuses a cached output only when every value that shaped its pixels is part of the cache +/// identity. Device-grid phase — the sub-pixel offset between the description's own coordinate space and the +/// pixel centres it writes to — is one such value, and no bounds, density, or author-supplied field carries it. +/// +public enum RenderDeviceGridSensitivity : byte +{ + /// + /// The output is unchanged by a sub-pixel shift of the device grid, so it may be cached and reused across + /// device-grid phase changes and across a remapping replay. + /// + Insensitive, + + /// + /// The output is a function of the device-grid phase, so a sub-pixel phase change or a remapping replay + /// ancestor produces different pixels than the cached output. + /// + /// + /// Declare this for anything computed from where the pixel centres fall rather than resampled from a + /// stored raster. Analytic anti-aliased coverage — glyph rasterization, signed-distance-field text — is + /// one such source, and so are screen-space dithering, ordered noise, and pixel-grid overlays, which + /// compute no coverage at all yet still change with the phase. + /// + PhaseDependent, +} + +internal sealed class OpaqueRenderDescription +{ + private readonly RenderExecutionChannel _execution; + + private OpaqueRenderDescription( + RenderExecutionChannel execution, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderInputDemandContract inputDemand, + RenderDeviceGridSensitivity deviceGridSensitivity, + object definitionFingerprint, + IReadOnlyList inputReadbacks, + IReadOnlyList resources, + RenderBackendBoundary backendBoundary, + Action? directReplay, + bool supportsDirectDstOut, + bool hasDirectReplayMaterializationContract = false, + bool directReplayAtExactIntegerReduction = false) + { + _execution = execution; + Bounds = bounds; + HitTest = hitTest; + ValueCardinality = valueCardinality; + Scale = scale; + InputDemand = inputDemand; + DeviceGridSensitivity = deviceGridSensitivity; + DefinitionFingerprint = definitionFingerprint; + InputReadbacks = inputReadbacks; + Resources = resources; + BackendBoundary = backendBoundary; + DirectReplay = directReplay; + SupportsDirectDstOut = supportsDirectDstOut; + HasDirectReplayMaterializationContract = hasDirectReplayMaterializationContract; + DirectReplayAtExactIntegerReduction = directReplayAtExactIntegerReduction; + } + + public OpaqueRenderBoundsContract Bounds { get; } + + public RenderHitTestContract HitTest { get; } + + public RenderValueCardinality ValueCardinality { get; } + + public RenderScaleContract Scale { get; } + + /// Gets the mapping from this operation's resolved output demand to the demand on each input. + /// + /// Only a combine or an expand may declare one. A one-input map carries demand backwards through + /// instead, and a source has no input to demand from. + /// + public RenderInputDemandContract InputDemand { get; } + + /// Gets the declared dependency of this description's pixels on the device pixel grid. + public RenderDeviceGridSensitivity DeviceGridSensitivity { get; } + + public IReadOnlyList InputReadbacks { get; } + + internal object DefinitionFingerprint { get; } + + public IReadOnlyList Resources { get; } + + internal void Execute(OpaqueRenderSession session) => _execution.Invoke(session); + + internal RenderBackendBoundary BackendBoundary { get; } + + internal Action? DirectReplay { get; } + + internal bool SupportsDirectDstOut { get; } + + internal bool HasDirectReplayMaterializationContract { get; } + + internal bool DirectReplayAtExactIntegerReduction { get; } + + internal void ThrowIfIncompatible(OpaqueRenderTopology topology, string parameterName) + { + Bounds.ThrowIfIncompatible(topology, parameterName); + Scale.ThrowIfIncompatible(topology, parameterName); + + if (!InputDemand.IsUnchanged + && topology is not (OpaqueRenderTopology.Combine or OpaqueRenderTopology.Expand)) + { + throw new ArgumentException( + "Only a combine or an expand declares a per-input demand mapping; a one-input map declares it " + + "through its scale contract and a source has no input.", + parameterName); + } + + if (DirectReplay is not null + && topology is not (OpaqueRenderTopology.Source or OpaqueRenderTopology.Combine)) + { + throw new ArgumentException( + "An engine direct-replay description can only be recorded as an opaque source or combine.", + parameterName); + } + + bool cardinalityValid = topology switch + { + OpaqueRenderTopology.Map => + ValueCardinality.Equals(RenderValueCardinality.Single) + || ValueCardinality.Equals(RenderValueCardinality.ZeroOrOne), + OpaqueRenderTopology.Combine => ValueCardinality.Maximum is <= 1, + OpaqueRenderTopology.Source => ValueCardinality.Maximum is <= 1, + OpaqueRenderTopology.Expand => true, + _ => false, + }; + if (!cardinalityValid) + { + throw new ArgumentException( + $"The declared value cardinality is incompatible with {topology} topology.", + parameterName); + } + + if (topology == OpaqueRenderTopology.Source && HitTest.Kind == RenderHitTestContractKind.AnyInput) + { + throw new ArgumentException( + "An opaque source has no logical inputs and cannot use AnyInput hit testing.", + parameterName); + } + } + + internal object GetStructuralIdentity(OpaqueRenderTopology topology) + => new OpaqueRenderStructuralIdentity( + topology, + DefinitionFingerprint, + DeviceGridSensitivity, + BackendBoundary, + HasDirectReplayMaterializationContract, + DirectReplayAtExactIntegerReduction, + SupportsDirectDstOut); + + internal OpaqueRenderDescription WithoutDirectReplay() + => DirectReplay is null + ? this + : new OpaqueRenderDescription( + _execution, + Bounds, + HitTest, + ValueCardinality, + Scale, + InputDemand, + DeviceGridSensitivity, + DefinitionFingerprint, + InputReadbacks, + Resources, + BackendBoundary, + directReplay: null, + supportsDirectDstOut: false, + hasDirectReplayMaterializationContract: false, + directReplayAtExactIntegerReduction: false); + + /// + /// Every pixel-affecting value the callback reads. It belongs in the call state; when it changes, the owning + /// node reports the change through . + /// + /// + /// A non-capturing callback. Declare it : a capture would let a per-frame value + /// shape the output without reaching , and is rejected. + /// + /// + /// The declared dependency of the produced pixels on the device-grid phase. The default states that the + /// output is unchanged by a sub-pixel shift of the grid, which lets the renderer cache and resample it. + /// + internal static OpaqueRenderDescription Create( + TState state, + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null) + where TState : notnull + => CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + execute, + nameof(state), + nameof(execute)), + bounds, + hitTest, + valueCardinality, + scale, + deviceGridSensitivity, + execute.Method, + inputReadbacks, + resources); + + /// + /// Creates an opaque description whose output can never satisfy a later request's cache lookup. + /// + /// + /// The opt-out for a callback whose pixel-affecting state cannot be expressed as copied, deeply immutable + /// CPU state. The callback may capture, and the recorded output takes a fresh request-local identity every + /// time. + /// + internal static OpaqueRenderDescription CreateRequestLocal( + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + valueCardinality, + scale, + deviceGridSensitivity, + execute.Method, + inputReadbacks, + resources); + + internal static OpaqueRenderDescription CreateCore( + RenderExecutionChannel execution, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + object definitionFingerprint, + IEnumerable? inputReadbacks, + IEnumerable? resources, + RenderInputDemandContract inputDemand = default) + { + ArgumentNullException.ThrowIfNull(bounds); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + valueCardinality.ThrowIfUninitialized(nameof(valueCardinality)); + scale.ThrowIfUninitialized(nameof(scale)); + ThrowIfUndefined(deviceGridSensitivity); + + ArgumentNullException.ThrowIfNull(definitionFingerprint); + + return new OpaqueRenderDescription( + execution, + bounds, + hitTest, + valueCardinality, + scale, + inputDemand, + deviceGridSensitivity, + definitionFingerprint, + Array.AsReadOnly(CopyInputReadbacks(inputReadbacks)), + RenderDescriptionValidation.CopyResourceBindings(resources, nameof(resources)), + RenderBackendBoundary.None, + directReplay: null, + supportsDirectDstOut: false); + } + + /// + /// Creates an engine-owned drawable source whose identity is declared rather than derived from state. + /// + /// + /// The callback is assembled by a shared recorder helper and reaches request-scoped resources and a + /// recorded paint plan, neither of which can be part of a persistent identity, so the declared identity is + /// hand-verified against what the helper draws with. Nothing outside the engine can reach this shape. + /// + internal static OpaqueRenderDescription CreateEngineSource( + Action execute, + Action? directReplay, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + bool directReplayAtExactIntegerReduction = false, + bool supportsDirectDstOut = true, + IEnumerable? resources = null) + { + ArgumentNullException.ThrowIfNull(execute); + ArgumentNullException.ThrowIfNull(bounds); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + ThrowIfUndefined(deviceGridSensitivity); + object definitionFingerprint = new EngineOpaqueDefinition( + RenderBackendBoundary.None, + execute.Method, + directReplay?.Method, + directReplayAtExactIntegerReduction); + + return new OpaqueRenderDescription( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + RenderValueCardinality.Single, + scale, + RenderInputDemandContract.Unchanged, + deviceGridSensitivity, + definitionFingerprint, + Array.AsReadOnly(Array.Empty()), + BindInternalResources(resources), + RenderBackendBoundary.None, + directReplay, + supportsDirectDstOut && directReplay is not null, + hasDirectReplayMaterializationContract: + directReplay is not null && scale.DeclaresNoSupplyDensity, + directReplayAtExactIntegerReduction: + directReplay is not null && directReplayAtExactIntegerReduction); + } + + internal static OpaqueRenderDescription CreateBackendBoundary( + RenderBackendBoundary backendBoundary, + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + IEnumerable? resources = null) + { + if (backendBoundary == RenderBackendBoundary.None || !Enum.IsDefined(backendBoundary)) + throw new ArgumentOutOfRangeException(nameof(backendBoundary)); + ArgumentNullException.ThrowIfNull(execute); + ArgumentNullException.ThrowIfNull(bounds); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + valueCardinality.ThrowIfUninitialized(nameof(valueCardinality)); + scale.ThrowIfUninitialized(nameof(scale)); + ThrowIfUndefined(deviceGridSensitivity); + object definitionFingerprint = new EngineOpaqueDefinition( + backendBoundary, + execute.Method, + DirectReplayMethod: null, + DirectReplayAtExactIntegerReduction: false); + + return new OpaqueRenderDescription( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + valueCardinality, + scale, + RenderInputDemandContract.Unchanged, + deviceGridSensitivity, + definitionFingerprint, + Array.AsReadOnly(Array.Empty()), + BindInternalResources(resources), + backendBoundary, + directReplay: null, + supportsDirectDstOut: false); + } + + private static void ThrowIfUndefined(RenderDeviceGridSensitivity deviceGridSensitivity) + { + if (!Enum.IsDefined(deviceGridSensitivity)) + throw new ArgumentOutOfRangeException(nameof(deviceGridSensitivity)); + } + + private static IReadOnlyList BindInternalResources( + IEnumerable? resources) + { + IReadOnlyList copy = + RenderDescriptionValidation.CopyResources(resources, nameof(resources)); + return copy + .Select(static resource => RenderResourceBinding.CreateEngineBinding(resource)) + .ToArray(); + } + + internal IReadOnlyList ResolveInputReadbacks( + int inputCount, + string parameterName) + { + if (InputReadbacks.Count == 0) + return Enumerable.Repeat(RenderInputReadback.None, inputCount).ToArray(); + if (InputReadbacks.Count != inputCount) + { + throw new ArgumentException( + "The opaque-render input readback count must match the authored input count.", + parameterName); + } + return InputReadbacks; + } + + private static RenderInputReadback[] CopyInputReadbacks( + IEnumerable? inputReadbacks) + { + if (inputReadbacks is null) + return []; + + RenderInputReadback[] result = inputReadbacks.ToArray(); + foreach (RenderInputReadback inputReadback in result) + inputReadback.ThrowIfUninitialized(nameof(inputReadbacks)); + return result; + } +} + +internal sealed class EngineDirectRenderSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly IReadOnlyList _inputs; + + internal EngineDirectRenderSession( + RenderExecutionSessionToken token, + ImmediateCanvas canvas, + IReadOnlyList inputs) + { + _token = token; + Canvas = canvas; + _inputs = inputs; + } + + internal ImmediateCanvas Canvas { get; } + + internal RenderExecutionSessionToken Token => _token; + + internal IReadOnlyList Inputs + { + get { _token.ThrowIfInactive(); return _inputs; } + } +} + +internal enum RenderBackendBoundary : byte +{ + None, + Graphics3D, +} + +public sealed class OpaqueRenderBoundsContract +{ + private readonly Rect _sourceBounds; + private readonly RenderBoundsContract _mapBounds; + private readonly Func, Rect>? _transformBounds; + private readonly Func, IReadOnlyList>? _getRequiredInputBounds; + + private OpaqueRenderBoundsContract(Rect sourceBounds, Thickness rasterOutset) + { + Kind = OpaqueRenderBoundsKind.Source; + _sourceBounds = sourceBounds; + RasterOutset = rasterOutset; + StructuralIdentity = new OpaqueRenderBoundsStructuralIdentity(Kind, null, null, null); + } + + private OpaqueRenderBoundsContract(RenderBoundsContract mapBounds) + { + Kind = OpaqueRenderBoundsKind.Map; + _mapBounds = mapBounds; + StructuralIdentity = new OpaqueRenderBoundsStructuralIdentity( + Kind, + mapBounds.StructuralIdentity, + null, + null); + } + + private OpaqueRenderBoundsContract( + OpaqueRenderBoundsKind kind, + Func, Rect> transformBounds, + Func, IReadOnlyList>? getRequiredInputBounds) + { + Kind = kind; + _transformBounds = transformBounds; + _getRequiredInputBounds = getRequiredInputBounds; + StructuralIdentity = new OpaqueRenderBoundsStructuralIdentity( + kind, + transformBounds.Method, + getRequiredInputBounds?.Method, + null); + } + + /// + /// The logical room this source's rasterization needs beyond the bounds it publishes, on each side. + /// + /// + /// Publishing the wider rectangle instead would move it: the bounds a fragment publishes are what places + /// it, so anything scale-dependent in them changes a project's composition between preview and export. + /// The outset therefore only widens the buffer the source draws into; nothing downstream sees it. + /// + public Thickness RasterOutset { get; } + + /// The bounds this source publishes, which place it. + /// + /// Extra logical room per side for the buffer only, for a source whose rasterization reaches outside the + /// bounds it publishes. Must be non-negative and finite. + /// + public static OpaqueRenderBoundsContract Source(Rect outputBounds, Thickness rasterOutset = default) + { + RenderRectValidation.ThrowIfInvalidInput(outputBounds, nameof(outputBounds)); + if (!IsUsableOutset(rasterOutset)) + { + throw new ArgumentOutOfRangeException( + nameof(rasterOutset), + rasterOutset, + "A raster outset must be finite and non-negative on every side."); + } + + return new OpaqueRenderBoundsContract(outputBounds, rasterOutset); + } + + private static bool IsUsableOutset(Thickness outset) + => float.IsFinite(outset.Left) + && float.IsFinite(outset.Top) + && float.IsFinite(outset.Right) + && float.IsFinite(outset.Bottom) + && outset.Left >= 0 + && outset.Top >= 0 + && outset.Right >= 0 + && outset.Bottom >= 0; + + public static OpaqueRenderBoundsContract Map(RenderBoundsContract bounds) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + return new OpaqueRenderBoundsContract(bounds); + } + + public static OpaqueRenderBoundsContract Combine( + Func, Rect> transformBounds, + Func, IReadOnlyList> getRequiredInputBounds) + { + ArgumentNullException.ThrowIfNull(transformBounds); + ArgumentNullException.ThrowIfNull(getRequiredInputBounds); + RenderDescriptionValidation.ValidatePureMetadataCallback(transformBounds, nameof(transformBounds)); + RenderDescriptionValidation.ValidatePureMetadataCallback( + getRequiredInputBounds, + nameof(getRequiredInputBounds)); + return new OpaqueRenderBoundsContract( + OpaqueRenderBoundsKind.Combine, + transformBounds, + getRequiredInputBounds); + } + + public static OpaqueRenderBoundsContract FullInputs( + Func, Rect> transformBounds) + { + ArgumentNullException.ThrowIfNull(transformBounds); + RenderDescriptionValidation.ValidatePureMetadataCallback(transformBounds, nameof(transformBounds)); + return new OpaqueRenderBoundsContract( + OpaqueRenderBoundsKind.FullInputs, + transformBounds, + null); + } + + internal OpaqueRenderBoundsKind Kind { get; } + + internal object StructuralIdentity { get; } + + internal Rect TransformBounds(IReadOnlyList inputBounds) + { + ArgumentNullException.ThrowIfNull(inputBounds); + ValidateRectangles(inputBounds, nameof(inputBounds)); + + Rect result = Kind switch + { + OpaqueRenderBoundsKind.Source when inputBounds.Count == 0 => _sourceBounds, + OpaqueRenderBoundsKind.Source => throw new InvalidOperationException( + "A source bounds contract cannot receive input bounds."), + OpaqueRenderBoundsKind.Map when inputBounds.Count == 1 => _mapBounds.TransformBounds(inputBounds[0]), + OpaqueRenderBoundsKind.Map => throw new InvalidOperationException( + "A map bounds contract requires exactly one input bound."), + OpaqueRenderBoundsKind.Combine or OpaqueRenderBoundsKind.FullInputs => _transformBounds!(inputBounds), + _ => throw new InvalidOperationException("The opaque render bounds contract is invalid."), + }; + + RenderRectValidation.ThrowIfInvalidResult( + result, + "The opaque render bounds forward mapping returned an invalid rectangle."); + return result; + } + + internal IReadOnlyList GetRequiredInputBounds( + Rect requestedOutputBounds, + IReadOnlyList inputBounds) + { + RenderRectValidation.ThrowIfInvalidInput(requestedOutputBounds, nameof(requestedOutputBounds)); + ArgumentNullException.ThrowIfNull(inputBounds); + ValidateRectangles(inputBounds, nameof(inputBounds)); + + if (Kind == OpaqueRenderBoundsKind.Source) + { + if (inputBounds.Count != 0) + throw new InvalidOperationException("A source bounds contract cannot receive input bounds."); + + return Array.Empty(); + } + + bool emptyRequirement = requestedOutputBounds.Width == 0 || requestedOutputBounds.Height == 0; + IReadOnlyList result; + if (Kind == OpaqueRenderBoundsKind.Map) + { + if (inputBounds.Count != 1) + throw new InvalidOperationException("A map bounds contract requires exactly one input bound."); + + Rect required = emptyRequirement + ? Rect.Empty + : _mapBounds.RequiresFullInput + ? inputBounds[0] + : _mapBounds.GetRequiredInputBounds(requestedOutputBounds); + result = [required]; + } + else if (Kind == OpaqueRenderBoundsKind.FullInputs) + { + result = emptyRequirement + ? Enumerable.Repeat(Rect.Empty, inputBounds.Count).ToArray() + : inputBounds.ToArray(); + } + else + { + result = _getRequiredInputBounds!(requestedOutputBounds, inputBounds) + ?? throw new InvalidOperationException("The opaque render bounds backward mapping returned null."); + } + + if (result.Count != inputBounds.Count) + { + throw new InvalidOperationException( + "The opaque render bounds backward mapping must return exactly one rectangle per input."); + } + + ValidateResultRectangles(result); + return result is ReadOnlyCollection ? result : Array.AsReadOnly(result.ToArray()); + } + + internal void ThrowIfIncompatible(OpaqueRenderTopology topology, string parameterName) + { + bool compatible = topology switch + { + OpaqueRenderTopology.Source => Kind == OpaqueRenderBoundsKind.Source, + OpaqueRenderTopology.Map => Kind == OpaqueRenderBoundsKind.Map, + OpaqueRenderTopology.Combine or OpaqueRenderTopology.Expand => + Kind is OpaqueRenderBoundsKind.Combine or OpaqueRenderBoundsKind.FullInputs, + _ => false, + }; + + if (!compatible) + { + throw new ArgumentException( + $"The {Kind} bounds contract is incompatible with {topology} topology.", + parameterName); + } + } + + private static void ValidateRectangles(IReadOnlyList values, string parameterName) + { + for (int index = 0; index < values.Count; index++) + { + if (!RenderRectValidation.IsFiniteNonNegative(values[index])) + { + throw new ArgumentException( + $"Input bound {index} must be finite and have non-negative dimensions.", + parameterName); + } + } + } + + private static void ValidateResultRectangles(IReadOnlyList values) + { + for (int index = 0; index < values.Count; index++) + { + if (!RenderRectValidation.IsFiniteNonNegative(values[index])) + { + throw new InvalidOperationException( + $"The opaque render bounds backward mapping returned an invalid rectangle at index {index}."); + } + } + } +} + +public readonly struct RenderHitTestContract +{ + private readonly RenderHitTestContractKind _kind; + private readonly Func? _hitTest; + private readonly object? _structuralIdentity; + + private RenderHitTestContract(RenderHitTestContractKind kind, object structuralIdentity) + { + _kind = kind; + _hitTest = null; + _structuralIdentity = structuralIdentity; + } + + private RenderHitTestContract( + Func hitTest, + object structuralIdentity) + { + _kind = RenderHitTestContractKind.Custom; + _hitTest = hitTest; + _structuralIdentity = structuralIdentity; + } + + public static RenderHitTestContract None { get; } = new( + RenderHitTestContractKind.None, + RenderHitTestContractKind.None); + + public static RenderHitTestContract OutputBounds { get; } = new( + RenderHitTestContractKind.OutputBounds, + RenderHitTestContractKind.OutputBounds); + + public static RenderHitTestContract AnyInput { get; } = new( + RenderHitTestContractKind.AnyInput, + RenderHitTestContractKind.AnyInput); + + public static RenderHitTestContract Custom( + Func hitTest) + { + ArgumentNullException.ThrowIfNull(hitTest); + RenderDescriptionValidation.ValidatePureMetadataCallback(hitTest, nameof(hitTest)); + return new RenderHitTestContract(hitTest, hitTest.Method); + } + + /// + /// Creates a hit test that reads the resource a call bound to . + /// + /// The raw resource type the slot addresses. + /// A slot the owning definition declares. + /// + /// The pure test, given the bound resource. It must not capture a resource of its own; the slot is + /// resolved against the bindings of the call being tested, so one definition can be reused across + /// recordings that bind different resources. + /// + public static RenderHitTestContract FromSlot( + RenderResourceSlot slot, + Func hitTest) + where T : class + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(hitTest); + RenderDescriptionValidation.ValidatePureMetadataCallback(hitTest, nameof(hitTest)); + return new RenderHitTestContract( + (context, point) => context.UseResource(slot, value => hitTest(value, point)), + hitTest.Method); + } + + /// + /// Creates a hit test that reads the resource a call bound to and also + /// consults the operation's output bounds and inputs. + /// + /// The raw resource type the slot addresses. + /// A slot the owning definition declares. + /// + /// The pure test, given the bound resource and the hit-test context. It must not capture a resource + /// of its own. + /// + public static RenderHitTestContract FromSlot( + RenderResourceSlot slot, + Func hitTest) + where T : class + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(hitTest); + RenderDescriptionValidation.ValidatePureMetadataCallback(hitTest, nameof(hitTest)); + return new RenderHitTestContract( + (context, point) => context.UseResource(slot, value => hitTest(value, context, point)), + hitTest.Method); + } + + internal static RenderHitTestContract FromResource( + RenderResource resource, + Func hitTest) + where T : class + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(hitTest); + return new RenderHitTestContract( + (_, point) => resource.Registry.Use(resource, value => hitTest(value, point)), + hitTest.Method); + } + + internal static RenderHitTestContract FromResource( + RenderResource resource, + Func hitTest) + where T : class + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(hitTest); + return new RenderHitTestContract( + (context, point) => resource.Registry.Use( + resource, + value => hitTest(value, context, point)), + hitTest.Method); + } + + internal RenderHitTestContractKind Kind => _kind; + + internal object StructuralIdentity + { + get + { + ThrowIfNotInitialized(); + return _structuralIdentity!; + } + } + + internal bool Evaluate( + Rect outputBounds, + IReadOnlyList inputs, + IReadOnlyList resources, + Point point) + { + ThrowIfNotInitialized(); + RenderRectValidation.ThrowIfInvalidInput(outputBounds, nameof(outputBounds)); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(resources); + + return _kind switch + { + RenderHitTestContractKind.None => false, + RenderHitTestContractKind.OutputBounds => outputBounds.Contains(point), + RenderHitTestContractKind.AnyInput => inputs.Any(input => input.HitTest(point)), + RenderHitTestContractKind.Custom => + _hitTest!(new RenderHitTestContext(outputBounds, inputs, resources), point), + _ => throw new InvalidOperationException("The hit-test contract is invalid."), + }; + } + + internal void ThrowIfUninitialized(string parameterName) + { + if (_kind == RenderHitTestContractKind.Uninitialized || _structuralIdentity is null) + { + throw new ArgumentException( + "default(RenderHitTestContract) is uninitialized; use None, OutputBounds, AnyInput, or Custom.", + parameterName); + } + } + + private void ThrowIfNotInitialized() + { + if (_kind == RenderHitTestContractKind.Uninitialized || _structuralIdentity is null) + { + throw new InvalidOperationException( + "default(RenderHitTestContract) is uninitialized; use None, OutputBounds, AnyInput, or Custom."); + } + } +} + +public sealed class RenderHitTestContext +{ + private readonly IReadOnlyList _resources; + + internal RenderHitTestContext( + Rect outputBounds, + IReadOnlyList inputs, + IReadOnlyList resources) + { + OutputBounds = outputBounds; + Inputs = inputs is ReadOnlyCollection + ? inputs + : Array.AsReadOnly(inputs.ToArray()); + _resources = resources; + } + + public Rect OutputBounds { get; } + + public IReadOnlyList Inputs { get; } + + /// + /// Reads the resource that the call being hit-tested bound to . + /// + /// The raw resource type the slot addresses. + /// The value the reader produces. + /// A slot the owning definition declares. + /// Reads the bound resource. The raw value must not outlive this call. + /// The call bound no resource to that slot. + public TResult UseResource(RenderResourceSlot slot, Func use) + where T : class + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(use); + + foreach (RenderResourceBinding binding in _resources) + { + if (ReferenceEquals(binding.Slot, slot)) + { + var resource = (RenderResource)binding.Resource; + return resource.Registry.Use(resource, use); + } + } + + throw new KeyNotFoundException( + "No resource was bound to the requested slot for this hit test."); + } +} + +public readonly struct RenderHitTestInput +{ + private readonly Func? _hitTest; + + internal RenderHitTestInput(Rect bounds, Func hitTest) + { + RenderRectValidation.ThrowIfInvalidInput(bounds, nameof(bounds)); + ArgumentNullException.ThrowIfNull(hitTest); + Bounds = bounds; + _hitTest = hitTest; + } + + public Rect Bounds { get; } + + public bool HitTest(Point point) + { + if (_hitTest is null) + throw new InvalidOperationException("The hit-test input is uninitialized."); + + return _hitTest(point); + } +} + +public readonly struct RenderScaleContract +{ + private readonly RenderScaleContractKind _kind; + private readonly Func? _resolve; + private readonly Func? _mapInputSupply; + private readonly Func? _mapOutputDemandToInput; + private readonly object? _structuralIdentity; + + private RenderScaleContract(RenderScaleContractKind kind) + { + _kind = kind; + _resolve = null; + _mapInputSupply = null; + _mapOutputDemandToInput = null; + _structuralIdentity = kind; + } + + private RenderScaleContract(Func resolve, object structuralIdentity) + { + _kind = RenderScaleContractKind.Custom; + _resolve = resolve; + _mapInputSupply = null; + _mapOutputDemandToInput = null; + _structuralIdentity = structuralIdentity; + } + + private RenderScaleContract( + Func mapInputSupply, + object structuralIdentity) + { + _kind = RenderScaleContractKind.MapInputSupply; + _resolve = null; + _mapInputSupply = mapInputSupply; + _mapOutputDemandToInput = null; + _structuralIdentity = new RenderScaleContractStructuralIdentity(_kind, structuralIdentity); + } + + private RenderScaleContract( + Func mapInputSupply, + Func mapOutputDemandToInput, + object structuralIdentity) + { + _kind = RenderScaleContractKind.MapInputSupply; + _resolve = null; + _mapInputSupply = mapInputSupply; + _mapOutputDemandToInput = mapOutputDemandToInput; + _structuralIdentity = new RenderScaleContractStructuralIdentity(_kind, structuralIdentity); + } + + public static RenderScaleContract Vector { get; } = new(RenderScaleContractKind.Vector); + + public static RenderScaleContract PreserveInputSupply { get; } = new(RenderScaleContractKind.PreserveInputSupply); + + public static RenderScaleContract MaterializeAtWorkingScale { get; } = + new(RenderScaleContractKind.MaterializeAtWorkingScale); + + /// + /// Maps both directions of the density relationship of an element-wise one-input operation: the resolved + /// input supply forward to the output supply, and the resolved output demand backward to the input demand. + /// + /// + /// A pure metadata callback that maps the corresponding input supply to the output supply. + /// The callback may return . + /// + /// + /// A pure metadata callback that maps a concrete output demand to the concrete input demand that satisfies + /// it. It must return a finite positive density; the engine bounds the result by the request ceiling. + /// + /// A declarative bidirectional one-input density mapping contract. + /// + /// This is the complete form and the right default for a one-input density map. An operation that enlarges + /// its input lowers its output supply and raises its input demand, so a purely forward map would let an + /// unbounded input rasterize below the density the enlargement consumes. + /// Both callbacks may be evaluated again during graph-wide metadata resolution, so they must remain + /// deterministic and side-effect-free. The backward map is not derived from the forward one: the forward + /// map may collapse to and need not be invertible. + /// + public static RenderScaleContract MapInputSupply( + Func map, + Func mapOutputDemandToInput) + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(mapOutputDemandToInput); + RenderDescriptionValidation.ValidatePureMetadataCallback(map, nameof(map)); + RenderDescriptionValidation.ValidatePureMetadataCallback( + mapOutputDemandToInput, + nameof(mapOutputDemandToInput)); + return new RenderScaleContract( + map, + mapOutputDemandToInput, + new RenderScaleBidirectionalMappingStructuralIdentity( + map.Method, + mapOutputDemandToInput.Method)); + } + + /// + /// Maps the resolved supply metadata of an element-wise one-input operation that consumes its input at the + /// density its own consumer demands, so backward demand passes through unchanged. + /// + /// + /// A pure metadata callback that maps the corresponding input supply to the output supply. + /// The callback may return . + /// + /// A declarative forward-only one-input supply mapping contract. + /// + /// The unchanged demand is the precondition, not a degraded default: it is what a supply map that reports a + /// different density without resampling, or one that collapses to , + /// actually needs. An operation that resamples — an enlargement, a reduction — must use + /// instead, because leaving demand unchanged lets an unbounded input + /// materialize below the density the operation consumes and blurs the result by the resampling factor. + /// The callback may be evaluated again during graph-wide metadata resolution when an upstream fragment has + /// symbolic recording metadata, so it must remain deterministic and side-effect-free. + /// + public static RenderScaleContract MapInputSupplyPreservingDemand( + Func map) + { + ArgumentNullException.ThrowIfNull(map); + RenderDescriptionValidation.ValidatePureMetadataCallback(map, nameof(map)); + return new RenderScaleContract(map, map.Method); + } + + /// + /// Resolves this operation's own concrete supply density from its inputs, output bounds, and the request's + /// output scale and ceiling. + /// + /// + /// A pure metadata callback returning a finite positive density. A throw or an invalid result fails the + /// recording rather than being sanitized to a fallback. + /// + /// A custom supply-resolving contract. + /// + /// A custom resolver declares no backward map, and none can be attached to one: an output demand reaches + /// this operation's inputs unchanged. That is correct only when this operation consumes its inputs at the + /// density its own consumer demands. A one-input operation that resamples must instead use + /// , whose second callback carries the demand back; declaring the density here + /// rather than there lets an unbounded input materialize below the density this operation consumes. + /// + public static RenderScaleContract Custom( + Func resolve) + { + ArgumentNullException.ThrowIfNull(resolve); + RenderDescriptionValidation.ValidatePureMetadataCallback(resolve, nameof(resolve)); + return new RenderScaleContract(resolve, resolve.Method); + } + + internal RenderScaleContractKind Kind => _kind; + + /// + /// Gets whether this contract declares no supply density of its own, so its output resolves to + /// and adopts whatever density its consumer renders at. + /// + /// + /// and the supply-mapping factories can also resolve to + /// , but only for a one-input map, whose supply is its input's rather + /// than the consumer's. Every other kind resolves to a concrete positive density. + /// + internal bool DeclaresNoSupplyDensity => _kind == RenderScaleContractKind.Vector; + + internal object StructuralIdentity + { + get + { + ThrowIfNotInitialized(); + return _structuralIdentity!; + } + } + + internal EffectiveScale Resolve( + IReadOnlyList inputSupplies, + Rect outputBounds, + float outputScale, + float maxWorkingScale) + { + ThrowIfNotInitialized(); + ArgumentNullException.ThrowIfNull(inputSupplies); + RenderRectValidation.ThrowIfInvalidInput(outputBounds, nameof(outputBounds)); + if (!float.IsFinite(outputScale) || outputScale <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(outputScale), outputScale, "Output scale must be positive and finite."); + } + + float ceiling = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + if (_kind == RenderScaleContractKind.Vector) + return EffectiveScale.Unbounded; + + if (_kind == RenderScaleContractKind.PreserveInputSupply) + { + if (inputSupplies.Count != 1) + { + throw new InvalidOperationException( + "PreserveInputSupply requires exactly one corresponding input supply."); + } + + return inputSupplies[0]; + } + + float resolved; + if (_kind == RenderScaleContractKind.MapInputSupply) + { + if (inputSupplies.Count != 1) + { + throw new InvalidOperationException( + "MapInputSupply and MapInputSupplyPreservingDemand require exactly one corresponding input supply."); + } + + EffectiveScale mapped = _mapInputSupply!(inputSupplies[0]); + if (mapped.IsUnbounded) + return EffectiveScale.Unbounded; + + resolved = EffectiveScale.At(mapped.Value).Value; + resolved = MathF.Min(resolved, ceiling); + } + else if (_kind == RenderScaleContractKind.MaterializeAtWorkingScale) + { + resolved = RenderScaleUtilities.ResolveWorkingScale(inputSupplies.ToArray(), outputScale, ceiling); + } + else + { + resolved = _resolve!(new RenderScaleContext( + inputSupplies is ReadOnlyCollection + ? inputSupplies + : Array.AsReadOnly(inputSupplies.ToArray()), + outputBounds, + outputScale, + ceiling)); + if (!float.IsFinite(resolved) || resolved <= 0) + { + throw new InvalidOperationException( + "A custom render scale resolver must return a positive finite value."); + } + + resolved = MathF.Min(resolved, ceiling); + } + + resolved = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(outputBounds, resolved); + if (!float.IsFinite(resolved) || resolved <= 0) + { + throw new InvalidOperationException( + "The resolved render scale cannot produce a positive finite backing density."); + } + + return EffectiveScale.At(resolved); + } + + internal EffectiveScale MapOutputDemandToInput(EffectiveScale outputDemand) + { + ThrowIfNotInitialized(); + if (outputDemand.IsUnbounded) + throw new ArgumentException("Output demand must be concrete.", nameof(outputDemand)); + + EffectiveScale mapped = _kind == RenderScaleContractKind.MapInputSupply + && _mapOutputDemandToInput is not null + ? _mapOutputDemandToInput(outputDemand) + : outputDemand; + if (mapped.IsUnbounded) + { + throw new InvalidOperationException( + "An output-demand mapping must return a concrete positive density."); + } + + return EffectiveScale.At(mapped.Value); + } + + internal void ThrowIfUninitialized(string parameterName) + { + if (_kind == RenderScaleContractKind.Uninitialized || _structuralIdentity is null) + { + throw new ArgumentException( + "default(RenderScaleContract) is uninitialized; use a named or custom contract.", + parameterName); + } + } + + internal void ThrowIfIncompatible(OpaqueRenderTopology topology, string parameterName) + { + ThrowIfUninitialized(parameterName); + if ((_kind is RenderScaleContractKind.PreserveInputSupply or RenderScaleContractKind.MapInputSupply) + && topology != OpaqueRenderTopology.Map) + { + throw new ArgumentException( + "A supply-preserving or supply-mapping contract is valid only for an element-wise one-input opaque map.", + parameterName); + } + } + + private void ThrowIfNotInitialized() + { + if (_kind == RenderScaleContractKind.Uninitialized || _structuralIdentity is null) + { + throw new InvalidOperationException( + "default(RenderScaleContract) is uninitialized; use a named or custom contract."); + } + } +} + +public readonly record struct RenderScaleContext( + IReadOnlyList InputSupplies, + Rect OutputBounds, + float OutputScale, + float MaxWorkingScale); + +public sealed class OpaqueRenderSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + private readonly Func _createOutput; + private readonly Action _publish; + private readonly IReadOnlyList _inputs; + private readonly IReadOnlyList _inputRanges; + private readonly Rect _outputBounds; + private readonly Rect _requiredRegion; + private readonly PixelRect _deviceBounds; + private readonly float _outputScale; + private readonly float _workingScale; + private readonly float _maxWorkingScale; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + + internal OpaqueRenderSession( + RenderExecutionSessionToken token, + IReadOnlyList inputs, + IReadOnlyList inputRanges, + Rect outputBounds, + Rect requiredRegion, + PixelRect deviceBounds, + float outputScale, + float workingScale, + float maxWorkingScale, + RenderIntent intent, + RenderRequestPurpose purpose, + IReadOnlyList resources, + Func createOutput, + Action publish) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(inputRanges); + ArgumentNullException.ThrowIfNull(resources); + ArgumentNullException.ThrowIfNull(createOutput); + ArgumentNullException.ThrowIfNull(publish); + _token = token; + _inputs = Array.AsReadOnly(inputs.ToArray()); + _inputRanges = RenderExecutionInputRange.CopyAndValidate( + _inputs, + inputRanges, + nameof(inputRanges)); + _outputBounds = outputBounds; + _requiredRegion = requiredRegion; + _deviceBounds = deviceBounds; + _outputScale = outputScale; + _workingScale = workingScale; + _maxWorkingScale = maxWorkingScale; + _intent = intent; + _purpose = purpose; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + _createOutput = createOutput; + _publish = publish; + } + + internal RenderExecutionSessionToken Token => _token; + + public IReadOnlyList Inputs + { + get { _token.ThrowIfInactive(); return _inputs; } + } + + /// + /// Gets one stable flattened-input range per authored input handle, including zero-length ranges for handles + /// that produced no runtime values. + /// + public IReadOnlyList InputRanges + { + get { _token.ThrowIfInactive(); return _inputRanges; } + } + + public Rect OutputBounds + { + get { _token.ThrowIfInactive(); return _outputBounds; } + } + + public Rect RequiredRegion + { + get { _token.ThrowIfInactive(); return _requiredRegion; } + } + + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return _deviceBounds; } + } + + public PixelSize DeviceSize + { + get { _token.ThrowIfInactive(); return _deviceBounds.Size; } + } + + public float OutputScale + { + get { _token.ThrowIfInactive(); return _outputScale; } + } + + public float WorkingScale + { + get { _token.ThrowIfInactive(); return _workingScale; } + } + + public float MaxWorkingScale + { + get { _token.ThrowIfInactive(); return _maxWorkingScale; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } + + /// Creates an unpublished output within the declared bounds. + /// The finite non-empty logical output bounds. + /// + /// The optional finite positive density for this output. uses + /// . The executor clamps either value to engine allocation limits. + /// + public OpaqueRenderOutput CreateOutput(Rect logicalBounds, float? density = null) + { + _token.ThrowIfInactive(); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(logicalBounds, nameof(logicalBounds)); + if (density is { } value && (!float.IsFinite(value) || value <= 0)) + { + throw new ArgumentOutOfRangeException( + nameof(density), + density, + "An opaque output density must be finite and positive."); + } + if (!RenderDescriptionValidation.Contains(_outputBounds, logicalBounds)) + { + throw new ArgumentException("An opaque output must be contained by the declared output bounds.", nameof(logicalBounds)); + } + + return _createOutput(this, logicalBounds, density); + } + + public void Publish(OpaqueRenderOutput output) + { + _token.ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(output); + output.Publish(this, _publish); + } + + /// Uses the resource bound to a definition-declared slot. + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + internal void UseResource(RenderResource resource, Action use) + where T : class + { + _token.UseResource(resource, _resources, use); + } + + internal void UseNestedTarget( + RenderResource resource, + Action use) + { + ArgumentNullException.ThrowIfNull(use); + _token.UseResource( + resource, + _resources, + binding => binding.UseImage(_token, use)); + } +} + +public sealed class OpaqueRenderOutput : IDisposable +{ + private readonly RenderExecutionSessionToken _token; + private readonly OpaqueRenderSession _owner; + private readonly Rect _allocationBounds; + private readonly EffectiveScale _effectiveScale; + private readonly RenderCallbackCanvas _canvas; + private readonly Action? _release; + private Rect _bounds; + private OpaqueRenderOutputState _state; + + internal OpaqueRenderOutput( + RenderExecutionSessionToken token, + OpaqueRenderSession owner, + Rect bounds, + EffectiveScale effectiveScale, + RenderCallbackCanvas canvas, + Action? release = null) + { + _token = token; + _owner = owner; + _allocationBounds = bounds; + _bounds = bounds; + _effectiveScale = effectiveScale; + _canvas = canvas; + _release = release; + } + + public Rect Bounds + { + get { ThrowIfUnavailable(); return _bounds; } + } + + public EffectiveScale EffectiveScale + { + get { ThrowIfUnavailable(); return _effectiveScale; } + } + + public RenderCallbackCanvas Canvas + { + get { ThrowIfUnavailable(); return _canvas; } + } + + public void SetOutputBounds(Rect logicalBounds) + { + ThrowIfUnavailable(); + RenderRectValidation.ThrowIfInvalidInput(logicalBounds, nameof(logicalBounds)); + if (!RenderDescriptionValidation.Contains(_allocationBounds, logicalBounds)) + { + throw new ArgumentException( + "Output bounds may only shrink within the allocated output bounds.", + nameof(logicalBounds)); + } + + _bounds = logicalBounds; + } + + public void Discard() + { + ThrowIfUnavailable(); + _state = OpaqueRenderOutputState.Discarded; + _release?.Invoke(this); + } + + public void Dispose() + { + _token.ThrowIfInactive(); + if (_state != OpaqueRenderOutputState.Active) + return; + + _state = OpaqueRenderOutputState.Disposed; + _release?.Invoke(this); + } + + internal void Publish(OpaqueRenderSession owner, Action publish) + { + ThrowIfUnavailable(); + if (!ReferenceEquals(owner, _owner)) + throw new InvalidOperationException("An opaque output belongs to a different execution session."); + + publish(this); + _state = OpaqueRenderOutputState.Published; + } + + private void ThrowIfUnavailable() + { + _token.ThrowIfInactive(); + if (_state != OpaqueRenderOutputState.Active) + throw new InvalidOperationException("The opaque output lease is no longer active."); + } +} + +internal enum OpaqueRenderTopology : byte +{ + Source, + Map, + Combine, + Expand, +} + +internal enum OpaqueRenderBoundsKind : byte +{ + Source, + Map, + Combine, + FullInputs, +} + +internal enum RenderHitTestContractKind : byte +{ + Uninitialized, + None, + OutputBounds, + AnyInput, + Custom, +} + +internal enum RenderScaleContractKind : byte +{ + Uninitialized, + Vector, + PreserveInputSupply, + MapInputSupply, + MaterializeAtWorkingScale, + Custom, +} + +internal enum OpaqueRenderOutputState : byte +{ + Active, + Published, + Discarded, + Disposed, +} + +internal readonly record struct OpaqueRenderBoundsStructuralIdentity( + OpaqueRenderBoundsKind Kind, + object? ForwardIdentity, + object? BackwardIdentity, + object? ExplicitKey); + +internal readonly record struct RenderScaleContractStructuralIdentity( + RenderScaleContractKind Kind, + object CallbackIdentity); + +internal readonly record struct RenderScaleBidirectionalMappingStructuralIdentity( + MethodInfo SupplyMethod, + MethodInfo DemandMethod); + +internal readonly record struct OpaqueRenderStructuralIdentity( + OpaqueRenderTopology Topology, + object DescriptionKey, + RenderDeviceGridSensitivity DeviceGridSensitivity, + RenderBackendBoundary BackendBoundary, + bool HasDirectReplayMaterializationContract, + bool DirectReplayAtExactIntegerReduction, + bool SupportsDirectDstOut); + +internal sealed record EngineOpaqueDefinition( + RenderBackendBoundary BackendBoundary, + MethodInfo ExecuteMethod, + MethodInfo? DirectReplayMethod, + bool DirectReplayAtExactIntegerReduction); + +internal static class RenderDescriptionValidation +{ + /// + /// Binds a definition callback to the values supplied by one operation call. + /// + public static RenderExecutionChannel CreateStateChannel( + TState state, + Action execute, + string stateParameterName, + string executeParameterName) + where TState : notnull + { + ValidateStatePassingCallback(state, execute, stateParameterName, executeParameterName); + return RenderExecutionChannel.FromState(state, execute); + } + + /// + /// Enforces the state-passing rule: every per-recording value reaches the callback through its call state. + /// + public static void ValidateStatePassingCallback( + TState state, + Delegate execute, + string stateParameterName, + string executeParameterName) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(execute, executeParameterName); + + // typeof(TState).IsValueType is a JIT-time constant, so a value-typed state never reaches the + // object-taking checks below and is never boxed on the recording path. + if (!typeof(TState).IsValueType) + { + if (state is null) + throw new ArgumentNullException(stateParameterName); + + ThrowIfExecutionFacadeIdentity(state, stateParameterName); + } + + if (RenderIdentityKeyValidator.CapturesState(execute)) + { + throw new ArgumentException( + $"A definition callback must not capture per-recording values. Move them into '{stateParameterName}' " + + "and pass the callback as static.", + executeParameterName); + } + } + + public static RenderExecutionChannel CreateRequestLocalChannel( + Action execute, + string executeParameterName) + { + ArgumentNullException.ThrowIfNull(execute, executeParameterName); + return RenderExecutionChannel.RequestLocal(execute); + } + + /// + /// A recorded query region is the whole region the operation reports to Measure and ROI, so a hit outside it + /// is a hit no consumer sized itself for. A zero-area region reports nothing, yet every hit-testing kind can + /// still answer true somewhere: because + /// is edge-inclusive and an empty rectangle still holds its own origin, + /// because it delegates to input regions the operation never + /// declared, and because the callback answers for any point at + /// all. Only is confined to an empty region. + /// + public static void ThrowIfQueryContributionIncoherent( + Rect queryBounds, + RenderHitTestContract hitTest, + string parameterName) + { + if ((queryBounds.Width > 0 && queryBounds.Height > 0) + || hitTest.Kind == RenderHitTestContractKind.None) + { + return; + } + + throw new ArgumentException( + "A zero-area queryBounds contributes no query region, so the hit-test contract must be " + + "RenderHitTestContract.None.", + parameterName); + } + + public static void ValidatePureMetadataCallback(Delegate callback, string parameterName) + { + ArgumentNullException.ThrowIfNull(callback); + object? target = callback.Target; + if (target is null) + return; + + ThrowIfExecutionFacadeIdentity(target, parameterName); + RenderIdentityKeyValidator.ThrowIfInvalid(target, parameterName); + + foreach (FieldInfo field in RenderIdentityKeyValidator.GetInstanceFields(target.GetType())) + { + // This runs once per recorded callback per frame, and reading a field whose declared type is + // already fixed and has no subtype would only box a number to accept it again. + if (RenderIdentityKeyValidator.IsSettledCaptureType(field.FieldType)) + continue; + + object? captured = field.GetValue(target); + if (captured is null) + continue; + + // When a lambda is written inside another lambda over the same locals, Roslyn caches the inner + // delegate in the shared closure. That field is the compiler's, not the author's: whatever the + // cached delegate reads is one of the closure's other fields, which this loop checks anyway. + if (captured is Delegate cached && ReferenceEquals(cached.Target, target)) + continue; + + ThrowIfExecutionFacadeIdentity(captured, parameterName); + try + { + RenderIdentityKeyValidator.ThrowIfMutableCapture(captured, parameterName); + } + catch (ArgumentException ex) + { + throw new ArgumentException( + "A pure metadata callback cannot capture a mutable value, resource, execution facade, or disposable object.", + parameterName, + ex); + } + } + } + + public static IReadOnlyList CopyResources( + IEnumerable? resources, + string parameterName) + { + if (resources is null) + return Array.Empty(); + + var result = new List(); + foreach (RenderResource? resource in resources) + { + if (resource is null) + throw new ArgumentException("A declared render resource cannot be null.", parameterName); + if (resource.RegistrationState == RenderResourceRegistrationState.Released) + throw new ArgumentException("A released render resource cannot be declared.", parameterName); + + result.Add(resource); + } + + return result.Count == 0 ? Array.Empty() : result.AsReadOnly(); + } + + public static IReadOnlyList CopyResourceBindings( + IEnumerable? resources, + string parameterName) + { + if (resources is null) + return Array.Empty(); + + var slots = new HashSet(ReferenceEqualityComparer.Instance); + var result = new List(); + foreach (RenderResourceBinding? binding in resources) + { + if (binding is null) + throw new ArgumentException("A declared render resource binding cannot be null.", parameterName); + if (!slots.Add(binding.Slot)) + throw new ArgumentException("A render resource slot cannot be bound more than once.", parameterName); + ThrowIfUndeclarable(binding.Resource, parameterName); + result.Add(binding); + } + + return result.Count == 0 ? Array.Empty() : result.AsReadOnly(); + } + + public static IReadOnlyList CopyResourceSlots( + IEnumerable? slots, + string parameterName) + { + if (slots is null) + return Array.Empty(); + + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var result = new List(); + foreach (RenderResourceSlot? slot in slots) + { + if (slot is null) + throw new ArgumentException("A render resource slot cannot be null.", parameterName); + if (!seen.Add(slot)) + throw new ArgumentException("A render resource slot cannot be declared more than once.", parameterName); + result.Add(slot); + } + + return result.Count == 0 ? Array.Empty() : result.AsReadOnly(); + } + + public static IReadOnlyList ValidateResourceBindings( + IReadOnlyList declaredSlots, + IEnumerable? bindings, + string parameterName) + { + ArgumentNullException.ThrowIfNull(declaredSlots); + IReadOnlyList copy = CopyResourceBindings(bindings, parameterName); + if (declaredSlots.Count != copy.Count) + { + throw new ArgumentException( + "A render call must bind every resource slot declared by its definition exactly once.", + parameterName); + } + + var bySlot = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (RenderResourceBinding binding in copy) + bySlot.Add(binding.Slot, binding); + + var ordered = new RenderResourceBinding[declaredSlots.Count]; + for (int index = 0; index < declaredSlots.Count; index++) + { + RenderResourceSlot slot = declaredSlots[index]; + if (!bySlot.TryGetValue(slot, out RenderResourceBinding? binding)) + { + throw new ArgumentException( + "A render call contains a resource slot that its definition did not declare.", + parameterName); + } + + ordered[index] = binding; + } + + return ordered.Length == 0 ? Array.Empty() : Array.AsReadOnly(ordered); + } + + public static void ThrowIfUndeclarable(RenderResource resource, string parameterName) + { + ArgumentNullException.ThrowIfNull(resource, parameterName); + if (resource.RegistrationState == RenderResourceRegistrationState.Released) + throw new ArgumentException("A released render resource cannot be declared.", parameterName); + } + + public static void ThrowIfFiniteNonEmpty(Rect bounds, string parameterName) + { + RenderRectValidation.ThrowIfInvalidInput(bounds, parameterName); + if (bounds.Width == 0 || bounds.Height == 0) + throw new ArgumentException("Bounds must be non-empty.", parameterName); + } + + public static bool Contains(Rect outer, Rect inner) + => inner.Left >= outer.Left + && inner.Top >= outer.Top + && inner.Right <= outer.Right + && inner.Bottom <= outer.Bottom; + + private static void ThrowIfExecutionFacadeIdentity(object value, string parameterName) + { + if (value is RenderExecutionInput + or RenderCallbackCanvas + or OpaqueRenderSession + or OpaqueRenderOutput + or GeometrySession + or ShaderExecutionContext + or ShaderUniformWriter + or ShaderResourceWriter + or TargetScopeSession + or TargetCommandSession + or RawTargetScopeSession + or RawTargetCommandSession) + { + throw new ArgumentException( + "A persistent identity or pure metadata callback cannot retain an execution session or facade.", + parameterName); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/RenderCallbackCanvas.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderCallbackCanvas.cs new file mode 100644 index 0000000000..c796beb15e --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderCallbackCanvas.cs @@ -0,0 +1,281 @@ +using System.Runtime.ExceptionServices; +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +public sealed class RenderCallbackCanvas +{ + private readonly RenderExecutionSessionToken _token; + private readonly float _density; + private readonly Rect _logicalBounds; + private readonly Point _logicalOrigin; + private readonly PixelRect _deviceBounds; + private readonly PixelPoint _backingDeviceOrigin; + private readonly Rect _rasterBounds; + private readonly Func _openCanvas; + private readonly CallbackCanvasCapability _capability; + private readonly bool _mapLogicalOrigin; + private bool _used; + + internal RenderCallbackCanvas( + RenderExecutionSessionToken token, + float density, + Rect logicalBounds, + Func openCanvas, + CallbackCanvasCapability capability, + bool mapLogicalOrigin = true, + PixelPoint? backingDeviceOrigin = null, + Rect? rasterBounds = null) + : this( + token, + density, + logicalBounds, + PixelRect.FromRect(logicalBounds, density), + openCanvas, + capability, + mapLogicalOrigin, + backingDeviceOrigin, + rasterBounds) + { + } + + internal RenderCallbackCanvas( + RenderExecutionSessionToken token, + float density, + Rect logicalBounds, + PixelRect deviceBounds, + Func openCanvas, + CallbackCanvasCapability capability, + bool mapLogicalOrigin = true, + PixelPoint? backingDeviceOrigin = null, + Rect? rasterBounds = null) + { + ArgumentNullException.ThrowIfNull(token); + if (!float.IsFinite(density) || density <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(density), density, "Callback canvas density must be positive and finite."); + } + + RenderRectValidation.ThrowIfInvalidInput(logicalBounds, nameof(logicalBounds)); + ArgumentNullException.ThrowIfNull(openCanvas); + if (!Enum.IsDefined(capability)) + throw new ArgumentOutOfRangeException(nameof(capability), capability, "The callback capability is invalid."); + + _token = token; + _density = density; + _logicalBounds = logicalBounds; + _rasterBounds = rasterBounds ?? deviceBounds.ToRect(density); + _deviceBounds = ValidateDeviceBounds( + logicalBounds, + density, + deviceBounds, + _rasterBounds); + _backingDeviceOrigin = mapLogicalOrigin + ? _deviceBounds.Position + : backingDeviceOrigin ?? default; + _logicalOrigin = _rasterBounds.Position; + _openCanvas = openCanvas; + _capability = capability; + _mapLogicalOrigin = mapLogicalOrigin; + } + + internal static RenderCallbackCanvas CreateTargetAttached( + RenderExecutionSessionToken token, + Rect logicalBounds, + ImmediateCanvas destination, + CallbackCanvasCapability capability) + { + ArgumentNullException.ThrowIfNull(destination); + Vector deviceGridOffset = DeviceGridAlignment.ResolveLogicalOffset(destination); + PixelRect deviceBounds = PixelRect.FromRect( + logicalBounds.Translate(deviceGridOffset), + destination.Density); + Rect rasterBounds = deviceBounds + .ToRect(destination.Density) + .Translate(-deviceGridOffset); + return new RenderCallbackCanvas( + token, + destination.Density, + logicalBounds, + deviceBounds, + destination.CreateExecutionView, + capability, + mapLogicalOrigin: false, + backingDeviceOrigin: destination.DeviceOrigin, + rasterBounds: rasterBounds); + } + + public float Density + { + get { _token.ThrowIfInactive(); return _density; } + } + + public Rect LogicalBounds + { + get { _token.ThrowIfInactive(); return _logicalBounds; } + } + + public Point LogicalOrigin + { + get { _token.ThrowIfInactive(); return _logicalOrigin; } + } + + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return _deviceBounds; } + } + + /// + /// Gets the translation from callback-local coordinates to the composition-device grid used + /// to round . + /// + public Vector DeviceGridOffset + { + get + { + _token.ThrowIfInactive(); + return new Vector( + (_deviceBounds.X / _density) - _rasterBounds.X, + (_deviceBounds.Y / _density) - _rasterBounds.Y); + } + } + + /// + /// Gets the pixel-aligned logical footprint of the backing target. The footprint can + /// conservatively extend beyond because of device-pixel rounding. + /// + public Rect RasterBounds + { + get { _token.ThrowIfInactive(); return _rasterBounds; } + } + + internal PixelPoint DeviceOriginUnchecked => _backingDeviceOrigin; + + public void Use(Action draw) + { + _token.ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(draw); + if (_used) + throw new InvalidOperationException("A callback canvas facade can be used only once."); + + _used = true; + ImmediateCanvas canvas = _openCanvas() + ?? throw new InvalidOperationException("The callback canvas provider returned null."); + ExceptionDispatchInfo? primaryFailure = null; + bool entered = false; + try + { + _token.EnterCanvas(canvas, this); + entered = true; + canvas.ConfigureExecutionCallback(_token, _capability); + // Every branch bounds the callback to its own footprint rather than describing content, so + // each one rounds outward: the rect is stated in callback-local units, and a scope replayed + // under a shrinking transform maps it to a sub-pixel span the device grid would snap away. + if (_mapLogicalOrigin) + { + canvas.PushTransform(Matrix.CreateTranslation(-_logicalOrigin.X, -_logicalOrigin.Y)); + canvas.ClipRectCoveringDevicePixels(_rasterBounds); + } + else if (_capability is CallbackCanvasCapability.TargetScope + or CallbackCanvasCapability.TargetCommandFull) + { + canvas.ClipRectCoveringDevicePixels( + RenderScaleUtilities.AddRasterApron(_deviceBounds) + .ToRect(_density) + .Translate(-DeviceGridOffset)); + } + else + { + canvas.ClipRectCoveringDevicePixels(_logicalBounds); + } + canvas.PinExecutionCallbackState(); + draw(canvas); + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + try + { + canvas.CloseWithoutFlush(); + } + catch when (primaryFailure is not null) + { + // The callback failure remains primary; canvas cleanup is best-effort on this path. + } + finally + { + if (entered) + _token.ExitCanvas(canvas); + } + } + + primaryFailure?.Throw(); + } + + private static PixelRect ValidateDeviceBounds( + Rect bounds, + float density, + PixelRect deviceBounds, + Rect rasterBounds) + { + bool logicalBoundsAreEmpty = bounds.Width == 0 || bounds.Height == 0; + if (deviceBounds.Width < 0 + || deviceBounds.Height < 0 + || (!logicalBoundsAreEmpty && (deviceBounds.Width == 0 || deviceBounds.Height == 0))) + { + throw new ArgumentException( + "A non-empty callback canvas requires non-empty device bounds.", + nameof(deviceBounds)); + } + + Vector deviceGridOffset = new( + (deviceBounds.X / density) - rasterBounds.X, + (deviceBounds.Y / density) - rasterBounds.Y); + PixelRect semanticDeviceBounds = PixelRect.FromRect( + bounds.Translate(deviceGridOffset), + density); + if (!DeviceBoundsValidation.MatchesExtent(rasterBounds.Width, density, deviceBounds.Width) + || !DeviceBoundsValidation.MatchesExtent(rasterBounds.Height, density, deviceBounds.Height) + || deviceBounds.X > semanticDeviceBounds.X + || deviceBounds.Y > semanticDeviceBounds.Y + || deviceBounds.Right < semanticDeviceBounds.Right + || deviceBounds.Bottom < semanticDeviceBounds.Bottom) + { + throw new ArgumentException( + "Callback canvas raster bounds must match the backing size and contain its logical bounds.", + nameof(deviceBounds)); + } + + return deviceBounds; + } +} + +internal enum CallbackCanvasCapability : byte +{ + Draw, + TargetScope, + TargetCommandFull, + TargetCommandRegion, + TargetCommandEmpty, +} + +internal static class DeviceBoundsValidation +{ + public static bool MatchesExtent(float rasterExtent, float density, int deviceExtent) + { + float reconstructed = rasterExtent * density; + if (!float.IsFinite(reconstructed)) + return false; + + float expected = deviceExtent; + float ulp = Math.Max( + Math.Abs(MathF.BitIncrement(expected) - expected), + Math.Abs(expected - MathF.BitDecrement(expected))); + float tolerance = Math.Min(0.75f, Math.Max(0.0001f, ulp * 2f)); + return Math.Abs((double)reconstructed - deviceExtent) <= tolerance; + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs new file mode 100644 index 0000000000..28ec4391eb --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderDefinitionCalls.cs @@ -0,0 +1,585 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Defines the fixed shape of an opaque render operation. +/// +/// The per-recording state supplied by an . +/// +/// A definition contains only operation shape: its callback, metadata contracts, and planner traits. Values that +/// affect pixels belong to a call. When those values change, the owning must set +/// before its next request. +/// +public sealed class OpaqueRenderDefinition + where TState : notnull +{ + private readonly Action _execute; + private readonly OpaqueRenderBoundsContract _bounds; + private readonly RenderHitTestContract _hitTest; + private readonly RenderValueCardinality _valueCardinality; + private readonly RenderScaleContract _scale; + private readonly RenderDeviceGridSensitivity _deviceGridSensitivity; + private readonly IReadOnlyList _inputReadbacks; + private readonly IReadOnlyList _resourceSlots; + private readonly RenderInputDemandContract _inputDemand; + + private OpaqueRenderDefinition( + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + IReadOnlyList inputReadbacks, + IReadOnlyList resourceSlots, + RenderInputDemandContract inputDemand) + { + _execute = execute; + _inputDemand = inputDemand; + _bounds = bounds; + _hitTest = hitTest; + _valueCardinality = valueCardinality; + _scale = scale; + _deviceGridSensitivity = deviceGridSensitivity; + _inputReadbacks = inputReadbacks; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable opaque-operation definition. + /// + /// declares what density each input has to reach for this operation's own + /// resolved output demand. Only a combine or an expand may declare one, and it is what an operation that + /// resamples its inputs asymmetrically needs: without it every input is asked for the unchanged output + /// demand, so an unbounded input feeding an enlargement materializes below the density that enlargement + /// consumes. + /// + public static OpaqueRenderDefinition Create( + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + RenderInputDemandContract inputDemand = default) + { + ArgumentNullException.ThrowIfNull(execute); + ArgumentNullException.ThrowIfNull(bounds); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + valueCardinality.ThrowIfUninitialized(nameof(valueCardinality)); + scale.ThrowIfUninitialized(nameof(scale)); + if (!Enum.IsDefined(deviceGridSensitivity)) + throw new ArgumentOutOfRangeException(nameof(deviceGridSensitivity)); + + return new OpaqueRenderDefinition( + execute, + bounds, + hitTest, + valueCardinality, + scale, + deviceGridSensitivity, + inputReadbacks?.ToArray() ?? [], + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources)), + inputDemand); + } + + /// Binds this operation shape to the state and resources for one recording. + public OpaqueRenderCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal OpaqueRenderDescription CreateDescription( + TState state, + IEnumerable? bindings) + => OpaqueRenderDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _execute, + nameof(state), + nameof(_execute)), + _bounds, + _hitTest, + _valueCardinality, + _scale, + _deviceGridSensitivity, + definitionFingerprint: _execute.Method, + inputReadbacks: _inputReadbacks, + resources: RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings)), + inputDemand: _inputDemand); +} + +/// Binds one opaque-operation definition to one recording's state and resource tokens. +public sealed class OpaqueRenderCall + where TState : notnull +{ + internal OpaqueRenderCall( + OpaqueRenderDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public OpaqueRenderDefinition Definition { get; } + + /// Gets the state supplied for this recording. + public TState State { get; } + + internal OpaqueRenderDescription Description { get; } +} + +/// Defines the fixed shape of a guarded target-scope operation. +/// The per-recording state supplied by a . +public sealed class TargetScopeDefinition + where TState : notnull +{ + private readonly Action _execute; + private readonly RenderBoundsContract _bounds; + private readonly RenderHitTestContract _hitTest; + private readonly RenderScaleContract _scale; + private readonly RenderDeviceGridSensitivity _deviceGridSensitivity; + private readonly RenderDeviceGridMapping _deviceGridMapping; + private readonly RenderScopeTransformSpace _transformSpace; + private readonly IReadOnlyList _resourceSlots; + + private TargetScopeDefinition( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + RenderDeviceGridMapping deviceGridMapping, + RenderScopeTransformSpace transformSpace, + IReadOnlyList resourceSlots) + { + _execute = execute; + _bounds = bounds; + _hitTest = hitTest; + _scale = scale; + _deviceGridSensitivity = deviceGridSensitivity; + _deviceGridMapping = deviceGridMapping; + _transformSpace = transformSpace; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable guarded target-scope definition. + /// + /// The space the callback's replay transform is defined in. The default assumes the ambient target + /// transform, which carries the scope's own scale; declare + /// when the callback transforms its input in the + /// input's own coordinates, so 's backward map reaches it. + /// + public static TargetScopeDefinition Create( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + RenderDeviceGridMapping deviceGridMapping = RenderDeviceGridMapping.Remapped, + RenderScopeTransformSpace transformSpace = RenderScopeTransformSpace.AmbientTarget, + IEnumerable? resources = null) + { + ArgumentNullException.ThrowIfNull(execute); + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + if (!Enum.IsDefined(deviceGridSensitivity)) + throw new ArgumentOutOfRangeException(nameof(deviceGridSensitivity)); + if (!Enum.IsDefined(deviceGridMapping)) + throw new ArgumentOutOfRangeException(nameof(deviceGridMapping)); + if (!Enum.IsDefined(transformSpace)) + throw new ArgumentOutOfRangeException(nameof(transformSpace)); + + return new TargetScopeDefinition( + execute, + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + transformSpace, + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources))); + } + + /// Binds this operation shape to the state and resources for one recording. + public TargetScopeCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal TargetScopeDescription CreateDescription( + TState state, + IEnumerable? bindings) + => TargetScopeDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _execute, + nameof(state), + nameof(_execute)), + _bounds, + _hitTest, + _scale, + _deviceGridSensitivity, + _deviceGridMapping, + definitionFingerprint: _execute.Method, + resources: RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings)), + isValueReplayMap: false, + _transformSpace); +} + +/// Binds one guarded target-scope definition to one recording's state and resource tokens. +public sealed class TargetScopeCall + where TState : notnull +{ + internal TargetScopeCall( + TargetScopeDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public TargetScopeDefinition Definition { get; } + + /// Gets the state supplied for this recording. + public TState State { get; } + + internal TargetScopeDescription Description { get; } +} + +/// Defines the fixed shape of a guarded target-command operation. +/// The per-recording state supplied by a . +public sealed class TargetCommandDefinition + where TState : notnull +{ + private readonly Action _execute; + private readonly TargetRegion _affectedRegion; + private readonly Rect _queryBounds; + private readonly RenderHitTestContract _hitTest; + private readonly TargetAccess _access; + private readonly IReadOnlyList _inputReadbacks; + private readonly RenderInputDemandContract _inputDemand; + private readonly IReadOnlyList _resourceSlots; + + private TargetCommandDefinition( + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access, + IReadOnlyList inputReadbacks, + RenderInputDemandContract inputDemand, + IReadOnlyList resourceSlots) + { + _execute = execute; + _affectedRegion = affectedRegion; + _queryBounds = queryBounds; + _hitTest = hitTest; + _access = access; + _inputReadbacks = inputReadbacks; + _inputDemand = inputDemand; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable guarded target-command definition. + public static TargetCommandDefinition Create( + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access = TargetAccess.ReadWrite, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + RenderInputDemandContract inputDemand = default) + { + ArgumentNullException.ThrowIfNull(execute); + affectedRegion.ThrowIfUninitialized(nameof(affectedRegion)); + RenderRectValidation.ThrowIfInvalidInput(queryBounds, nameof(queryBounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + RenderDescriptionValidation.ThrowIfQueryContributionIncoherent( + queryBounds, + hitTest, + nameof(hitTest)); + if (!Enum.IsDefined(access)) + throw new ArgumentOutOfRangeException(nameof(access)); + if (access == TargetAccess.Readback && affectedRegion.Kind == TargetRegionKind.Empty) + { + throw new ArgumentException( + "A readback command requires a non-empty target region.", + nameof(affectedRegion)); + } + + return new TargetCommandDefinition( + execute, + affectedRegion, + queryBounds, + hitTest, + access, + inputReadbacks?.ToArray() ?? [], + inputDemand, + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources))); + } + + /// Binds this operation shape to the state and resources for one recording. + public TargetCommandCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal TargetCommandDescription CreateDescription( + TState state, + IEnumerable? bindings) + => TargetCommandDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _execute, + nameof(state), + nameof(_execute)), + _affectedRegion, + _queryBounds, + _hitTest, + _access, + _inputReadbacks, + definitionFingerprint: _execute.Method, + inputDemand: _inputDemand, + resources: RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings))); +} + +/// Binds one guarded target-command definition to one recording's state and resource tokens. +public sealed class TargetCommandCall + where TState : notnull +{ + internal TargetCommandCall( + TargetCommandDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public TargetCommandDefinition Definition { get; } + + /// Gets the state supplied for this recording. + public TState State { get; } + + internal TargetCommandDescription Description { get; } +} + +/// Defines the fixed shape of an opaque external target scope. +/// +/// Raw target work is intentionally never eligible for persistent output reuse. Its definition still declares +/// metadata and resource slots so the invocation can be validated before execution. +/// +public sealed class RawTargetScopeDefinition + where TState : notnull +{ + private readonly Action _execute; + private readonly RenderBoundsContract _bounds; + private readonly RenderHitTestContract _hitTest; + private readonly RenderScaleContract _scale; + private readonly IReadOnlyList _resourceSlots; + + private RawTargetScopeDefinition( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + IReadOnlyList resourceSlots) + { + _execute = execute; + _bounds = bounds; + _hitTest = hitTest; + _scale = scale; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable raw target-scope definition. + public static RawTargetScopeDefinition Create( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + IEnumerable? resources = null) + { + ArgumentNullException.ThrowIfNull(execute); + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + return new RawTargetScopeDefinition( + execute, + bounds, + hitTest, + scale, + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources))); + } + + /// Binds this raw scope to one recording's callback state and resource tokens. + public RawTargetScopeCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal RawTargetScopeDescription CreateDescription( + TState state, + IEnumerable? bindings) + => RawTargetScopeDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _execute, + nameof(state), + nameof(_execute)), + _bounds, + _hitTest, + _scale, + _execute.Method, + RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings))); +} + +/// Binds one raw target-scope definition to one recording. +public sealed class RawTargetScopeCall + where TState : notnull +{ + internal RawTargetScopeCall( + RawTargetScopeDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public RawTargetScopeDefinition Definition { get; } + + /// Gets the callback state supplied for this recording. + public TState State { get; } + + internal RawTargetScopeDescription Description { get; } +} + +/// Defines the fixed shape of an opaque external target command. +/// +/// Raw target work is intentionally never eligible for persistent output reuse. Its definition still declares +/// metadata and resource slots so the invocation can be validated before execution. +/// +public sealed class RawTargetCommandDefinition + where TState : notnull +{ + private readonly Action _execute; + private readonly Rect _queryBounds; + private readonly RenderHitTestContract _hitTest; + private readonly IReadOnlyList _resourceSlots; + + private RawTargetCommandDefinition( + Action execute, + Rect queryBounds, + RenderHitTestContract hitTest, + IReadOnlyList resourceSlots) + { + _execute = execute; + _queryBounds = queryBounds; + _hitTest = hitTest; + _resourceSlots = resourceSlots; + } + + /// Creates an immutable raw target-command definition. + public static RawTargetCommandDefinition Create( + Action execute, + Rect queryBounds, + RenderHitTestContract hitTest, + IEnumerable? resources = null) + { + ArgumentNullException.ThrowIfNull(execute); + RenderRectValidation.ThrowIfInvalidInput(queryBounds, nameof(queryBounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + if (hitTest.Kind == RenderHitTestContractKind.AnyInput) + { + throw new ArgumentException( + "A raw target command has no logical value inputs and cannot use AnyInput hit testing.", + nameof(hitTest)); + } + RenderDescriptionValidation.ThrowIfQueryContributionIncoherent( + queryBounds, + hitTest, + nameof(hitTest)); + return new RawTargetCommandDefinition( + execute, + queryBounds, + hitTest, + RenderDescriptionValidation.CopyResourceSlots(resources, nameof(resources))); + } + + /// Binds this raw command to one recording's callback state and resource tokens. + public RawTargetCommandCall Call( + TState state, + IEnumerable? bindings = null) + => new(this, state, bindings); + + internal RawTargetCommandDescription CreateDescription( + TState state, + IEnumerable? bindings) + => RawTargetCommandDescription.CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + _execute, + nameof(state), + nameof(_execute)), + _queryBounds, + _hitTest, + _execute.Method, + RenderDescriptionValidation.ValidateResourceBindings( + _resourceSlots, + bindings, + nameof(bindings))); +} + +/// Binds one raw target-command definition to one recording. +public sealed class RawTargetCommandCall + where TState : notnull +{ + internal RawTargetCommandCall( + RawTargetCommandDefinition definition, + TState state, + IEnumerable? bindings) + { + ArgumentNullException.ThrowIfNull(definition); + Definition = definition; + State = state; + Description = definition.CreateDescription(state, bindings); + } + + /// Gets the immutable operation shape. + public RawTargetCommandDefinition Definition { get; } + + /// Gets the callback state supplied for this recording. + public TState State { get; } + + internal RawTargetCommandDescription Description { get; } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/RenderExecutionBinding.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderExecutionBinding.cs new file mode 100644 index 0000000000..63dd13c4d6 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderExecutionBinding.cs @@ -0,0 +1,77 @@ +using System.Reflection; + +namespace Beutl.Graphics.Rendering; + +/// +/// The channel through which a description's per-frame values reach its deferred execution callback. +/// +/// +/// It carries callback state only. Persistent-output reuse is governed by the owning node's +/// lifecycle rather than by callback values. +/// +internal readonly struct RenderExecutionChannel +{ + private readonly Action? _execute; + + private readonly RenderExecutionBinding? _binding; + + private RenderExecutionChannel(Action? execute, RenderExecutionBinding? binding) + { + _execute = execute; + _binding = binding; + } + + /// Gets the callback method used to derive an internal definition fingerprint. + internal MethodInfo Method => _execute is not null ? _execute.Method : Binding.Method; + + private RenderExecutionBinding Binding + => _binding ?? throw new InvalidOperationException("The execution channel has no state binding."); + + internal static RenderExecutionChannel FromState( + TState state, + Action execute) + where TState : notnull + => new(null, new StateRenderExecutionBinding(state, execute)); + + internal static RenderExecutionChannel RequestLocal(Action execute) + => new(execute, null); + + internal void Invoke(TSession session) + { + if (_execute is not null) + _execute(session); + else + Binding.Invoke(session); + } +} + +internal abstract class RenderExecutionBinding +{ + internal abstract MethodInfo Method { get; } + + internal abstract void Invoke(TSession session); +} + +/// +/// Invokes a non-capturing callback with its call-owned state. +/// +/// +/// The state is stored in its own field so a value-typed state is never boxed while the binding is recorded or +/// invoked. A node must set before a changed state can alter cached output. +/// +internal sealed class StateRenderExecutionBinding : RenderExecutionBinding + where TState : notnull +{ + private readonly TState _state; + private readonly Action _execute; + + internal StateRenderExecutionBinding(TState state, Action execute) + { + _state = state; + _execute = execute; + } + + internal override MethodInfo Method => _execute.Method; + + internal override void Invoke(TSession session) => _execute(session, _state); +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/RenderInputReadback.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderInputReadback.cs new file mode 100644 index 0000000000..2f7e706013 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderInputReadback.cs @@ -0,0 +1,141 @@ +namespace Beutl.Graphics.Rendering; + +/// Selects which runtime values from one authored render input require CPU readback. +public readonly struct RenderInputReadback : IEquatable +{ + private readonly RenderInputReadbackKind _kind; + private readonly IReadOnlyList? _valueIndices; + + private RenderInputReadback(RenderInputReadbackKind kind, IReadOnlyList valueIndices) + { + _kind = kind; + _valueIndices = valueIndices; + } + + /// Does not schedule CPU readback for any runtime value from the authored input. + public static RenderInputReadback None { get; } = new( + RenderInputReadbackKind.None, + Array.AsReadOnly(Array.Empty())); + + /// Schedules CPU readback for every runtime value produced by the authored input. + public static RenderInputReadback All { get; } = new( + RenderInputReadbackKind.All, + Array.AsReadOnly(Array.Empty())); + + /// Gets whether every runtime value produced by the authored input requires CPU readback. + public bool ReadsAllValues => _kind == RenderInputReadbackKind.All; + + /// Gets the sorted local runtime-value indices selected by . + public IReadOnlyList ValueIndices => _valueIndices ?? Array.Empty(); + + /// Selects finite local runtime-value indices from one authored input. + public static RenderInputReadback Values(IEnumerable valueIndices) + { + ArgumentNullException.ThrowIfNull(valueIndices); + int[] result = valueIndices.ToArray(); + if (result.Length == 0) + throw new ArgumentException("At least one input value index is required.", nameof(valueIndices)); + if (result.Any(static index => index < 0)) + { + throw new ArgumentOutOfRangeException( + nameof(valueIndices), + "Input value indices must be non-negative."); + } + + Array.Sort(result); + for (int index = 1; index < result.Length; index++) + { + if (result[index] == result[index - 1]) + throw new ArgumentException("Input value indices must be unique.", nameof(valueIndices)); + } + + return new RenderInputReadback( + RenderInputReadbackKind.Values, + Array.AsReadOnly(result)); + } + + public bool Equals(RenderInputReadback other) + => _kind == other._kind && ValueIndices.SequenceEqual(other.ValueIndices); + + public override bool Equals(object? obj) + => obj is RenderInputReadback other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(_kind); + foreach (int valueIndex in ValueIndices) + hash.Add(valueIndex); + return hash.ToHashCode(); + } + + public static bool operator ==(RenderInputReadback left, RenderInputReadback right) + => left.Equals(right); + + public static bool operator !=(RenderInputReadback left, RenderInputReadback right) + => !left.Equals(right); + + internal bool RequiresAnyReadback => _kind is RenderInputReadbackKind.All or RenderInputReadbackKind.Values; + + internal int StructuralKind => (int)_kind; + + internal bool RequiresValue(int localIndex) + => _kind == RenderInputReadbackKind.All + || (_kind == RenderInputReadbackKind.Values && BinarySearch(ValueIndices, localIndex) >= 0); + + internal void ThrowIfUninitialized(string parameterName) + { + if (_kind == RenderInputReadbackKind.Uninitialized) + { + throw new ArgumentException( + "default(RenderInputReadback) is uninitialized; use None, All, or Values.", + parameterName); + } + } + + internal void ValidateRuntimeCount( + RenderValueCardinality cardinality, + int valueCount) + { + if (_kind != RenderInputReadbackKind.Values) + return; + + foreach (int valueIndex in ValueIndices) + { + bool isImpossible = cardinality.Maximum is { } maximum && valueIndex >= maximum; + bool isGuaranteedButMissing = valueIndex < cardinality.Minimum && valueIndex >= valueCount; + if (isImpossible || isGuaranteedButMissing) + { + throw new InvalidOperationException( + "A render operation declared readback for a local input value index that was not produced at runtime."); + } + } + } + + private static int BinarySearch(IReadOnlyList values, int value) + { + int lower = 0; + int upper = values.Count - 1; + while (lower <= upper) + { + int middle = lower + ((upper - lower) / 2); + int current = values[middle]; + if (current == value) + return middle; + if (current < value) + lower = middle + 1; + else + upper = middle - 1; + } + + return -1; + } +} + +internal enum RenderInputReadbackKind : byte +{ + Uninitialized, + None, + All, + Values, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/RenderResource.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderResource.cs new file mode 100644 index 0000000000..d6dccb11cb --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/RenderResource.cs @@ -0,0 +1,594 @@ +using System.Runtime.CompilerServices; + +namespace Beutl.Graphics.Rendering; + +/// +/// Represents a declaration-owned resource address. +/// +/// +/// This non-generic base exists only so a definition can declare a heterogeneous set of typed slots. +/// It does not expose a raw resource type or value to callbacks. +/// +public abstract class RenderResourceSlot +{ + internal RenderResourceSlot() + { + } + + internal abstract Type ValueType { get; } + + internal abstract bool Accepts(RenderResource resource); +} + +/// +/// Declares one typed resource address for a reusable render definition. +/// +/// The raw resource type leased to the execution callback. +public sealed class RenderResourceSlot : RenderResourceSlot + where T : class +{ + /// Initializes a resource slot. + public RenderResourceSlot() + { + } + + /// Binds this declared slot to a resource token from the active render context. + /// The request-scoped resource token to bind. + /// A binding suitable for a call of the definition that declares this slot. + public RenderResourceBinding Bind(RenderResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + resource.Registry.ValidateBinding(resource); + return new RenderResourceBinding(this, resource); + } + + internal override Type ValueType => typeof(T); + + internal override bool Accepts(RenderResource resource) + => resource is RenderResource; +} + +/// +/// Binds a definition-declared resource slot to a request-scoped resource token. +/// +/// +/// Bindings can only be created by , which +/// prevents pairing a slot with a fabricated or differently typed token. +/// +public sealed class RenderResourceBinding +{ + internal RenderResourceBinding(RenderResourceSlot slot, RenderResource resource) + { + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(resource); + if (!slot.Accepts(resource)) + { + throw new ArgumentException( + "A render resource binding must use a token whose type matches its slot.", + nameof(resource)); + } + + Slot = slot; + Resource = resource; + } + + internal RenderResourceSlot Slot { get; } + + internal RenderResource Resource { get; } + + internal static RenderResourceBinding CreateEngineBinding(RenderResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + resource.Registry.ValidateBinding(resource); + return new RenderResourceBinding(new EngineRenderResourceSlot(resource.ValueType), resource); + } +} + +/// +/// Identifies a request-scoped resource without exposing its raw value. +/// +public abstract class RenderResource +{ + private RenderResourceRegistration? _slot; + private RenderResourceOwnershipState _terminalState; + + internal RenderResource(RenderRequestResourceRegistry registry, RenderResourceRegistration slot) + { + Registry = registry; + _slot = slot; + } + + internal RenderRequestResourceRegistry Registry { get; } + + internal abstract Type ValueType { get; } + + internal RenderResourceRegistration Slot => GetActiveSlot(); + + internal object SlotIdentity => GetActiveSlot(); + + internal RenderResourceOwnershipState OwnershipState => _slot?.State ?? _terminalState; + + internal RenderResourceRegistrationState RegistrationState { get; set; } + + internal void Detach(RenderResourceOwnershipState terminalState) + { + _terminalState = terminalState; + _slot = null; + } + + private RenderResourceRegistration GetActiveSlot() + => _slot ?? throw new InvalidOperationException( + "A released render resource no longer retains its request-scoped slot."); +} + +/// +/// Identifies a typed request-scoped resource without publicly exposing its raw value. +/// +/// The raw resource type. +public sealed class RenderResource : RenderResource + where T : class +{ + internal RenderResource(RenderRequestResourceRegistry registry, RenderResourceRegistration slot) + : base(registry, slot) + { + } + + internal override Type ValueType => typeof(T); +} + +internal sealed class EngineRenderResourceSlot(Type valueType) : RenderResourceSlot +{ + private readonly Type _valueType = valueType ?? throw new ArgumentNullException(nameof(valueType)); + + internal override Type ValueType => _valueType; + + internal override bool Accepts(RenderResource resource) => true; +} + +internal sealed class RenderRequestResourceRegistry : IDisposable +{ + private readonly Dictionary> _slotsByRawValue = + new(ReferenceEqualityComparer.Instance); + private readonly ConditionalWeakTable _ownedTombstones = new(); + private readonly ConditionalWeakTable _borrowedTombstones = new(); + private readonly List _slots = []; + private bool _disposed; + + public RenderResource RegisterOwned(T value) + where T : class, IDisposable + { + ArgumentNullException.ThrowIfNull(value); + ThrowIfDisposed(); + + if (_ownedTombstones.TryGetValue(value, out _)) + { + throw new InvalidOperationException( + "The raw resource was already transferred to this request family and cannot be registered again."); + } + + if (_borrowedTombstones.TryGetValue(value, out _)) + { + throw new InvalidOperationException( + "The raw resource was already borrowed by this request family and cannot later transfer ownership."); + } + + if (_slotsByRawValue.TryGetValue(value, out List? registrations) + && registrations.Count > 0) + { + throw new InvalidOperationException( + "The raw resource is already registered. Duplicate ownership and Own/Borrow mixtures are forbidden."); + } + + RenderResourceRegistration slot = CreateSlot( + value, + RenderResourceOwnershipMode.Owned); + _ownedTombstones.Add(value, OwnedResourceTombstone.Instance); + return CreateToken(slot); + } + + public RenderResource RegisterBorrowed(T value) + where T : class + { + ArgumentNullException.ThrowIfNull(value); + ThrowIfDisposed(); + + if (_ownedTombstones.TryGetValue(value, out _)) + { + throw new InvalidOperationException( + "The raw resource was already transferred to this request family and cannot be borrowed."); + } + + if (_slotsByRawValue.TryGetValue(value, out List? registrations)) + { + if (registrations.Any(static slot => slot.Mode == RenderResourceOwnershipMode.Owned)) + { + throw new InvalidOperationException( + "The raw resource is already owned by this request family and cannot also be borrowed."); + } + } + + RenderResourceRegistration created = CreateSlot( + value, + RenderResourceOwnershipMode.Borrowed); + RenderResource createdToken = CreateToken(created); + MarkBorrowed(value); + return createdToken; + } + + public void Commit(RenderResource resource) + { + EnsureRegistered(resource); + if (resource.RegistrationState != RenderResourceRegistrationState.Pending) + { + throw new InvalidOperationException("Only a pending resource registration can be committed."); + } + + resource.RegistrationState = RenderResourceRegistrationState.Committed; + resource.Slot.PendingRegistrations--; + resource.Slot.CommittedRegistrations++; + resource.Slot.UpdateStableState(); + } + + public void Rollback(RenderResource resource) + { + EnsureRegistered(resource); + if (resource.RegistrationState == RenderResourceRegistrationState.Released) + { + return; + } + + if (resource.RegistrationState != RenderResourceRegistrationState.Pending) + { + throw new InvalidOperationException("Only a pending resource registration can be rolled back."); + } + + ReleaseCore(resource); + } + + public TResult Use(RenderResource resource, Func use) + where T : class + { + ArgumentNullException.ThrowIfNull(use); + return UseUntyped(resource, value => use((T)value)); + } + + internal TResult UseUntyped(RenderResource resource, Func use) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(use); + EnsureCommitted(resource); + + RenderResourceRegistration slot = resource.Slot; + if (slot.State == RenderResourceOwnershipState.LeasedToCallback) + { + throw new InvalidOperationException("A render resource cannot be leased by nested callbacks."); + } + + RenderResourceOwnershipState returnState = slot.State; + slot.State = RenderResourceOwnershipState.LeasedToCallback; + try + { + return use(slot.RawValue); + } + finally + { + if (slot.State == RenderResourceOwnershipState.LeasedToCallback) + { + slot.State = returnState; + } + } + } + + public T TransferOwned(RenderResource resource) + where T : class, IDisposable + { + EnsureCommitted(resource); + RenderResourceRegistration slot = resource.Slot; + if (slot.Mode != RenderResourceOwnershipMode.Owned + || slot.State != RenderResourceOwnershipState.RequestOwned) + { + throw new InvalidOperationException("Only an unleased request-owned resource can transfer to a cache."); + } + + slot.State = RenderResourceOwnershipState.Discharged; + InvalidateTokens(slot); + RemoveSlot(slot); + return (T)slot.TakeRawValue(); + } + + public void Release(RenderResource resource) + { + EnsureRegistered(resource); + if (resource.RegistrationState == RenderResourceRegistrationState.Released) + { + return; + } + + ReleaseCore(resource); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + List? failures = null; + for (int index = _slots.Count - 1; index >= 0; index--) + { + RenderResourceRegistration slot = _slots[index]; + try + { + RemoveSlot(slot); + DischargeSlot(slot); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + _slots.Clear(); + _slotsByRawValue.Clear(); + _ownedTombstones.Clear(); + _borrowedTombstones.Clear(); + if (failures is not null) + { + throw new AggregateException("One or more render resources failed to discharge.", failures); + } + } + + internal IReadOnlyList Slots => _slots; + + internal void ValidateBinding(RenderResource resource) + { + EnsureRegistered(resource); + if (resource.RegistrationState == RenderResourceRegistrationState.Released) + throw new InvalidOperationException("A released render resource cannot be bound to a resource slot."); + } + + private RenderResourceRegistration CreateSlot( + object rawValue, + RenderResourceOwnershipMode mode) + { + var slot = new RenderResourceRegistration(rawValue, mode); + _slots.Add(slot); + if (!_slotsByRawValue.TryGetValue(rawValue, out List? registrations)) + { + registrations = []; + _slotsByRawValue.Add(rawValue, registrations); + } + + registrations.Add(slot); + return slot; + } + + private RenderResource CreateToken(RenderResourceRegistration slot) + where T : class + { + var token = new RenderResource(this, slot) + { + RegistrationState = RenderResourceRegistrationState.Pending, + }; + slot.PendingRegistrations++; + slot.Tokens.Add(token); + slot.UpdateStableState(); + return token; + } + + private void EnsureRegistered(RenderResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + ThrowIfDisposed(); + if (!ReferenceEquals(resource.Registry, this)) + { + throw new InvalidOperationException("The resource belongs to a different render request family."); + } + } + + private void EnsureCommitted(RenderResource resource) + { + EnsureRegistered(resource); + if (resource.RegistrationState != RenderResourceRegistrationState.Committed) + { + throw new InvalidOperationException("The resource is not committed to this request."); + } + } + + private void ReleaseCore(RenderResource resource) + { + RenderResourceRegistration slot = resource.Slot; + if (slot.State == RenderResourceOwnershipState.LeasedToCallback) + { + throw new InvalidOperationException( + "A leased render resource cannot be released from its callback."); + } + + switch (resource.RegistrationState) + { + case RenderResourceRegistrationState.Pending: + slot.PendingRegistrations--; + break; + case RenderResourceRegistrationState.Committed: + slot.CommittedRegistrations--; + break; + default: + return; + } + + resource.RegistrationState = RenderResourceRegistrationState.Released; + if (slot.PendingRegistrations == 0 && slot.CommittedRegistrations == 0) + { + RemoveSlot(slot); + DischargeSlot(slot); + } + else + { + slot.UpdateStableState(); + resource.Detach(RenderResourceOwnershipState.ReleasedToken); + } + } + + private static void DischargeSlot(RenderResourceRegistration slot) + { + if (slot.State is RenderResourceOwnershipState.Discharged + or RenderResourceOwnershipState.ReleasedToken) + { + return; + } + + if (slot.State == RenderResourceOwnershipState.LeasedToCallback) + { + throw new InvalidOperationException("A leased render resource cannot be discharged."); + } + + if (slot.Mode == RenderResourceOwnershipMode.Owned) + { + slot.State = RenderResourceOwnershipState.Discharged; + InvalidateTokens(slot); + ((IDisposable)slot.TakeRawValue()).Dispose(); + } + else + { + slot.State = RenderResourceOwnershipState.ReleasedToken; + InvalidateTokens(slot); + _ = slot.TakeRawValue(); + } + } + + private static void InvalidateTokens(RenderResourceRegistration slot) + { + foreach (RenderResource token in slot.Tokens) + { + token.RegistrationState = RenderResourceRegistrationState.Released; + token.Detach(slot.State); + } + + slot.PendingRegistrations = 0; + slot.CommittedRegistrations = 0; + } + + private void RemoveSlot(RenderResourceRegistration slot) + { + _slots.Remove(slot); + object rawValue = slot.RawValue; + if (_slotsByRawValue.TryGetValue(rawValue, out List? registrations)) + { + registrations.Remove(slot); + if (registrations.Count == 0) + { + _slotsByRawValue.Remove(rawValue); + } + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private void MarkBorrowed(object value) + => _ = _borrowedTombstones.GetValue(value, static _ => BorrowedResourceTombstone.Instance); +} + +internal sealed class RenderResourceRegistration +{ + private object? _rawValue; + + public RenderResourceRegistration( + object rawValue, + RenderResourceOwnershipMode mode) + { + _rawValue = rawValue; + Mode = mode; + State = mode == RenderResourceOwnershipMode.Owned + ? RenderResourceOwnershipState.Pending + : RenderResourceOwnershipState.BorrowedPending; + } + + public object RawValue + => _rawValue ?? throw new InvalidOperationException( + "The render resource slot no longer retains its raw value."); + + public object TakeRawValue() + { + object value = RawValue; + _rawValue = null; + return value; + } + + public RenderResourceOwnershipMode Mode { get; } + + public List Tokens { get; } = []; + + public int PendingRegistrations { get; set; } + + public int CommittedRegistrations { get; set; } + + public RenderResourceOwnershipState State { get; set; } + + public void UpdateStableState() + { + if (State is RenderResourceOwnershipState.Discharged + or RenderResourceOwnershipState.ReleasedToken + or RenderResourceOwnershipState.LeasedToCallback) + { + return; + } + + State = Mode switch + { + RenderResourceOwnershipMode.Owned when CommittedRegistrations > 0 + => RenderResourceOwnershipState.RequestOwned, + RenderResourceOwnershipMode.Owned + => RenderResourceOwnershipState.Pending, + RenderResourceOwnershipMode.Borrowed when CommittedRegistrations > 0 + => RenderResourceOwnershipState.RequestBorrowed, + _ => RenderResourceOwnershipState.BorrowedPending, + }; + } +} + +internal enum RenderResourceOwnershipMode : byte +{ + Owned, + Borrowed, +} + +internal enum RenderResourceOwnershipState : byte +{ + Pending, + RequestOwned, + BorrowedPending, + RequestBorrowed, + LeasedToCallback, + Discharged, + ReleasedToken, +} + +internal enum RenderResourceRegistrationState : byte +{ + Pending, + Committed, + Released, +} + +internal sealed class OwnedResourceTombstone +{ + public static OwnedResourceTombstone Instance { get; } = new(); + + private OwnedResourceTombstone() + { + } +} + +internal sealed class BorrowedResourceTombstone +{ + public static BorrowedResourceTombstone Instance { get; } = new(); + + private BorrowedResourceTombstone() + { + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureDescription.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureDescription.cs new file mode 100644 index 0000000000..d145f9fd58 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureDescription.cs @@ -0,0 +1,74 @@ +namespace Beutl.Graphics.Rendering; + +public sealed class TargetCaptureDescription +{ + private TargetCaptureDescription( + TargetRegion sourceRegion, + Rect bounds, + RenderHitTestContract hitTest, + TargetCaptureScaleContract scale) + { + SourceRegion = sourceRegion; + Bounds = bounds; + HitTest = hitTest; + Scale = scale; + } + + public TargetRegion SourceRegion { get; } + + public Rect Bounds { get; } + + public RenderHitTestContract HitTest { get; } + + public TargetCaptureScaleContract Scale { get; } + + public static TargetCaptureDescription Create( + TargetRegion sourceRegion, + Rect bounds, + RenderHitTestContract hitTest, + TargetCaptureScaleContract scale) + { + sourceRegion.ThrowIfUninitialized(nameof(sourceRegion)); + if (sourceRegion.Kind == TargetRegionKind.Empty) + throw new ArgumentException("A target capture source region cannot be empty.", nameof(sourceRegion)); + + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(bounds, nameof(bounds)); + if (sourceRegion.Kind == TargetRegionKind.Region + && !RenderDescriptionValidation.Contains(sourceRegion.Value, bounds)) + { + throw new ArgumentException( + "Target capture bounds must be contained by a finite source region.", + nameof(bounds)); + } + + hitTest.ThrowIfUninitialized(nameof(hitTest)); + if (hitTest.Kind == RenderHitTestContractKind.AnyInput) + { + throw new ArgumentException( + "A target capture has no logical value inputs and cannot use AnyInput hit testing.", + nameof(hitTest)); + } + + scale.ThrowIfUninitialized(nameof(scale)); + + return new TargetCaptureDescription(sourceRegion, bounds, hitTest, scale); + } + + /// + /// Checks the region and domain a capture resolved against once the surrounding graph is known. + /// + /// + /// Both are decided by the scope the capture ends up in, not by the author, who has neither at the point + /// they call . Requiring to sit inside them would therefore be a + /// precondition nobody can satisfy: the same description fails or succeeds depending on where it is used. + /// A capture reaching past the pixels available to it reads transparent there instead - the value is + /// cleared before the copy - which is the same answer as capturing an area nothing has drawn into. + /// The author-observable half of this rule is still enforced at : an explicit finite + /// source region must contain the bounds asked of it. + /// + internal void ValidateResolvedBounds(Rect resolvedSourceRegion, Rect targetDomain) + { + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(resolvedSourceRegion, nameof(resolvedSourceRegion)); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(targetDomain, nameof(targetDomain)); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureScaleContract.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureScaleContract.cs new file mode 100644 index 0000000000..6bede89cc9 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCaptureScaleContract.cs @@ -0,0 +1,109 @@ +namespace Beutl.Graphics.Rendering; + +/// Declares how a target capture resolves its materialized pixel density. +public readonly struct TargetCaptureScaleContract +{ + private readonly TargetCaptureScaleContractKind _kind; + private readonly RenderScaleContract _declaredScale; + + private TargetCaptureScaleContract( + TargetCaptureScaleContractKind kind, + RenderScaleContract declaredScale = default) + { + _kind = kind; + _declaredScale = declaredScale; + } + + /// + /// Resolves a concrete output-derived density without observing the enclosing target's density. + /// + public static TargetCaptureScaleContract MaterializeAtWorkingScale { get; } = + new( + TargetCaptureScaleContractKind.Declared, + RenderScaleContract.MaterializeAtWorkingScale); + + /// + /// Preserves the resolved density of the enclosing root, finite layer, or target-layer scope. + /// + /// + /// This contract remains late-bound while the graph is recorded. The capture materializes at the active target + /// density during execution, so a denser enclosing scope is not downsampled before downstream consumers run. + /// + public static TargetCaptureScaleContract PreserveTargetSupply { get; } = + new(TargetCaptureScaleContractKind.PreserveTargetSupply); + + /// Creates a concrete output-derived capture density contract. + /// + /// A pure resolver that receives no input supplies and may use output bounds, output scale, and maximum working + /// scale. + /// + /// A validated target-capture scale contract. + /// is . + public static TargetCaptureScaleContract Custom( + Func resolve) + => new( + TargetCaptureScaleContractKind.Declared, + RenderScaleContract.Custom(resolve)); + + internal bool PreservesTargetSupply + { + get + { + ThrowIfUninitialized(); + return _kind == TargetCaptureScaleContractKind.PreserveTargetSupply; + } + } + + internal object StructuralIdentity + { + get + { + ThrowIfUninitialized(); + return _kind == TargetCaptureScaleContractKind.PreserveTargetSupply + ? _kind + : new TargetCaptureScaleStructuralIdentity(_kind, _declaredScale.StructuralIdentity); + } + } + + internal EffectiveScale ResolveDeclared( + Rect outputBounds, + float outputScale, + float maxWorkingScale) + { + ThrowIfUninitialized(); + if (_kind == TargetCaptureScaleContractKind.PreserveTargetSupply) + { + throw new InvalidOperationException( + "A target-supply-preserving capture resolves against its active target during execution."); + } + + return _declaredScale.Resolve([], outputBounds, outputScale, maxWorkingScale); + } + + internal void ThrowIfUninitialized(string? parameterName = null) + { + if (_kind == TargetCaptureScaleContractKind.Uninitialized) + { + if (parameterName is null) + { + throw new InvalidOperationException( + "default(TargetCaptureScaleContract) is uninitialized; use a named or custom contract."); + } + + throw new ArgumentException( + "default(TargetCaptureScaleContract) is uninitialized; use a named or custom contract.", + parameterName); + } + } +} + +internal enum TargetCaptureScaleContractKind : byte +{ + Uninitialized, + Declared, + PreserveTargetSupply, +} + +internal readonly record struct TargetCaptureScaleStructuralIdentity( + TargetCaptureScaleContractKind Kind, + object DeclaredScale); diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCommandDescription.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCommandDescription.cs new file mode 100644 index 0000000000..43f58ce375 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetCommandDescription.cs @@ -0,0 +1,328 @@ +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +internal sealed class TargetCommandDescription +{ + private readonly RenderExecutionChannel _execution; + + private TargetCommandDescription( + RenderExecutionChannel execution, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access, + IReadOnlyList inputReadbacks, + object definitionFingerprint, + RenderInputDemandContract inputDemand, + IReadOnlyList resources) + { + _execution = execution; + AffectedRegion = affectedRegion; + QueryBounds = queryBounds; + HitTest = hitTest; + Access = access; + InputReadbacks = inputReadbacks; + DefinitionFingerprint = definitionFingerprint; + InputDemand = inputDemand; + Resources = resources; + } + + /// Gets the mapping from this command's target demand to the demand it places on each input. + /// + /// A command that resamples an input while drawing it - a transform pushed before + /// Inputs[i].Draw - needs that input at a different density from the target it draws onto. + /// + public RenderInputDemandContract InputDemand { get; } + + public TargetRegion AffectedRegion { get; } + + public Rect QueryBounds { get; } + + public RenderHitTestContract HitTest { get; } + + public TargetAccess Access { get; } + + public IReadOnlyList InputReadbacks { get; } + + internal object DefinitionFingerprint { get; } + + public IReadOnlyList Resources { get; } + + internal void Execute(TargetCommandSession session) => _execution.Invoke(session); + + /// + /// Every pixel-affecting value the callback reads. It belongs in the call state; when it changes, the owning + /// node reports the change through . + /// + /// + /// A non-capturing callback. Declare it : a capture would let a per-frame value + /// shape the target without reaching , and is rejected. + /// + /// + /// obliges the callback to consume + /// exactly once. + /// + internal static TargetCommandDescription Create( + TState state, + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access = TargetAccess.ReadWrite, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + RenderInputDemandContract inputDemand = default) + where TState : notnull + => CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + execute, + nameof(state), + nameof(execute)), + affectedRegion, + queryBounds, + hitTest, + access, + inputReadbacks, + execute.Method, + inputDemand, + resources); + + /// + /// Creates a command whose effect on the target can never satisfy a later request's cache lookup. + /// + /// + /// The opt-out for a callback whose pixel-affecting state cannot be expressed as copied, deeply immutable + /// CPU state. The callback may capture, and the recorded output takes a fresh request-local identity every + /// time. + /// + internal static TargetCommandDescription CreateRequestLocal( + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access = TargetAccess.ReadWrite, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + RenderInputDemandContract inputDemand = default) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + affectedRegion, + queryBounds, + hitTest, + access, + inputReadbacks, + execute.Method, + inputDemand, + resources); + + internal static TargetCommandDescription CreateCore( + RenderExecutionChannel execution, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access, + IEnumerable? inputReadbacks, + object definitionFingerprint, + RenderInputDemandContract inputDemand, + IEnumerable? resources) + { + affectedRegion.ThrowIfUninitialized(nameof(affectedRegion)); + RenderRectValidation.ThrowIfInvalidInput(queryBounds, nameof(queryBounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + RenderDescriptionValidation.ThrowIfQueryContributionIncoherent( + queryBounds, + hitTest, + nameof(hitTest)); + if (!Enum.IsDefined(access)) + throw new ArgumentOutOfRangeException(nameof(access), access, "The target access value is invalid."); + if (access == TargetAccess.Readback && affectedRegion.Kind == TargetRegionKind.Empty) + { + throw new ArgumentException( + "A readback command requires a non-empty target region.", + nameof(affectedRegion)); + } + + ArgumentNullException.ThrowIfNull(definitionFingerprint); + RenderInputReadback[] readbacks = CopyInputReadbacks(inputReadbacks); + + return new TargetCommandDescription( + execution, + affectedRegion, + queryBounds, + hitTest, + access, + Array.AsReadOnly(readbacks), + definitionFingerprint, + inputDemand, + RenderDescriptionValidation.CopyResourceBindings(resources, nameof(resources))); + } + + internal IReadOnlyList ResolveInputReadbacks( + int inputCount, + string parameterName) + { + if (InputReadbacks.Count == 0) + return Enumerable.Repeat(RenderInputReadback.None, inputCount).ToArray(); + if (InputReadbacks.Count != inputCount) + { + throw new ArgumentException( + "The target-command input readback count must match the authored input count.", + parameterName); + } + return InputReadbacks; + } + + private static RenderInputReadback[] CopyInputReadbacks( + IEnumerable? inputReadbacks) + { + if (inputReadbacks is null) + return []; + + RenderInputReadback[] result = inputReadbacks.ToArray(); + foreach (RenderInputReadback inputReadback in result) + { + inputReadback.ThrowIfUninitialized(nameof(inputReadbacks)); + } + + return result; + } +} + +public enum TargetAccess +{ + ReadWrite, + Readback, +} + +public sealed class TargetCommandSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly IReadOnlyList _inputs; + private readonly IReadOnlyList _inputRanges; + private readonly Rect _affectedBounds; + private readonly Rect _requiredRegion; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly RenderCallbackCanvas _canvas; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + private readonly Func? _createSnapshot; + private readonly bool _snapshotRequired; + private bool _snapshotUsed; + + internal TargetCommandSession( + RenderExecutionSessionToken token, + IReadOnlyList inputs, + IReadOnlyList inputRanges, + Rect affectedBounds, + Rect requiredRegion, + RenderIntent intent, + RenderRequestPurpose purpose, + RenderCallbackCanvas canvas, + IReadOnlyList resources, + bool snapshotRequired, + Func? createSnapshot) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(inputRanges); + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(resources); + _token = token; + _inputs = Array.AsReadOnly(inputs.ToArray()); + _inputRanges = RenderExecutionInputRange.CopyAndValidate( + _inputs, + inputRanges, + nameof(inputRanges)); + _affectedBounds = affectedBounds; + _requiredRegion = requiredRegion; + _intent = intent; + _purpose = purpose; + _canvas = canvas; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + _snapshotRequired = snapshotRequired; + _createSnapshot = createSnapshot; + } + + public IReadOnlyList Inputs + { + get { _token.ThrowIfInactive(); return _inputs; } + } + + /// + /// Gets one stable flattened-input range per authored input handle, including zero-length ranges for handles + /// that produced no runtime values. + /// + public IReadOnlyList InputRanges + { + get { _token.ThrowIfInactive(); return _inputRanges; } + } + + public Rect AffectedBounds + { + get { _token.ThrowIfInactive(); return _affectedBounds; } + } + + public Rect RequiredRegion + { + get { _token.ThrowIfInactive(); return _requiredRegion; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } + + public RenderCallbackCanvas Canvas + { + get { _token.ThrowIfInactive(); return _canvas; } + } + + /// Replaces every pixel in the declared affected region with . + /// + /// The operation uses clipped source replacement, so a transparent color erases a finite region without + /// exposing unrestricted blend state or writing outside the command's declared target access. + /// + public void ReplaceAffectedRegion(Color color) + { + _token.ThrowIfInactive(); + _canvas.Use(canvas => canvas.ReplaceAffectedRegion(color)); + } + + public void UseSnapshot(Action use) + { + _token.ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(use); + if (!_snapshotRequired || _createSnapshot is null) + throw new InvalidOperationException("This target command did not declare target readback."); + if (_snapshotUsed) + throw new InvalidOperationException("The target snapshot is a one-shot execution lease."); + + _snapshotUsed = true; + using Bitmap snapshot = _createSnapshot() + ?? throw new InvalidOperationException("The target snapshot provider returned null."); + _token.AuthorizeResource(snapshot, () => use(snapshot)); + } + + /// Uses the resource bound to a definition-declared slot. + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + internal void ValidateCompletion() + { + _token.ThrowIfInactive(); + if (_snapshotRequired && !_snapshotUsed) + throw new InvalidOperationException("A readback target command must consume its snapshot exactly once."); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/TargetRegion.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetRegion.cs new file mode 100644 index 0000000000..f55eec70a0 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetRegion.cs @@ -0,0 +1,59 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Describes a target-relative pixel access region without resolving a symbolic full target during recording. +/// +public readonly struct TargetRegion +{ + private readonly TargetRegionKind _kind; + private readonly Rect _value; + + private TargetRegion(TargetRegionKind kind, Rect value = default) + { + _kind = kind; + _value = value; + } + + public static TargetRegion Full { get; } = new(TargetRegionKind.Full); + + public static TargetRegion Empty { get; } = new(TargetRegionKind.Empty); + + public static TargetRegion Region(Rect region) + { + if (!RenderRectValidation.IsFiniteNonNegative(region)) + { + throw new ArgumentException( + "A target region must be finite and have non-negative dimensions.", + nameof(region)); + } + + return region.Width == 0 || region.Height == 0 + ? Empty + : new TargetRegion(TargetRegionKind.Region, region); + } + + internal TargetRegionKind Kind => _kind; + + internal Rect Value + => _kind == TargetRegionKind.Region + ? _value + : throw new InvalidOperationException("Only a finite target region has a Rect value."); + + internal void ThrowIfUninitialized(string parameterName) + { + if (_kind == TargetRegionKind.Uninitialized) + { + throw new ArgumentException( + "default(TargetRegion) is uninitialized; use Full, Empty, or Region.", + parameterName); + } + } +} + +internal enum TargetRegionKind : byte +{ + Uninitialized, + Full, + Empty, + Region, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Operations/TargetScopeDescription.cs b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetScopeDescription.cs new file mode 100644 index 0000000000..405debbe3c --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Operations/TargetScopeDescription.cs @@ -0,0 +1,651 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Declares whether a guarded target scope replays its input onto the device pixel grid the input would have +/// been rasterized against without the scope. +/// +/// +/// A scope callback's whole permitted vocabulary is save/restore, transform, and clip, so moving the replayed +/// content onto a different grid is an ordinary thing for a scope to do rather than an exception. The planner +/// therefore assumes unless the scope states otherwise: upstream content that declares +/// is re-rasterized under a remapping scope instead +/// of being resampled out of an output cache. +/// +public enum RenderDeviceGridMapping : byte +{ + /// + /// The scope may replay its input onto a different device pixel grid. Declaring this for a scope that in + /// fact preserves the grid only costs upstream cache reuse; it never produces wrong pixels. + /// + Remapped, + + /// + /// The scope replays its input onto the same device pixel grid, so device-grid phase dependent content + /// upstream keeps the phase its cached output was captured at. + /// + Preserved, +} + +/// +/// Declares the space a guarded target scope's replay transform is defined in. +/// +/// +/// A scope's declared can carry an output demand back to its input only when +/// the transform between them is expressed in the input's own coordinates. A scope defined against the ambient +/// target transform - what TransformOperator.Append and TransformOperator.Set do - has that scale +/// carried by the destination matrix instead, which the value graph has no representation of, so raising the +/// input's demand there would rasterize it enlarged and then draw it enlarged again. +/// +public enum RenderScopeTransformSpace : byte +{ + /// + /// The replay transform is defined against the ambient target transform. The scale contract's backward + /// map is not applied, because the destination already carries whatever the scope contributes. + /// + AmbientTarget, + + /// + /// The replay transform is defined in the input's own logical space, so the scale contract describes the + /// step between them completely and its backward map reaches the input. + /// + InputLogical, +} + +internal sealed class TargetScopeDescription +{ + private readonly RenderExecutionChannel _execution; + + private TargetScopeDescription( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + RenderDeviceGridMapping deviceGridMapping, + object definitionFingerprint, + IReadOnlyList resources, + bool isValueReplayMap, + RenderScopeTransformSpace transformSpace, + bool builtInBackdropCapturesBackingTarget) + { + _execution = execution; + Bounds = bounds; + HitTest = hitTest; + Scale = scale; + DeviceGridSensitivity = deviceGridSensitivity; + DeviceGridMapping = deviceGridMapping; + DefinitionFingerprint = definitionFingerprint; + Resources = resources; + IsValueReplayMap = isValueReplayMap; + TransformSpace = transformSpace; + BuiltInBackdropCapturesBackingTarget = builtInBackdropCapturesBackingTarget; + } + + public RenderBoundsContract Bounds { get; } + + public RenderHitTestContract HitTest { get; } + + public RenderScaleContract Scale { get; } + + /// Gets whether this scope's own replay or clip coverage depends on device-grid phase. + public RenderDeviceGridSensitivity DeviceGridSensitivity { get; } + + /// Gets the declared device pixel grid this scope replays its input onto. + public RenderDeviceGridMapping DeviceGridMapping { get; } + + internal object DefinitionFingerprint { get; } + + public IReadOnlyList Resources { get; } + + internal void Execute(TargetScopeSession session) => _execution.Invoke(session); + + /// Gets whether the renderer lowers this scope into the value graph. + /// + /// Engine-owned and not declarable: it requires the callback to be mechanically restricted to + /// allocation-free target state plus exactly one replay. is the separate, + /// author-declarable question of where the replay transform lives. + /// + internal bool IsValueReplayMap { get; } + + /// Gets the space this scope's replay transform is defined in. + public RenderScopeTransformSpace TransformSpace { get; } + + internal bool BuiltInBackdropCapturesBackingTarget { get; } + + /// + /// Every pixel-affecting value the callback reads. It belongs in the call state; when it changes, the owning + /// node reports the change through . + /// + /// + /// Whether this scope's replay or surrounding clip state changes coverage with device-grid phase. The + /// conservative default requires an explicit promise. + /// + /// + /// A non-capturing callback. Declare it : a capture would let a per-frame value + /// shape the output without reaching , and is rejected. + /// + /// + /// The device pixel grid the callback replays its input onto. The default assumes a different grid; + /// declare only when the callback leaves the target + /// transform alone. + /// + internal static TargetScopeDescription Create( + TState state, + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + RenderDeviceGridMapping deviceGridMapping = RenderDeviceGridMapping.Remapped, + RenderScopeTransformSpace transformSpace = RenderScopeTransformSpace.AmbientTarget, + IEnumerable? resources = null) + where TState : notnull + => CreateCore( + RenderDescriptionValidation.CreateStateChannel( + state, + execute, + nameof(state), + nameof(execute)), + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + execute.Method, + resources, + isValueReplayMap: false, + transformSpace, + builtInBackdropCapturesBackingTarget: false); + + /// + /// Creates a scope whose output can never satisfy a later request's cache lookup. + /// + /// + /// The opt-out for a callback whose pixel-affecting state cannot be expressed as copied, deeply immutable + /// CPU state. The callback may capture, and the recorded output takes a fresh request-local identity every + /// time. + /// + internal static TargetScopeDescription CreateRequestLocal( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + RenderDeviceGridMapping deviceGridMapping = RenderDeviceGridMapping.Remapped, + RenderScopeTransformSpace transformSpace = RenderScopeTransformSpace.AmbientTarget, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + execute.Method, + resources, + isValueReplayMap: false, + transformSpace, + builtInBackdropCapturesBackingTarget: false); + + /// + /// Creates a scope the renderer lowers into the value graph instead of materializing its input. + /// + /// + /// Eligibility is engine-owned because no declaration can establish it: the callback must be mechanically + /// restricted to allocation-free target state plus exactly one replay, which only an in-engine author can + /// guarantee. Public therefore always produces a materializing boundary. + /// + internal static TargetScopeDescription CreateValueReplayMap( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + RenderDeviceGridMapping deviceGridMapping, + bool builtInBackdropCapturesBackingTarget = false, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + new EngineValueReplayMapDefinition(execute.Method), + resources, + isValueReplayMap: true, + // A value replay map is lowered into the value graph, which only holds together when the + // transform between the scope and its input is expressed in the input's own coordinates. + RenderScopeTransformSpace.InputLogical, + builtInBackdropCapturesBackingTarget); + + internal static TargetScopeDescription CreateCore( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity, + RenderDeviceGridMapping deviceGridMapping, + object definitionFingerprint, + IEnumerable? resources, + bool isValueReplayMap, + RenderScopeTransformSpace transformSpace, + bool builtInBackdropCapturesBackingTarget = false) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + if (!Enum.IsDefined(deviceGridSensitivity)) + throw new ArgumentOutOfRangeException(nameof(deviceGridSensitivity)); + if (!Enum.IsDefined(deviceGridMapping)) + throw new ArgumentOutOfRangeException(nameof(deviceGridMapping)); + if (!Enum.IsDefined(transformSpace)) + throw new ArgumentOutOfRangeException(nameof(transformSpace)); + ArgumentNullException.ThrowIfNull(definitionFingerprint); + + return new TargetScopeDescription( + execution, + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + definitionFingerprint, + RenderDescriptionValidation.CopyResourceBindings(resources, nameof(resources)), + isValueReplayMap, + transformSpace, + builtInBackdropCapturesBackingTarget); + } +} + +public sealed class TargetScopeSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly Rect _outputBounds; + private readonly Rect _requiredRegion; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly RenderCallbackCanvas _canvas; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + private readonly Action _replayInput; + private bool _replayed; + + internal TargetScopeSession( + RenderExecutionSessionToken token, + Rect outputBounds, + Rect requiredRegion, + RenderIntent intent, + RenderRequestPurpose purpose, + RenderCallbackCanvas canvas, + IReadOnlyList resources, + Action replayInput) + { + _token = token; + _outputBounds = outputBounds; + _requiredRegion = requiredRegion; + _intent = intent; + _purpose = purpose; + _canvas = canvas; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + _replayInput = replayInput; + } + + public Rect OutputBounds + { + get { _token.ThrowIfInactive(); return _outputBounds; } + } + + public Rect RequiredRegion + { + get { _token.ThrowIfInactive(); return _requiredRegion; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } + + public RenderCallbackCanvas Canvas + { + get { _token.ThrowIfInactive(); return _canvas; } + } + + public void ReplayInput() + { + _token.ThrowIfInactive(); + if (_replayed) + throw new InvalidOperationException("A target scope input must be replayed exactly once."); + + ImmediateCanvas canvas = _token.GetActiveCanvas(_canvas); + _replayed = true; + canvas.ReplayTargetScopeInput(_replayInput); + } + + /// Uses the resource bound to a definition-declared slot. + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + internal void ValidateCompletion() + { + _token.ThrowIfInactive(); + if (!_replayed) + throw new InvalidOperationException("A target scope input must be replayed exactly once."); + } +} + +internal sealed record EngineValueReplayMapDefinition(System.Reflection.MethodInfo ExecuteMethod); + +internal sealed class RawTargetScopeDescription +{ + private readonly RenderExecutionChannel _execution; + + private RawTargetScopeDescription( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + object definitionFingerprint, + IReadOnlyList resources) + { + _execution = execution; + Bounds = bounds; + HitTest = hitTest; + Scale = scale; + DefinitionFingerprint = definitionFingerprint; + Resources = resources; + } + + public RenderBoundsContract Bounds { get; } + + public RenderHitTestContract HitTest { get; } + + public RenderScaleContract Scale { get; } + + internal object DefinitionFingerprint { get; } + + public IReadOnlyList Resources { get; } + + internal void Execute(RawTargetScopeSession session) => _execution.Invoke(session); + + /// + /// Creates a raw scope whose output can never satisfy a later request's cache lookup. + /// + /// + /// A raw scope hands an unguarded canvas to an opaque external callback, so the renderer can describe + /// nothing about what it draws and gives every recording a fresh request-local identity. There is no + /// state-passing form: no declared state could make the output reusable. + /// + internal static RawTargetScopeDescription CreateRequestLocal( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + bounds, + hitTest, + scale, + execute.Method, + resources); + + internal static RawTargetScopeDescription CreateCore( + RenderExecutionChannel execution, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + object definitionFingerprint, + IEnumerable? resources) + { + bounds.ThrowIfUninitialized(nameof(bounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + ArgumentNullException.ThrowIfNull(definitionFingerprint); + + return new RawTargetScopeDescription( + execution, + bounds, + hitTest, + scale, + definitionFingerprint, + RenderDescriptionValidation.CopyResourceBindings(resources, nameof(resources))); + } +} + +public sealed class RawTargetScopeSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly ImmediateCanvas _canvas; + private readonly Rect _outputBounds; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + private readonly Action _replayInput; + private bool _replayed; + + internal RawTargetScopeSession( + RenderExecutionSessionToken token, + ImmediateCanvas canvas, + Rect outputBounds, + RenderIntent intent, + RenderRequestPurpose purpose, + IReadOnlyList resources, + Action replayInput) + { + _token = token; + _canvas = canvas; + _outputBounds = outputBounds; + _intent = intent; + _purpose = purpose; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + _replayInput = replayInput; + } + + public ImmediateCanvas Canvas + { + get { _token.ThrowIfInactive(); return _canvas; } + } + + public Rect OutputBounds + { + get { _token.ThrowIfInactive(); return _outputBounds; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } + + public void ReplayInput() + { + _token.ThrowIfInactive(); + if (_replayed) + throw new InvalidOperationException("A raw target scope input must be replayed exactly once."); + if (!_token.IsActiveCanvas(_canvas)) + throw new InvalidOperationException("ReplayInput must be called while the raw callback canvas is active."); + + _replayed = true; + _replayInput(_canvas); + } + + /// Uses the resource bound to a definition-declared slot. + /// + /// The addressing mode a reusable definition needs: its callback is static and its slots are fixed, so + /// the token changes per call and only the slot names it from inside the callback. + /// + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + /// Uses a resource by its token. + /// For a request-local callback, which may capture the tokens it needs. + public void UseResource(RenderResource resource, Action use) + where T : class + { + _token.UseResource(resource, _resources, use); + } + + internal void ValidateCompletion() + { + _token.ThrowIfInactive(); + if (!_replayed) + throw new InvalidOperationException("A raw target scope input must be replayed exactly once."); + } +} + +internal sealed class RawTargetCommandDescription +{ + private readonly RenderExecutionChannel _execution; + + private RawTargetCommandDescription( + RenderExecutionChannel execution, + Rect queryBounds, + RenderHitTestContract hitTest, + object definitionFingerprint, + IReadOnlyList resources) + { + _execution = execution; + QueryBounds = queryBounds; + HitTest = hitTest; + DefinitionFingerprint = definitionFingerprint; + Resources = resources; + } + + public Rect QueryBounds { get; } + + public RenderHitTestContract HitTest { get; } + + internal object DefinitionFingerprint { get; } + + public IReadOnlyList Resources { get; } + + internal void Execute(RawTargetCommandSession session) => _execution.Invoke(session); + + /// + /// Creates a raw command whose effect on the target can never satisfy a later request's cache lookup. + /// + /// + /// A raw command hands an unguarded canvas to an opaque external callback, so the renderer can describe + /// nothing about what it draws and gives every recording a fresh request-local identity. There is no + /// state-passing form: no declared state could make the output reusable. + /// + internal static RawTargetCommandDescription CreateRequestLocal( + Action execute, + Rect queryBounds, + RenderHitTestContract hitTest, + IEnumerable? resources = null) + => CreateCore( + RenderDescriptionValidation.CreateRequestLocalChannel(execute, nameof(execute)), + queryBounds, + hitTest, + execute.Method, + resources); + + internal static RawTargetCommandDescription CreateCore( + RenderExecutionChannel execution, + Rect queryBounds, + RenderHitTestContract hitTest, + object definitionFingerprint, + IEnumerable? resources) + { + RenderRectValidation.ThrowIfInvalidInput(queryBounds, nameof(queryBounds)); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + if (hitTest.Kind == RenderHitTestContractKind.AnyInput) + { + throw new ArgumentException( + "A raw target command has no logical value inputs and cannot use AnyInput hit testing.", + nameof(hitTest)); + } + + RenderDescriptionValidation.ThrowIfQueryContributionIncoherent( + queryBounds, + hitTest, + nameof(hitTest)); + ArgumentNullException.ThrowIfNull(definitionFingerprint); + + return new RawTargetCommandDescription( + execution, + queryBounds, + hitTest, + definitionFingerprint, + RenderDescriptionValidation.CopyResourceBindings(resources, nameof(resources))); + } +} + +public sealed class RawTargetCommandSession +{ + private readonly RenderExecutionSessionToken _token; + private readonly ImmediateCanvas _canvas; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly IReadOnlyList _resourceBindings; + private readonly IReadOnlyList _resources; + + internal RawTargetCommandSession( + RenderExecutionSessionToken token, + ImmediateCanvas canvas, + RenderIntent intent, + RenderRequestPurpose purpose, + IReadOnlyList resources) + { + _token = token; + _canvas = canvas; + _intent = intent; + _purpose = purpose; + _resourceBindings = resources; + _resources = resources.Select(static binding => binding.Resource).ToArray(); + } + + public ImmediateCanvas Canvas + { + get { _token.ThrowIfInactive(); return _canvas; } + } + + public RenderIntent Intent + { + get { _token.ThrowIfInactive(); return _intent; } + } + + public RenderRequestPurpose Purpose + { + get { _token.ThrowIfInactive(); return _purpose; } + } + + /// Uses the resource bound to a definition-declared slot. + /// + /// The addressing mode a reusable definition needs: its callback is static and its slots are fixed, so + /// the token changes per call and only the slot names it from inside the callback. + /// + public void UseResource(RenderResourceSlot slot, Action use) + where T : class + { + _token.UseResource(slot, _resourceBindings, use); + } + + /// Uses a resource by its token. + /// For a request-local callback, which may capture the tokens it needs. + public void UseResource(RenderResource resource, Action use) + where T : class + { + _token.UseResource(resource, _resources, use); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs new file mode 100644 index 0000000000..f318df9cdb --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/CompiledRenderRequest.cs @@ -0,0 +1,823 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +internal sealed class CompiledRenderRequest : IDisposable +{ + public CompiledRenderRequest( + RenderRequest request, + RecordedRenderGraph graph, + RegionAnalysis regions, + ImmutableArray roots, + IReadOnlyDictionary materializationDemands, + IReadOnlySet materializedFragments, + IReadOnlySet previewDropEligibleMaterializations, + TargetDependencyPlan targetDependencies, + RenderCacheResolution cacheResolution, + ExecutionIslandPlan executionPlan, + ImmutableArray nestedRequests = default) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + Graph = graph ?? throw new ArgumentNullException(nameof(graph)); + Regions = regions ?? throw new ArgumentNullException(nameof(regions)); + TargetDependencies = targetDependencies ?? throw new ArgumentNullException(nameof(targetDependencies)); + Measurement = regions.Measurement; + SelectedOutputBounds = regions.FinalCommitBounds; + ExecutionTargetBounds = ResolveExecutionTargetBounds(graph, regions, TargetDependencies); + Roots = roots; + MaterializationDemands = materializationDemands + ?? throw new ArgumentNullException(nameof(materializationDemands)); + MaterializedFragments = materializedFragments + ?? throw new ArgumentNullException(nameof(materializedFragments)); + PreviewDropEligibleMaterializations = previewDropEligibleMaterializations + ?? throw new ArgumentNullException(nameof(previewDropEligibleMaterializations)); + CacheResolution = cacheResolution ?? throw new ArgumentNullException(nameof(cacheResolution)); + ExecutionPlan = executionPlan ?? throw new ArgumentNullException(nameof(executionPlan)); + NestedRequests = nestedRequests.IsDefault ? [] : nestedRequests; + } + + public RenderRequest Request { get; } + + public RecordedRenderGraph Graph { get; } + + public RenderNodeMeasurement Measurement { get; } + + public RegionAnalysis Regions { get; } + + public Rect SelectedOutputBounds { get; } + + public Rect ExecutionTargetBounds { get; } + + public ImmutableArray Roots { get; } + + public IReadOnlyDictionary MaterializationDemands { get; } + + public IReadOnlySet MaterializedFragments { get; } + + public IReadOnlySet PreviewDropEligibleMaterializations { get; } + + public TargetDependencyPlan TargetDependencies { get; } + + public RenderCacheResolution CacheResolution { get; } + + public ExecutionIslandPlan ExecutionPlan { get; } + + public ImmutableArray NestedRequests { get; } + + public bool IsDisposed { get; private set; } + + private static Rect ResolveExecutionTargetBounds( + RecordedRenderGraph graph, + RegionAnalysis regions, + TargetDependencyPlan targetDependencies) + { + Rect result = regions.FinalCommitBounds; + if (regions.TargetAccessRequirements.Count == 0) + return result; + + IReadOnlyDictionary references = graph.Fragments + .ToDictionary( + static fragment => fragment.Id, + static fragment => (RenderFragmentReference)fragment.Payload!); + IReadOnlyDictionary scopes = targetDependencies.Scopes + .ToDictionary(static scope => scope.Id); + var scopesByOwner = targetDependencies.Scopes + .Where(static scope => scope.OwnerFragmentId is not null) + .GroupBy(static scope => scope.OwnerFragmentId!.Value) + .ToDictionary(static group => group.Key, static group => group.ToArray()); + var scopesByEffect = targetDependencies.Steps + .GroupBy(static step => step.FragmentId) + .ToDictionary( + static group => group.Key, + static group => group.Select(static step => step.ScopeId).Distinct().ToArray()); + var tokens = new TargetTokenConnectivity(targetDependencies); + + foreach ((RenderFragmentId fragmentId, RequiredRegion requirement) + in regions.TargetAccessRequirements) + { + if (requirement.IsEmpty) + continue; + + TargetScopeId[] accessScopes = scopesByOwner.TryGetValue(fragmentId, out TargetScopePlan[]? owned) + ? owned.Select(static scope => scope.Id).ToArray() + : scopesByEffect.TryGetValue(fragmentId, out TargetScopeId[]? effected) + ? effected + : throw new InvalidOperationException( + "A target-access requirement has no lowered target scope."); + foreach (TargetScopeId accessScopeId in accessScopes) + { + TargetScopePlan accessScope = scopes[accessScopeId]; + Rect accessBounds = ResolveRequirement(requirement, accessScope); + if (TryMapToRoot( + accessScope, + accessBounds, + scopes, + references, + tokens, + out Rect rootBounds)) + { + result = result.Union(rootBounds); + } + } + } + + return result; + } + + private static Rect ResolveRequirement( + RequiredRegion requirement, + TargetScopePlan scope) + { + if (!requirement.IsFull) + return requirement.Value; + if (scope.ResolvedDomain is not { } domain) + { + throw new InvalidOperationException( + "A Full target-access requirement has no finite owning target domain."); + } + + return domain; + } + + private static bool TryMapToRoot( + TargetScopePlan scope, + Rect bounds, + IReadOnlyDictionary scopes, + IReadOnlyDictionary references, + TargetTokenConnectivity tokens, + out Rect rootBounds) + { + while (scope.ParentId is { } parentId) + { + TargetScopePlan parent = scopes[parentId]; + if (!tokens.ShareTarget(scope, parent)) + { + rootBounds = default; + return false; + } + + if (scope.OwnerFragmentId is not { } ownerId + || !references.TryGetValue(ownerId, out RenderFragmentReference? owner)) + { + throw new InvalidOperationException( + "A non-root target scope has no recorded owner fragment."); + } + + bounds = owner.Payload switch + { + TargetScopeRenderFragmentPayload payload + => payload.Description.Bounds.TransformBounds(bounds), + RawTargetScopeRenderFragmentPayload payload + => payload.Description.Bounds.TransformBounds(bounds), + _ => bounds, + }; + if (parent.ResolvedDomain is { } parentDomain) + bounds = bounds.Intersect(parentDomain); + scope = parent; + } + + rootBounds = bounds; + return true; + } + + private sealed class TargetTokenConnectivity + { + private readonly Dictionary _parents = []; + + public TargetTokenConnectivity(TargetDependencyPlan plan) + { + foreach (TargetScopePlan scope in plan.Scopes) + Add(scope.InitialToken); + foreach (TargetDependencyStep step in plan.Steps) + { + Add(step.InputToken); + Add(step.OutputToken); + Union(step.InputToken, step.OutputToken); + } + } + + public bool ShareTarget(TargetScopePlan first, TargetScopePlan second) + => Find(first.InitialToken) == Find(second.InitialToken); + + private void Add(TargetTokenId token) + => _parents.TryAdd(token, token); + + private TargetTokenId Find(TargetTokenId token) + { + TargetTokenId parent = _parents[token]; + while (parent != _parents[parent]) + parent = _parents[parent]; + + TargetTokenId current = token; + while (current != parent) + { + TargetTokenId next = _parents[current]; + _parents[current] = parent; + current = next; + } + + return parent; + } + + private void Union(TargetTokenId first, TargetTokenId second) + { + TargetTokenId firstRoot = Find(first); + TargetTokenId secondRoot = Find(second); + if (firstRoot != secondRoot) + _parents[secondRoot] = firstRoot; + } + } + + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + foreach (CompiledRenderRequest nestedRequest in NestedRequests.Reverse()) + nestedRequest.Dispose(); + Request.Dispose(); + } +} + +internal sealed class ExecutionIslandPlan +{ + private readonly Dictionary _membershipByFragment; + + public ExecutionIslandPlan( + ImmutableArray islands, + ImmutableArray boundaries) + { + Islands = islands; + Boundaries = boundaries; + _membershipByFragment = []; + var islandIds = new HashSet(); + foreach (ExecutionIsland island in islands) + { + if (!islandIds.Add(island.Id)) + throw new ArgumentException("Execution-island IDs must be unique.", nameof(islands)); + + ValidateIsland(island, nameof(islands)); + for (int index = 0; index < island.Fragments.Length; index++) + { + RenderFragmentId fragmentId = island.Fragments[index]; + bool terminal = index == island.Fragments.Length - 1; + if (!_membershipByFragment.TryAdd( + fragmentId, + new ExecutionIslandMembership(island, island.ShaderRun, terminal))) + { + throw new ArgumentException( + "A fragment cannot belong to more than one execution island.", + nameof(islands)); + } + } + + } + } + + public ImmutableArray Islands { get; } + + public ImmutableArray Boundaries { get; } + + public IEnumerable ShaderRuns + => Islands + .Where(static island => island.ShaderRun is not null) + .Select(static island => island.ShaderRun!); + + public bool TryGetMembership( + RenderFragmentReference fragment, + out ExecutionIslandMembership membership) + { + ArgumentNullException.ThrowIfNull(fragment); + if (fragment.Id is not { } id) + throw new InvalidOperationException("An execution-plan fragment is not committed."); + return _membershipByFragment.TryGetValue(id, out membership); + } + + public ExecutionIslandExecutionLedger CreateExecutionLedger( + RecordedRenderGraph graph, + ImmutableArray roots, + RenderCacheResolution cacheResolution) + => new(this, graph, roots, cacheResolution); + + private static void ValidateIsland(ExecutionIsland island, string parameterName) + { + if (island.Fragments.Distinct().Count() != island.Fragments.Length) + throw new ArgumentException("An execution island cannot contain a fragment more than once.", parameterName); + + if (island.ShaderRun is not { } run) + { + if (island.Fragments.Length != 1) + { + throw new ArgumentException( + "A non-Shader execution island must identify exactly one semantic fragment.", + parameterName); + } + return; + } + + if (!island.Fragments.SequenceEqual(run.Stages.Select(static stage => stage.FragmentId))) + { + throw new ArgumentException( + "A Shader-run island must contain exactly its compiled stages in execution order.", + parameterName); + } + if (run.Output.Id != island.Fragments[^1]) + throw new ArgumentException("A Shader run must publish its final stage.", parameterName); + + RenderFragmentReference current = run.Output; + for (int index = run.Stages.Length - 1; index >= 0; index--) + { + CompiledShaderStage stage = run.Stages[index]; + if (!ReferenceEquals(current, stage.Fragment) + || current.Id != stage.FragmentId + || current.Kind != stage.Kind + || current.Inputs.Length != 1) + { + throw new ArgumentException( + "A Shader run must describe one direct single-input semantic chain.", + parameterName); + } + current = current.Inputs[0]; + } + if (!ReferenceEquals(current, run.Input)) + throw new ArgumentException("A Shader run has a mismatched declared input.", parameterName); + } +} + +internal readonly record struct ExecutionIslandMembership( + ExecutionIsland Island, + CompiledShaderRun? ShaderRun, + bool IsTerminal); + +internal sealed class ExecutionIslandExecutionLedger +{ + private readonly ExecutionIslandPlan _plan; + private readonly Dictionary _expectedCompletionOrder; + private readonly HashSet _active = []; + private readonly HashSet _completed = []; + private int _lastCompletedOrder = -1; + + public ExecutionIslandExecutionLedger( + ExecutionIslandPlan plan, + RecordedRenderGraph graph, + ImmutableArray roots, + RenderCacheResolution cacheResolution) + { + _plan = plan ?? throw new ArgumentNullException(nameof(plan)); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(cacheResolution); + if (roots.IsDefault) + throw new ArgumentException("Publication roots must be initialized.", nameof(roots)); + + var graphReferences = new Dictionary(); + foreach (RecordedRenderFragment recorded in graph.Fragments) + { + if (recorded.Payload is not RenderFragmentReference reference || reference.Id != recorded.Id) + { + throw new InvalidOperationException( + "The execution graph contains a fragment without its committed semantic reference."); + } + graphReferences.Add(recorded.Id, reference); + } + + HashSet cacheHits = cacheResolution.CollectPrunedHitProducers(); + HashSet reachable = GetReachableReferences( + roots, + graphReferences, + cacheHits); + foreach (ExecutionIsland island in plan.Islands) + { + foreach (RenderFragmentId fragmentId in island.Fragments) + { + if (!graphReferences.TryGetValue(fragmentId, out RenderFragmentReference? reference) + || !reachable.Contains(reference)) + { + throw new InvalidOperationException( + "An execution island contains a fragment that is not reachable from publication roots."); + } + } + } + + foreach (RenderFragmentReference reference in reachable) + { + RenderFragmentId id = reference.Id!.Value; + if (cacheHits.Contains(id) + || reference.Kind is RenderFragmentKind.ContributeValues or RenderFragmentKind.MaterializedInput) + { + continue; + } + if (!plan.TryGetMembership(reference, out _)) + { + throw new InvalidOperationException( + $"Executable fragment '{id.Value}' is not assigned to an execution island."); + } + } + + var expected = new List(); + var emitted = new HashSet(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var visiting = new HashSet(ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + Visit(root, cacheHits, expected, emitted, visited, visiting); + + if (emitted.Count != plan.Islands.Length) + { + throw new InvalidOperationException( + "Every planned execution island must be reachable in publication dependency order."); + } + _expectedCompletionOrder = expected + .Select(static (island, index) => (island.Id, index)) + .ToDictionary(static item => item.Id, static item => item.index); + } + + public ExecutionIsland Begin(RenderFragmentReference fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + if (!_plan.TryGetMembership(fragment, out ExecutionIslandMembership membership)) + throw new InvalidOperationException("The executable fragment is not assigned to an execution island."); + if (membership.ShaderRun is not null && !membership.IsTerminal) + { + throw new InvalidOperationException( + "A non-terminal Shader stage cannot execute independently of its compiled island."); + } + + ExecutionIsland island = membership.Island; + if (_completed.Contains(island.Id) || !_active.Add(island.Id)) + throw new InvalidOperationException("An execution island cannot execute more than once."); + return island; + } + + public void Complete(ExecutionIsland island) + { + ArgumentNullException.ThrowIfNull(island); + if (!_active.Remove(island.Id)) + throw new InvalidOperationException("Only an active execution island can complete."); + if (!_completed.Add(island.Id)) + throw new InvalidOperationException("An execution island cannot complete more than once."); + if (!_expectedCompletionOrder.TryGetValue(island.Id, out int order)) + throw new InvalidOperationException("The completed execution island is not part of the request schedule."); + if (order <= _lastCompletedOrder) + { + throw new InvalidOperationException( + "Execution islands completed outside dependency and painter order."); + } + _lastCompletedOrder = order; + } + + public void AbandonActive() + { + _active.Clear(); + } + + public ImmutableArray CaptureActiveIslands() => [.. _active]; + + /// + /// Abandons only the islands that became active after was taken, so a failed + /// nested execution leaves its enclosing islands free to complete. + /// + public void AbandonIslandsSince(ImmutableArray captured) + { + _active.IntersectWith(captured); + } + + public void ValidateCompleted( + bool allowSkippedIslands = false, + IReadOnlySet? regionEmptyIslands = null) + { + if (_active.Count != 0) + throw new InvalidOperationException("An execution island was left active at request completion."); + if (allowSkippedIslands) + return; + + bool hasIncompleteIsland = _expectedCompletionOrder.Keys.Any( + id => !_completed.Contains(id) + && (regionEmptyIslands is null || !regionEmptyIslands.Contains(id))); + if (hasIncompleteIsland) + { + throw new InvalidOperationException( + "Every scheduled execution island must complete before request publication."); + } + } + + private void Visit( + RenderFragmentReference reference, + IReadOnlySet cacheHits, + ICollection expected, + ISet emitted, + ISet visited, + ISet visiting) + { + if (visiting.Contains(reference)) + throw new InvalidOperationException("The execution graph contains a dependency cycle."); + if (!visited.Add(reference)) + return; + visiting.Add(reference); + try + { + RenderFragmentId id = reference.Id + ?? throw new InvalidOperationException("An execution fragment is not committed."); + if (cacheHits.Contains(id)) + return; + + if (_plan.TryGetMembership(reference, out ExecutionIslandMembership membership)) + { + if (membership.ShaderRun is { } run) + { + if (!membership.IsTerminal) + { + throw new InvalidOperationException( + "A non-terminal Shader stage cannot be scheduled as an independent entry point."); + } + Visit(run.Input, cacheHits, expected, emitted, visited, visiting); + } + else + { + foreach (RenderFragmentReference input in EnumerateExecutionInputs(reference)) + Visit(input, cacheHits, expected, emitted, visited, visiting); + } + + if (emitted.Add(membership.Island.Id)) + expected.Add(membership.Island); + return; + } + + foreach (RenderFragmentReference input in EnumerateExecutionInputs(reference)) + Visit(input, cacheHits, expected, emitted, visited, visiting); + } + finally + { + visiting.Remove(reference); + } + } + + private static IEnumerable EnumerateExecutionInputs( + RenderFragmentReference reference) + { + ImmutableArray inputs = reference.ExecutionInputs; + if (reference.Kind == RenderFragmentKind.OpacityMask && inputs.Length > 1) + { + for (int index = 1; index < inputs.Length; index++) + yield return inputs[index]; + yield return inputs[0]; + yield break; + } + + foreach (RenderFragmentReference input in inputs) + yield return input; + } + + private static HashSet GetReachableReferences( + ImmutableArray roots, + IReadOnlyDictionary graphReferences, + IReadOnlySet cacheHits) + { + var result = new HashSet(ReferenceEqualityComparer.Instance); + var pending = new Stack(roots.Reverse()); + while (pending.TryPop(out RenderFragmentReference? reference)) + { + RenderFragmentId id = reference.Id + ?? throw new InvalidOperationException("A publication root is not committed."); + if (!graphReferences.TryGetValue(id, out RenderFragmentReference? graphReference) + || !ReferenceEquals(reference, graphReference)) + { + throw new ArgumentException("A publication root is not part of the recorded graph.", nameof(roots)); + } + if (!result.Add(reference) || cacheHits.Contains(id)) + continue; + ImmutableArray inputs = reference.ExecutionInputs; + for (int index = inputs.Length - 1; index >= 0; index--) + pending.Push(inputs[index]); + } + return result; + } +} + +internal sealed class ExecutionIsland +{ + public ExecutionIsland( + ExecutionIslandId id, + ExecutionIslandKind kind, + ImmutableArray fragments, + bool plansGpuPass, + CompiledShaderRun? shaderRun = null) + { + if (id.Value <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + if (!Enum.IsDefined(kind)) + throw new ArgumentOutOfRangeException(nameof(kind)); + if (fragments.IsDefaultOrEmpty) + throw new ArgumentException("An execution island must contain at least one fragment.", nameof(fragments)); + if ((kind == ExecutionIslandKind.ShaderRun) != (shaderRun is not null)) + { + throw new ArgumentException( + "Only Shader-run islands carry a compiled Shader run.", + nameof(shaderRun)); + } + if (kind == ExecutionIslandKind.ShaderRun && !plansGpuPass) + throw new ArgumentException("A Shader-run island must plan one GPU pass.", nameof(plansGpuPass)); + + Id = id; + Kind = kind; + Fragments = fragments; + PlansGpuPass = plansGpuPass; + ShaderRun = shaderRun; + } + + public ExecutionIslandId Id { get; } + + public ExecutionIslandKind Kind { get; } + + public ImmutableArray Fragments { get; } + + public bool PlansGpuPass { get; } + + public CompiledShaderRun? ShaderRun { get; } +} + +internal sealed class CompiledShaderRun +{ + public CompiledShaderRun( + CompiledShaderRunId id, + RenderFragmentReference input, + RenderFragmentReference output, + ImmutableArray stages, + SkslMergedProgram program, + ShaderRunCoverageSource coverageSource) + { + if (id.Value <= 0) + throw new ArgumentOutOfRangeException(nameof(id)); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(output); + if (stages.IsDefaultOrEmpty) + throw new ArgumentException("A compiled Shader run must contain at least one stage.", nameof(stages)); + ArgumentNullException.ThrowIfNull(program); + if (program.RequiresStandaloneExecution) + { + throw new ArgumentException( + "A backend-overflowing program must remain a compatibility boundary.", + nameof(program)); + } + if (program.StageCount != stages.Length) + throw new ArgumentException("The merged program and semantic stage counts must match.", nameof(program)); + if (!Enum.IsDefined(coverageSource)) + throw new ArgumentOutOfRangeException(nameof(coverageSource)); + + ShaderDescription? wholeSourceHead = stages[0].Description.Kind == ShaderDescriptionKind.WholeSource + ? stages[0].Description + : null; + if (stages.Skip(wholeSourceHead is null ? 0 : 1) + .Any(static stage => stage.Description.Kind == ShaderDescriptionKind.WholeSource)) + { + throw new ArgumentException( + "A WholeSource shader can appear only at the head of a compiled Shader run.", + nameof(stages)); + } + if (wholeSourceHead is not null + && (!output.Bounds.Equals(stages[0].Fragment.Bounds) + || !output.EffectiveScale.Equals(stages[0].Fragment.EffectiveScale))) + { + throw new ArgumentException( + "A WholeSource-headed run must preserve the head stage's output bounds and effective scale.", + nameof(output)); + } + + Id = id; + Input = input; + Output = output; + Stages = stages; + Program = program; + CoverageSource = coverageSource; + WholeSourceHead = wholeSourceHead; + } + + public CompiledShaderRunId Id { get; } + + public RenderFragmentReference Input { get; } + + public RenderFragmentReference Output { get; } + + public ImmutableArray Stages { get; } + + public SkslMergedProgram Program { get; } + + /// Gets the WholeSource head whose implicit source mapping governs the run input, if present. + public ShaderDescription? WholeSourceHead { get; } + + /// + /// Gets the compile-time witness for the run input's coverage provenance. The executor still + /// consumes a materialized value for every run; this witness does not authorize bypassing that + /// runtime materialization. + /// + public ShaderRunCoverageSource CoverageSource { get; } + + public bool IsFused => Stages.Length > 1; +} + +internal sealed record CompiledShaderStage( + RenderFragmentId FragmentId, + RenderFragmentReference Fragment, + RenderFragmentKind Kind, + ShaderDescription Description, + SkslCoverageBehavior CoverageBehavior, + int ProgramStageIndex); + +internal readonly record struct ExecutionIslandBoundary( + RenderFragmentId? BeforeFragmentId, + RenderFragmentId? AfterFragmentId, + ExecutionIslandBoundaryReason Reason, + ImmutableArray BackendLimits); + +internal readonly record struct ExecutionIslandId(int Value); + +internal readonly record struct CompiledShaderRunId(int Value); + +internal enum ExecutionIslandKind : byte +{ + ShaderRun, + Compatibility, + Target, + Readback, +} + +internal enum ShaderRunCoverageSource : byte +{ + MaterializedInput, + PriorShaderRun, + CompatibilityMaterialization, + EngineHomogeneousProof, +} + +internal enum ExecutionIslandBoundaryReason : byte +{ + MaterializedInput, + CoverageResolution, + WholeSourceShader, + Geometry, + Opaque, + LegacyCustomEffect, + TargetCommand, + TargetCapture, + TargetScope, + Layer, + Readback, + UnsafeComposite, + SemanticComposite, + LegacyRawCanvas, + CacheInput, + CacheCapture, + BackendTransition, + ThreeD, + DynamicTopology, + ScopeMismatch, + ScaleTransition, + Branching, + FusionDisabled, + BackendLimit, + FilterEffectSegment, +} + +internal sealed class TargetDependencyPlan +{ + public TargetDependencyPlan( + ImmutableArray steps, + ImmutableArray scopes) + { + Steps = steps; + Scopes = scopes; + } + + public ImmutableArray Steps { get; } + + public ImmutableArray Scopes { get; } +} + +internal readonly record struct TargetScopeId(int Value); + +internal readonly record struct TargetTokenId(int Value); + +internal readonly record struct TargetDependencyStep( + RenderFragmentId FragmentId, + TargetScopeId ScopeId, + TargetTokenId InputToken, + TargetTokenId OutputToken, + RenderValueId? TargetReadValueId, + RenderValueId? ProducedValueId, + TargetDependencyKind Kind); + +internal readonly record struct TargetScopePlan( + TargetScopeId Id, + TargetScopeId? ParentId, + RenderFragmentId? OwnerFragmentId, + TargetTokenId InitialToken, + Rect? ResolvedDomain, + bool IsOrderOnly); + +internal enum TargetDependencyKind : byte +{ + Composite, + Command, + Capture, + ScopeComposite, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs new file mode 100644 index 0000000000..5b2fecabb8 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/ExecutionIslandPlanner.cs @@ -0,0 +1,819 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +/// +/// Partitions the recorded value DAG without executing it. Shader runs are restricted to direct, at-most-one-value, +/// target-independent chains so merging cannot change fan-out, painter order, group opacity, or target-token scope. +/// +internal sealed class ExecutionIslandPlanner +{ + internal static bool HasCompatibleMergeScale( + RenderFragmentReference predecessor, + RenderFragmentReference successor) + => predecessor.EffectiveScale.IsUnbounded + || predecessor.EffectiveScale == successor.EffectiveScale; + + internal static bool HasCompatibleOpacityFusionMetadata( + RenderFragmentReference input, + RenderFragmentReference opacity) + => input.Bounds == opacity.Bounds + && input.EffectiveScale == opacity.EffectiveScale; + + public ExecutionIslandPlan Plan( + RecordedRenderGraph graph, + ImmutableArray roots, + FusionMode fusionMode, + SkslBackendBudget budget) + => Plan(graph, roots, new RenderCacheResolution([]), fusionMode, budget); + + public ExecutionIslandPlan Plan( + RecordedRenderGraph graph, + ImmutableArray roots, + RenderCacheResolution cacheResolution, + FusionMode fusionMode, + SkslBackendBudget budget) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(cacheResolution); + if (roots.IsDefault) + throw new ArgumentException("Publication roots must be initialized.", nameof(roots)); + if (!Enum.IsDefined(fusionMode)) + throw new ArgumentOutOfRangeException(nameof(fusionMode)); + ArgumentNullException.ThrowIfNull(budget); + + HashSet cacheHitIds = cacheResolution.CollectPrunedHitProducers(); + HashSet cacheCaptureIds = + [ + .. cacheResolution.MissCaptures.Select(static capture => capture.ProducerId), + ]; + RenderFragmentReference[] references = GetOrderedReferences(graph, roots, cacheHitIds); + var referenceSet = new HashSet( + references, + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + { + if (!referenceSet.Contains(root)) + throw new ArgumentException("A publication root is not part of the recorded graph.", nameof(roots)); + } + + Dictionary consumerCounts = CountConsumers( + references, + roots, + cacheHitIds); + var stageCandidates = new Dictionary( + ReferenceEqualityComparer.Instance); + var rejectedStageClassifications = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference reference in references) + { + if (cacheHitIds.Contains(GetId(reference))) + continue; + + if (TryCreateStage(reference, out StageCandidate? stage, out ExecutionIslandBoundaryReason reason)) + { + StageCandidate accepted = stage!; + if (fusionMode == FusionMode.Disabled && accepted.IsWholeSourceHeadOnly) + { + rejectedStageClassifications.Add( + reference, + new ExecutionIslandClassification( + ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.WholeSourceShader, + [])); + } + else + { + stageCandidates.Add(reference, accepted); + } + } + else if (reference.Kind is RenderFragmentKind.Shader or RenderFragmentKind.Opacity) + { + rejectedStageClassifications.Add( + reference, + new ExecutionIslandClassification(ExecutionIslandKind.Compatibility, reason, [])); + } + } + + Dictionary successors = BuildMergeableSuccessors( + references, + stageCandidates, + consumerCounts, + cacheCaptureIds); + var predecessors = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((RenderFragmentReference predecessor, RenderFragmentReference successor) in successors) + predecessors.Add(successor, predecessor); + + var drafts = new List(); + var boundaries = new List(); + AddSelectedCacheBoundaries( + references, + cacheHitIds, + cacheCaptureIds, + boundaries); + var compiledFragments = new HashSet(ReferenceEqualityComparer.Instance); + var visitedStages = new HashSet(ReferenceEqualityComparer.Instance); + + foreach (RenderFragmentReference reference in references) + { + if (!stageCandidates.ContainsKey(reference) + || predecessors.ContainsKey(reference) + || visitedStages.Contains(reference)) + { + continue; + } + + List chain = BuildChain( + reference, + stageCandidates, + successors, + visitedStages); + if (!chain.Any(static item => item.Fragment.Kind == RenderFragmentKind.Shader)) + continue; + + IReadOnlyList groups = BuildProgramGroups(chain, fusionMode, budget); + ProgramGroup? previous = null; + foreach (ProgramGroup group in groups) + { + if (previous is not null) + { + ExecutionIslandBoundaryReason splitReason = fusionMode == FusionMode.Disabled + ? ExecutionIslandBoundaryReason.FusionDisabled + : ExecutionIslandBoundaryReason.BackendLimit; + boundaries.Add(new ExecutionIslandBoundary( + GetId(previous.Stages[^1].Fragment), + GetId(group.Stages[0].Fragment), + splitReason, + splitReason == ExecutionIslandBoundaryReason.BackendLimit + ? GetSplitLimits(previous.Stages, group.Stages, budget) + : [])); + } + else + { + AddRunEntryBoundary( + chain[0], + stageCandidates, + consumerCounts, + cacheHitIds, + cacheCaptureIds, + boundaries); + } + + if (group.Program.RequiresStandaloneExecution) + { + StageCandidate standalone = group.Stages.Single(); + rejectedStageClassifications[standalone.Fragment] = new ExecutionIslandClassification( + ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.BackendLimit, + [.. group.Program.OverflowReasons]); + previous = group; + continue; + } + + RenderFragmentReference input = group.Stages[0].Fragment.Inputs.Single(); + RenderFragmentReference output = group.Stages[^1].Fragment; + ShaderRunCoverageSource coverageSource = ResolveCoverageSource( + input, + compiledFragments, + group.Stages, + cacheHitIds); + drafts.Add(new IslandDraft( + GetId(group.Stages[0].Fragment).Value, + ExecutionIslandKind.ShaderRun, + [.. group.Stages.Select(static item => GetId(item.Fragment))], + PlansGpuPass: true, + input, + output, + CreateCompiledStages(group), + group.Program, + coverageSource)); + foreach (StageCandidate stage in group.Stages) + compiledFragments.Add(stage.Fragment); + previous = group; + } + } + + foreach (RenderFragmentReference reference in references) + { + if (compiledFragments.Contains(reference) + || cacheHitIds.Contains(GetId(reference)) + || reference.Kind is RenderFragmentKind.ContributeValues or RenderFragmentKind.MaterializedInput) + { + continue; + } + + if (!TryClassifyExecutionIsland( + reference, + rejectedStageClassifications, + out ExecutionIslandClassification item)) + continue; + + bool requiresReadback = RequiresDeclaredReadback(reference); + if (requiresReadback) + item = item with { Kind = ExecutionIslandKind.Readback }; + + drafts.Add(new IslandDraft( + GetId(reference).Value, + item.Kind, + [GetId(reference)], + PlansGpuPass: PlansGpuPass(reference), + Input: null, + Output: null, + Stages: [], + Program: null, + ShaderRunCoverageSource.CompatibilityMaterialization)); + boundaries.Add(new ExecutionIslandBoundary( + reference.Inputs.IsDefaultOrEmpty ? null : GetId(reference.Inputs[0]), + GetId(reference), + item.Reason, + item.BackendLimits)); + if (requiresReadback && item.Reason != ExecutionIslandBoundaryReason.Readback) + { + boundaries.Add(new ExecutionIslandBoundary( + reference.Inputs.IsDefaultOrEmpty ? null : GetId(reference.Inputs[0]), + GetId(reference), + ExecutionIslandBoundaryReason.Readback, + [])); + } + if (item.Reason == ExecutionIslandBoundaryReason.ThreeD) + { + boundaries.Add(new ExecutionIslandBoundary( + reference.Inputs.IsDefaultOrEmpty ? null : GetId(reference.Inputs[0]), + GetId(reference), + ExecutionIslandBoundaryReason.BackendTransition, + [])); + } + } + + IslandDraft[] orderedDrafts = [.. drafts + .OrderBy(static item => item.AuthoredOrder) + .ThenBy(static item => item.Kind)]; + var islands = ImmutableArray.CreateBuilder(orderedDrafts.Length); + int nextRunId = 0; + for (int index = 0; index < orderedDrafts.Length; index++) + { + IslandDraft draft = orderedDrafts[index]; + CompiledShaderRun? run = null; + if (draft.Kind == ExecutionIslandKind.ShaderRun) + { + run = new CompiledShaderRun( + new CompiledShaderRunId(++nextRunId), + draft.Input!, + draft.Output!, + draft.Stages, + draft.Program!, + draft.CoverageSource); + } + + islands.Add(new ExecutionIsland( + new ExecutionIslandId(index + 1), + draft.Kind, + draft.Fragments, + draft.PlansGpuPass, + run)); + } + + ImmutableArray orderedBoundaries = + [.. boundaries + .Distinct(ExecutionIslandBoundaryComparer.Instance) + .OrderBy(static item => item.AfterFragmentId?.Value ?? long.MinValue) + .ThenBy(static item => item.BeforeFragmentId?.Value ?? long.MinValue) + .ThenBy(static item => item.Reason)]; + return new ExecutionIslandPlan(islands.MoveToImmutable(), orderedBoundaries); + } + + private static RenderFragmentReference[] GetOrderedReferences( + RecordedRenderGraph graph, + ImmutableArray roots, + IReadOnlySet cacheHitIds) + { + var ordered = new RenderFragmentReference[graph.Fragments.Length]; + var all = new HashSet(ReferenceEqualityComparer.Instance); + for (int index = 0; index < graph.Fragments.Length; index++) + { + RecordedRenderFragment recorded = graph.Fragments[index]; + if (recorded.Payload is not RenderFragmentReference reference) + { + throw new InvalidOperationException( + "A recorded fragment is missing its planner-visible semantic reference."); + } + if (reference.Id != recorded.Id) + throw new InvalidOperationException("A recorded fragment reference has a mismatched request ID."); + ordered[index] = reference; + all.Add(reference); + } + + var reachable = new HashSet( + roots, + ReferenceEqualityComparer.Instance); + if (!reachable.IsSubsetOf(all)) + throw new ArgumentException("A publication root is not part of the recorded graph.", nameof(roots)); + for (int index = ordered.Length - 1; index >= 0; index--) + { + RenderFragmentReference reference = ordered[index]; + if (!reachable.Contains(reference)) + continue; + if (cacheHitIds.Contains(GetId(reference))) + continue; + foreach (RenderFragmentReference input in reference.ExecutionInputs) + reachable.Add(input); + } + + return [.. ordered.Where(reachable.Contains)]; + } + + private static Dictionary CountConsumers( + IReadOnlyList references, + ImmutableArray roots, + IReadOnlySet cacheHitIds) + { + var result = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference reference in references) + result.Add(reference, 0); + + foreach (RenderFragmentReference reference in references) + { + if (cacheHitIds.Contains(GetId(reference))) + continue; + foreach (RenderFragmentReference input in reference.ExecutionInputs) + { + if (!result.TryGetValue(input, out int count)) + { + throw new InvalidOperationException( + "An execution-planner input is not part of the recorded request graph."); + } + result[input] = checked(count + 1); + } + } + + foreach (RenderFragmentReference root in roots) + result[root] = checked(result[root] + 1); + return result; + } + + private static Dictionary BuildMergeableSuccessors( + IReadOnlyList references, + IReadOnlyDictionary stages, + IReadOnlyDictionary consumerCounts, + IReadOnlySet cacheCaptureIds) + { + var candidates = new Dictionary>( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference current in references) + { + if (!stages.TryGetValue(current, out StageCandidate? currentStage) + || currentStage.IsWholeSourceHeadOnly + || current.Inputs.Length != 1) + continue; + + RenderFragmentReference input = current.Inputs[0]; + if (!stages.ContainsKey(input)) + continue; + + if (!candidates.TryGetValue(input, out List? values)) + { + values = []; + candidates.Add(input, values); + } + values.Add(current); + } + + var result = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((RenderFragmentReference predecessor, List values) in candidates) + { + if (values.Count != 1 + || consumerCounts[predecessor] != 1 + || cacheCaptureIds.Contains(GetId(predecessor)) + || !HasCompatibleMergeScale(predecessor, values[0])) + { + continue; + } + + result.Add(predecessor, values[0]); + } + return result; + } + + private static List BuildChain( + RenderFragmentReference first, + IReadOnlyDictionary stages, + IReadOnlyDictionary successors, + ISet visited) + { + var result = new List(); + RenderFragmentReference? current = first; + while (current is not null) + { + if (!visited.Add(current)) + throw new InvalidOperationException("The eligible Shader-stage graph contains a cycle."); + result.Add(stages[current]); + current = successors.TryGetValue(current, out RenderFragmentReference? next) ? next : null; + } + return result; + } + + private static IReadOnlyList BuildProgramGroups( + IReadOnlyList chain, + FusionMode fusionMode, + SkslBackendBudget budget) + { + if (fusionMode == FusionMode.Disabled) + { + var disabled = new List(chain.Count); + foreach (StageCandidate stage in chain) + { + SkslMergedProgram program = SkslSnippetMerger.MergeAndSplit([stage.Snippet], budget).Single(); + disabled.Add(new ProgramGroup([stage], program)); + } + return disabled; + } + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + chain.Select(static item => item.Snippet).ToArray(), + budget); + var result = new List(programs.Count); + foreach (SkslMergedProgram program in programs) + { + StageCandidate[] stages = program.Stages + .Select(layout => chain[layout.StageIndex]) + .ToArray(); + result.Add(new ProgramGroup(stages, program)); + } + return result; + } + + private static void AddRunEntryBoundary( + StageCandidate first, + IReadOnlyDictionary stages, + IReadOnlyDictionary consumerCounts, + IReadOnlySet cacheHitIds, + IReadOnlySet cacheCaptureIds, + ICollection boundaries) + { + RenderFragmentReference input = first.Fragment.Inputs.Single(); + RenderFragmentId inputId = GetId(input); + if (cacheHitIds.Contains(inputId) || cacheCaptureIds.Contains(inputId)) + return; + + ExecutionIslandBoundaryReason reason; + if (stages.ContainsKey(input)) + { + if (first.IsWholeSourceHeadOnly) + { + reason = ExecutionIslandBoundaryReason.WholeSourceShader; + } + else if (consumerCounts[input] != 1) + { + reason = ExecutionIslandBoundaryReason.Branching; + } + else if (!HasCompatibleMergeScale(input, first.Fragment)) + { + reason = ExecutionIslandBoundaryReason.ScaleTransition; + } + else + { + throw new InvalidOperationException( + "A mergeable Shader-stage input cannot begin a separate execution chain."); + } + } + else + { + reason = input.Kind == RenderFragmentKind.MaterializedInput + ? ExecutionIslandBoundaryReason.MaterializedInput + : ExecutionIslandBoundaryReason.CoverageResolution; + } + + boundaries.Add(new ExecutionIslandBoundary( + GetId(input), + GetId(first.Fragment), + reason, + [])); + } + + private static ShaderRunCoverageSource ResolveCoverageSource( + RenderFragmentReference input, + IReadOnlySet compiledFragments, + IReadOnlyList stages, + IReadOnlySet cacheHitIds) + { + if (compiledFragments.Contains(input)) + return ShaderRunCoverageSource.PriorShaderRun; + if (input.Kind == RenderFragmentKind.MaterializedInput + || cacheHitIds.Contains(GetId(input))) + return ShaderRunCoverageSource.MaterializedInput; + if (stages.All(static item => + item.Snippet.CoverageBehavior == SkslCoverageBehavior.PremultipliedCoverageHomogeneous)) + { + return ShaderRunCoverageSource.EngineHomogeneousProof; + } + return ShaderRunCoverageSource.CompatibilityMaterialization; + } + + private static void AddSelectedCacheBoundaries( + IReadOnlyList references, + IReadOnlySet cacheHitIds, + IReadOnlySet cacheCaptureIds, + ICollection boundaries) + { + HashSet reachableIds = + [ + .. references.Select(GetId), + ]; + foreach (RenderFragmentId hitId in cacheHitIds) + { + if (reachableIds.Contains(hitId)) + { + boundaries.Add(new ExecutionIslandBoundary( + BeforeFragmentId: null, + AfterFragmentId: hitId, + ExecutionIslandBoundaryReason.CacheInput, + [])); + } + } + + foreach (RenderFragmentId captureId in cacheCaptureIds) + { + if (reachableIds.Contains(captureId)) + { + boundaries.Add(new ExecutionIslandBoundary( + BeforeFragmentId: captureId, + AfterFragmentId: null, + ExecutionIslandBoundaryReason.CacheCapture, + [])); + } + } + } + + private static bool TryCreateStage( + RenderFragmentReference fragment, + out StageCandidate? stage, + out ExecutionIslandBoundaryReason rejectionReason) + { + stage = null; + rejectionReason = ExecutionIslandBoundaryReason.UnsafeComposite; + if (fragment.Kind is not (RenderFragmentKind.Shader or RenderFragmentKind.Opacity)) + return false; + if (fragment.Inputs.Length != 1) + { + rejectionReason = ExecutionIslandBoundaryReason.DynamicTopology; + return false; + } + RenderFragmentReference input = fragment.Inputs[0]; + bool isOptionalStage = + fragment.ValueCardinality.Equals(RenderValueCardinality.ZeroOrOne) + && input.ValueCardinality.Equals(RenderValueCardinality.ZeroOrOne); + if (!fragment.ValueCardinality.Equals(RenderValueCardinality.Single) + && !isOptionalStage) + { + rejectionReason = ExecutionIslandBoundaryReason.DynamicTopology; + return false; + } + if (!fragment.CanBeUsedAsValueInput + || !input.CanBeUsedAsValueInput + || fragment.HasTargetEffects != input.HasTargetEffects + || fragment.HasOpaqueExternalWork != input.HasOpaqueExternalWork) + { + rejectionReason = ExecutionIslandBoundaryReason.ScopeMismatch; + return false; + } + + ShaderDescription description; + SkslCoverageBehavior coverageBehavior; + if (fragment.Kind == RenderFragmentKind.Shader) + { + var payload = (ShaderRenderFragmentPayload?)fragment.Payload; + if (payload is null) + { + rejectionReason = ExecutionIslandBoundaryReason.WholeSourceShader; + return false; + } + description = payload.Description; + coverageBehavior = SkslCoverageBehavior.RequiresResolvedCoverage; + } + else + { + var payload = (OpacityRenderFragmentPayload?)fragment.Payload; + if (payload is null + || payload.Opacity < 0 + || payload.Opacity > 1 + || !HasCompatibleOpacityFusionMetadata(input, fragment)) + { + rejectionReason = ExecutionIslandBoundaryReason.UnsafeComposite; + return false; + } + description = payload.FusionDescription; + coverageBehavior = SkslCoverageBehavior.PremultipliedCoverageHomogeneous; + } + + stage = new StageCandidate( + fragment, + new SkslSnippetStage(description, coverageBehavior), + description.Kind == ShaderDescriptionKind.WholeSource); + return true; + } + + // A segment also collects Skia items and typed suffixes, so a custom effect is only the reason + // the island cannot fuse when the segment actually contains one. + private static ExecutionIslandBoundaryReason SegmentBoundaryReason( + FilterEffectSegmentRenderFragmentPayload payload) + => payload.HasImperativeItem + ? ExecutionIslandBoundaryReason.LegacyCustomEffect + : ExecutionIslandBoundaryReason.FilterEffectSegment; + + private static bool TryClassifyExecutionIsland( + RenderFragmentReference reference, + IReadOnlyDictionary rejectedStageClassifications, + out ExecutionIslandClassification result) + { + if (rejectedStageClassifications.TryGetValue(reference, out result)) + return true; + + result = reference.Kind switch + { + RenderFragmentKind.Opacity => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.SemanticComposite, []), + RenderFragmentKind.Shader => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.WholeSourceShader, []), + RenderFragmentKind.Geometry => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.Geometry, []), + RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + when reference.Payload is OpaqueRenderFragmentPayload opaque + && opaque.Description.BackendBoundary + == RenderBackendBoundary.Graphics3D => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.ThreeD, []), + RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.Opaque, []), + RenderFragmentKind.FilterEffectSegment => new( + ExecutionIslandKind.Compatibility, + SegmentBoundaryReason((FilterEffectSegmentRenderFragmentPayload)reference.Payload!), + []), + RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture => new(ExecutionIslandKind.Target, + ExecutionIslandBoundaryReason.TargetCapture, []), + RenderFragmentKind.Layer => new(ExecutionIslandKind.Target, + ExecutionIslandBoundaryReason.Layer, []), + RenderFragmentKind.TargetLayerScope + or RenderFragmentKind.TargetScope => new(ExecutionIslandKind.Target, + ExecutionIslandBoundaryReason.TargetScope, []), + RenderFragmentKind.RawTargetScope + or RenderFragmentKind.RawTargetCommand => new(ExecutionIslandKind.Target, + ExecutionIslandBoundaryReason.LegacyRawCanvas, []), + RenderFragmentKind.TargetCommand + when ((TargetCommandRenderFragmentPayload)reference.Payload!).Description.Access + == TargetAccess.Readback => new(ExecutionIslandKind.Readback, + ExecutionIslandBoundaryReason.Readback, []), + RenderFragmentKind.TargetCommand => new(ExecutionIslandKind.Target, + ExecutionIslandBoundaryReason.TargetCommand, []), + RenderFragmentKind.Blend + or RenderFragmentKind.OpacityMask => new(ExecutionIslandKind.Compatibility, + ExecutionIslandBoundaryReason.UnsafeComposite, []), + _ => default, + }; + return result != default; + } + + private static bool RequiresDeclaredReadback(RenderFragmentReference reference) + => reference.Payload switch + { + GeometryRenderFragmentPayload geometry => geometry.Description.RequiresReadback, + OpaqueRenderFragmentPayload opaque + => opaque.InputReadbacks.Any(static item => item.RequiresAnyReadback), + TargetCommandRenderFragmentPayload command + => command.Description.Access == TargetAccess.Readback + || command.InputReadbacks.Any(static item => item.RequiresAnyReadback), + _ => false, + }; + + private static bool PlansGpuPass(RenderFragmentReference reference) + => reference.Kind switch + { + RenderFragmentKind.Opacity + or RenderFragmentKind.Blend + or RenderFragmentKind.OpacityMask + or RenderFragmentKind.Shader + or RenderFragmentKind.Geometry + or RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + or RenderFragmentKind.Layer + or RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture => true, + RenderFragmentKind.TargetLayerScope + => ((TargetLayerScopeRenderFragmentPayload)reference.Payload!).Region.Kind + != TargetRegionKind.Empty, + RenderFragmentKind.TargetCommand + => ((TargetCommandRenderFragmentPayload)reference.Payload!).Description.AffectedRegion.Kind + != TargetRegionKind.Empty, + RenderFragmentKind.TargetScope + => ((TargetScopeRenderFragmentPayload)reference.Payload!).Description.IsValueReplayMap, + _ => false, + }; + + private static ImmutableArray GetSplitLimits( + IReadOnlyList previous, + IReadOnlyList current, + SkslBackendBudget budget) + { + SkslMergedProgram combined = SkslSnippetMerger.Merge( + previous.Concat(current.Take(1)).Select(static item => item.Snippet).ToArray()); + var result = ImmutableArray.CreateBuilder(); + if (combined.StageCount > budget.MaxStages) + result.Add(SkslBackendLimit.StageCount); + if (combined.UniformVectorCount > budget.MaxUniformVectors) + result.Add(SkslBackendLimit.UniformVectors); + if (combined.SamplerCount > budget.MaxSamplers) + result.Add(SkslBackendLimit.Samplers); + if (combined.ChildCount > budget.MaxChildren) + result.Add(SkslBackendLimit.Children); + if (combined.SourceByteCount > budget.MaxSourceBytes) + result.Add(SkslBackendLimit.SourceBytes); + if (combined.ProgramTokenCount > budget.MaxProgramTokens) + result.Add(SkslBackendLimit.ProgramTokens); + return result.ToImmutable(); + } + + private static ImmutableArray CreateCompiledStages(ProgramGroup group) + { + if (group.Stages.Count != group.Program.Stages.Count) + throw new InvalidOperationException("A merged program lost its semantic stage mapping."); + + var result = ImmutableArray.CreateBuilder(group.Stages.Count); + for (int index = 0; index < group.Stages.Count; index++) + { + result.Add(group.Stages[index].ToCompiledStage( + group.Program.Stages[index].StageIndex)); + } + return result.MoveToImmutable(); + } + + private static RenderFragmentId GetId(RenderFragmentReference reference) + => reference.Id + ?? throw new InvalidOperationException("An execution-planner fragment is not committed."); + + private sealed record StageCandidate( + RenderFragmentReference Fragment, + SkslSnippetStage Snippet, + bool IsWholeSourceHeadOnly) + { + public CompiledShaderStage ToCompiledStage(int programStageIndex) + => new( + GetId(Fragment), + Fragment, + Fragment.Kind, + Snippet.Description, + Snippet.CoverageBehavior, + programStageIndex); + } + + private sealed record ProgramGroup( + IReadOnlyList Stages, + SkslMergedProgram Program); + + private sealed record IslandDraft( + long AuthoredOrder, + ExecutionIslandKind Kind, + ImmutableArray Fragments, + bool PlansGpuPass, + RenderFragmentReference? Input, + RenderFragmentReference? Output, + ImmutableArray Stages, + SkslMergedProgram? Program, + ShaderRunCoverageSource CoverageSource); + + private readonly record struct ExecutionIslandClassification( + ExecutionIslandKind Kind, + ExecutionIslandBoundaryReason Reason, + ImmutableArray BackendLimits); + + private sealed class ExecutionIslandBoundaryComparer : IEqualityComparer + { + public static ExecutionIslandBoundaryComparer Instance { get; } = new(); + + public bool Equals(ExecutionIslandBoundary x, ExecutionIslandBoundary y) + => x.BeforeFragmentId == y.BeforeFragmentId + && x.AfterFragmentId == y.AfterFragmentId + && x.Reason == y.Reason + && x.BackendLimits.AsSpan().SequenceEqual(y.BackendLimits.AsSpan()); + + public int GetHashCode(ExecutionIslandBoundary obj) + { + var hash = new HashCode(); + hash.Add(obj.BeforeFragmentId); + hash.Add(obj.AfterFragmentId); + hash.Add(obj.Reason); + foreach (SkslBackendLimit limit in obj.BackendLimits) + hash.Add(limit); + return hash.ToHashCode(); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/LegacyFilterSamplingSupport.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/LegacyFilterSamplingSupport.cs new file mode 100644 index 0000000000..8ffff367a1 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/LegacyFilterSamplingSupport.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +/// +/// Resolves the input region a recorded legacy filter segment reads to produce a requested output region. +/// +internal static class LegacyFilterSamplingSupport +{ + /// + /// Maps backward through every item of the segment. + /// + /// + /// when the segment is not a pure Skia chain, when an item declares no proven + /// sampling footprint, or when a mapped region is unusable; the caller must then require the complete + /// input. A footprint is never inferred from the forward bounds items, which may be narrower than what + /// the filter reads, as Erode is behind its identity forward map. + /// + public static bool TryResolveSampledInput(ImmutableArray items, Rect output, out Rect input) + { + input = default; + if (items.IsDefaultOrEmpty || !IsUsable(output)) + return false; + + Rect region = output; + for (int index = items.Length - 1; index >= 0; index--) + { + if (items[index] is not IFEItem_Skia skia + || !skia.TryTransformSamplingBounds(region, out Rect sampled) + || !IsUsable(sampled)) + { + return false; + } + + region = sampled; + } + + input = region; + return true; + } + + private static bool IsUsable(Rect rect) + => float.IsFinite(rect.X) + && float.IsFinite(rect.Y) + && float.IsFinite(rect.Width) + && float.IsFinite(rect.Height) + && rect.Width >= 0 + && rect.Height >= 0; +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/NestedRenderTargetBinding.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/NestedRenderTargetBinding.cs new file mode 100644 index 0000000000..23ea2f617a --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/NestedRenderTargetBinding.cs @@ -0,0 +1,237 @@ +using Beutl.Graphics.Backend; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +/// +/// Owns the target produced by one nested render request until its request family completes. +/// The binding is created while recording, populated only by the executor, and can be consumed +/// only through a request-declared resource scope. +/// +internal sealed class NestedRenderTargetBinding : IDisposable +{ + private RenderTargetLease? _lease; + private NestedRenderTargetBindingState _state; + + public Rect LogicalBounds { get; private set; } + + public float Density { get; private set; } + + public PixelRect DeviceBounds { get; private set; } + + public bool IsReady => _state == NestedRenderTargetBindingState.Ready; + + public bool IsDisposed => _state == NestedRenderTargetBindingState.Disposed; + + public void Stage( + RenderTargetLease lease, + Rect logicalBounds, + float density) + { + ArgumentNullException.ThrowIfNull(lease); + ObjectDisposedException.ThrowIf(IsDisposed, this); + if (_state != NestedRenderTargetBindingState.Empty) + throw new InvalidOperationException("A nested render target can be staged only once."); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(logicalBounds, nameof(logicalBounds)); + if (!float.IsFinite(density) || density <= 0) + throw new ArgumentOutOfRangeException(nameof(density)); + + PixelRect deviceBounds = PixelRect.FromRect(logicalBounds, density); + if (deviceBounds.Size != new PixelSize(lease.Target.Width, lease.Target.Height)) + { + throw new ArgumentException( + "The nested target lease does not match the declared logical bounds and density.", + nameof(lease)); + } + + _lease = lease; + LogicalBounds = logicalBounds; + Density = density; + DeviceBounds = deviceBounds; + _state = NestedRenderTargetBindingState.Staged; + } + + public void PrepareForSampling() + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + if (_state != NestedRenderTargetBindingState.Staged || _lease is null) + throw new InvalidOperationException("The nested render target is not staged."); + + // The binding can later expose either an SKImage or its Vulkan texture. Until the consumer is known, + // retain the cross-backend synchronization required by the latter. + _lease.Target.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + _state = NestedRenderTargetBindingState.Ready; + } + + public void Reject() + { + if (_state is NestedRenderTargetBindingState.Disposed or NestedRenderTargetBindingState.Empty) + return; + + _state = NestedRenderTargetBindingState.Rejected; + } + + public ITexture2D? GetTexture(Rect expectedLogicalBounds, float expectedDensity) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(expectedLogicalBounds, nameof(expectedLogicalBounds)); + if (!float.IsFinite(expectedDensity) || expectedDensity <= 0f) + throw new ArgumentOutOfRangeException(nameof(expectedDensity)); + if (_state is NestedRenderTargetBindingState.Empty or NestedRenderTargetBindingState.Rejected) + return null; + if (_state != NestedRenderTargetBindingState.Ready || _lease is null) + throw new InvalidOperationException("The nested render target is not ready for sampling."); + if (LogicalBounds != expectedLogicalBounds) + { + throw new InvalidOperationException( + "The prepared nested target does not match the texture source's logical domain."); + } + if (Density != expectedDensity) + { + throw new InvalidOperationException( + "The prepared nested target density does not match the consuming 3D surface density."); + } + + return _lease.Target.Texture; + } + + public void UseImage( + RenderExecutionSessionToken token, + Action use) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(use); + ObjectDisposedException.ThrowIf(IsDisposed, this); + if (_state != NestedRenderTargetBindingState.Ready || _lease is null) + throw new InvalidOperationException("The nested render target is not ready for consumption."); + + using SKImage image = _lease.Target.Value.Snapshot(); + var view = new NestedRenderTargetImage(token, image, LogicalBounds, Density, DeviceBounds); + token.AuthorizeResource(image, () => use(view)); + } + + public void Dispose() + { + if (IsDisposed) + return; + + RenderTargetLease? lease = Interlocked.Exchange(ref _lease, null); + _state = NestedRenderTargetBindingState.Disposed; + lease?.Dispose(); + } +} + +internal sealed class NestedRenderTargetImage +{ + private readonly RenderExecutionSessionToken _token; + private readonly SKImage _image; + + public NestedRenderTargetImage( + RenderExecutionSessionToken token, + SKImage image, + Rect logicalBounds, + float density, + PixelRect deviceBounds) + { + _token = token; + _image = image; + LogicalBounds = logicalBounds; + Density = density; + DeviceBounds = deviceBounds; + } + + public Rect LogicalBounds + { + get { _token.ThrowIfInactive(); return field; } + } + + public float Density + { + get { _token.ThrowIfInactive(); return field; } + } + + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return field; } + } + + public Rect RasterBounds + { + get + { + _token.ThrowIfInactive(); + return DeviceBounds.ToRect(Density); + } + } + + public void Draw(ImmediateCanvas canvas) + { + ArgumentNullException.ThrowIfNull(canvas); + _token.VerifyActiveCanvas(canvas); + canvas.DrawImageScaled(_image, RasterBounds); + } +} + +internal enum NestedRenderTargetBindingState : byte +{ + Empty, + Staged, + Ready, + Rejected, + Disposed, +} + +internal static class NestedRenderTargetBindingScope +{ + private static readonly AsyncLocal s_current = new(); + + public static void Use(object identity, NestedRenderTargetBinding binding, Action use) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(binding); + ArgumentNullException.ThrowIfNull(use); + + Scope? previous = s_current.Value; + s_current.Value = new Scope(identity, binding, previous); + try + { + use(); + } + finally + { + s_current.Value = previous; + } + } + + public static bool TryGet(object identity, out NestedRenderTargetBinding binding) + { + ArgumentNullException.ThrowIfNull(identity); + for (Scope? current = s_current.Value; current is not null; current = current.Parent) + { + if (ReferenceEquals(current.Identity, identity)) + { + binding = current.Binding; + return true; + } + } + + binding = null!; + return false; + } + + private sealed record Scope( + object Identity, + NestedRenderTargetBinding Binding, + Scope? Parent); +} + +internal sealed record RecordedNestedRenderTarget( + RecordedNestedRenderRequest Recording, + RenderResource Binding, + NestedRenderTargetBinding Target) +{ + public RenderRequest Request => Recording.Request; + + public RecordedRenderGraph Graph => Recording.Graph; +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/NodeRecordingTransaction.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/NodeRecordingTransaction.cs new file mode 100644 index 0000000000..0fed39216c --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/NodeRecordingTransaction.cs @@ -0,0 +1,609 @@ +using System.Collections.Immutable; + +namespace Beutl.Graphics.Rendering; + +internal sealed class NodeRecordingTransaction : IRenderFragmentHandleOwner +{ + private readonly IRenderRequestRecordingHost _host; + private readonly NodeRecordingTransaction? _parent; + private readonly object _origin; + private readonly HashSet _ownedReferences = + new(ReferenceEqualityComparer.Instance); + private readonly List _fragments = []; + private readonly List _publications = []; + private readonly List _resources = []; + private readonly List _nestedRequests = []; + private readonly List _builtInBackdropBindings = []; + private HashSet? _dropped; + private bool _cacheDisabled; + + public NodeRecordingTransaction( + IRenderRequestRecordingHost host, + object origin, + IEnumerable inputs, + NodeRecordingTransaction? parent = null) + { + _host = host; + _parent = parent; + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + ArgumentNullException.ThrowIfNull(inputs); + + var facades = new List(); + foreach (RenderFragmentReference input in inputs) + { + ArgumentNullException.ThrowIfNull(input); + _ownedReferences.Add(input); + facades.Add(new RenderFragmentHandle(this, input)); + } + + Inputs = facades; + } + + public IReadOnlyList Inputs { get; } + + public RenderRequest Request => _host.Request; + + public int PublicationCount + { + get + { + VerifyActive(); + return _publications.Count; + } + } + + // Disablement reaches the nodes recorded inside this checkpoint and nobody else. A committed child does + // not mark its parent, so which sibling ran first cannot change whether the others may be cached. + public bool IsRenderCacheEnabled + => State == NodeRecordingTransactionState.Active + && !_cacheDisabled + && (_parent?.IsRenderCacheEnabled ?? _host.IsRenderCacheEnabled); + + public NodeRecordingTransactionState State { get; private set; } + + public RenderFragmentHandle CreateFragment( + RenderFragmentKind kind, + Rect bounds, + EffectiveScale effectiveScale, + RenderValueCardinality valueCardinality, + bool contributesValuesToTarget, + bool canBeUsedAsValueInput, + bool hasTargetEffects, + bool hasOpaqueExternalWork, + IEnumerable? inputs, + object? payload, + Func? hitTest, + RenderFragmentBoundsRequirement boundsRequirement = RenderFragmentBoundsRequirement.Finite, + bool hasDirectSymbolicBoundsDependency = false) + { + VerifyActive(); + ImmutableArray inputCopy = inputs is null ? [] : [.. inputs]; + foreach (RenderFragmentReference input in inputCopy) + { + VerifyOwns(input); + } + + var reference = new RenderFragmentReference( + kind, + bounds, + effectiveScale, + valueCardinality, + contributesValuesToTarget, + canBeUsedAsValueInput, + hasTargetEffects, + hasOpaqueExternalWork, + inputCopy, + payload, + hitTest, + boundsRequirement, + hasDirectSymbolicBoundsDependency); + _ownedReferences.Add(reference); + _fragments.Add(new RecordedRenderFragmentEntry(reference, _origin, "RenderNode.Process")); + return new RenderFragmentHandle(this, reference); + } + + public RenderFragmentReference GetReference(RenderFragmentHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + return handle.GetReference(this); + } + + public ImmutableArray GetReferences( + IEnumerable handles, + string parameterName) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(handles, parameterName); + var result = ImmutableArray.CreateBuilder(); + foreach (RenderFragmentHandle handle in handles) + { + if (handle is null) + throw new ArgumentException("A fragment sequence cannot contain null handles.", parameterName); + result.Add(handle.GetReference(this)); + } + + return result.ToImmutable(); + } + + public void Publish(RenderFragmentHandle handle) + { + PublishCore(GetReference(handle)); + } + + public void PassThrough() + { + VerifyActive(); + foreach (RenderFragmentHandle input in Inputs) + { + PublishCore(input.GetReference(this)); + } + } + + public void Drop(RenderFragmentHandle fragment) + { + RenderFragmentReference reference = GetReference(fragment); + if (_publications.Contains(reference)) + { + throw new InvalidOperationException( + "The render fragment was already published and cannot be dropped."); + } + + (_dropped ??= new HashSet(ReferenceEqualityComparer.Instance)) + .Add(reference); + } + + public void DisableRenderCache() + { + VerifyActive(); + _cacheDisabled = true; + } + + public RenderResource Own(T resource) + where T : class, IDisposable + { + VerifyActive(); + RenderResource token = Request.Options.Owner.ResourceRegistry.RegisterOwned(resource); + _resources.Add(token); + return token; + } + + public RenderResource Borrow(T resource) + where T : class + { + VerifyActive(); + RenderResource token = Request.Options.Owner.ResourceRegistry.RegisterBorrowed(resource); + _resources.Add(token); + return token; + } + + public void RollbackResources(IReadOnlyList resources) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(resources); + + var transactionIndices = new int[resources.Count]; + var claimed = new HashSet(); + for (int resourceIndex = resources.Count - 1; resourceIndex >= 0; resourceIndex--) + { + RenderResource resource = resources[resourceIndex]; + int transactionIndex = -1; + for (int candidate = _resources.Count - 1; candidate >= 0; candidate--) + { + if (!claimed.Contains(candidate) && ReferenceEquals(_resources[candidate], resource)) + { + transactionIndex = candidate; + break; + } + } + + if (transactionIndex < 0 || !claimed.Add(transactionIndex)) + { + throw new InvalidOperationException( + "The render resource does not belong to this recording transaction."); + } + + transactionIndices[resourceIndex] = transactionIndex; + } + + List? failures = null; + for (int resourceIndex = resources.Count - 1; resourceIndex >= 0; resourceIndex--) + { + RenderResource resource = resources[resourceIndex]; + int transactionIndex = transactionIndices[resourceIndex]; + _resources.RemoveAt(transactionIndex); + for (int earlier = 0; earlier < resourceIndex; earlier++) + { + if (transactionIndices[earlier] > transactionIndex) + transactionIndices[earlier]--; + } + + try + { + if (resource.RegistrationState == RenderResourceRegistrationState.Pending) + Request.Options.Owner.ResourceRegistry.Rollback(resource); + else if (resource.RegistrationState == RenderResourceRegistrationState.Committed) + Request.Options.Owner.ResourceRegistry.Release(resource); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is not null) + throw new AggregateException("One or more render resources failed to roll back.", failures); + } + + public Exception? RollbackResourcesAndCapture( + IReadOnlyList resources, + Exception primaryFailure) + { + ArgumentNullException.ThrowIfNull(primaryFailure); + Request.Options.Owner.RecordPrimaryFailure(primaryFailure); + try + { + RollbackResources(resources); + } + catch (AggregateException ex) + { + foreach (Exception cleanupFailure in ex.InnerExceptions) + Request.Options.Owner.RecordCleanupFailure(cleanupFailure); + return ex; + } + catch (Exception ex) + { + Request.Options.Owner.RecordCleanupFailure(ex); + return ex; + } + + return null; + } + + public IReadOnlyList RecordNode( + RenderNode node, + IReadOnlyList inputs, + bool subtree) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(node); + ImmutableArray inputReferences = GetReferences(inputs, nameof(inputs)); + IReadOnlyList outputs = + _host.RecordNode(this, node, inputReferences, subtree); + return MapReferences(outputs); + } + + public RecordedNestedRenderRequest RecordNestedRequest( + RenderNode root, + RenderRequestOptions options) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(root); + ArgumentNullException.ThrowIfNull(options); + RecordedNestedRenderRequest nested = _host.RecordNestedRequest(root, options); + _nestedRequests.Add(nested); + return nested; + } + + public void BindBuiltInBackdrop( + object identity, + RenderFragmentHandle capture) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(identity); + RenderFragmentReference reference = GetReference(capture); + if (reference.Kind is not (RenderFragmentKind.TargetCapture or RenderFragmentKind.BuiltInBackdropCapture)) + { + throw new ArgumentException( + "A built-in backdrop binding requires a target-capture fragment.", + nameof(capture)); + } + + _builtInBackdropBindings.RemoveAll(binding => ReferenceEquals(binding.Identity, identity)); + _builtInBackdropBindings.Add(new BuiltInBackdropBinding(identity, reference)); + } + + public bool TryGetBuiltInBackdrop( + object identity, + out RenderFragmentHandle? handle) + { + VerifyActive(); + ArgumentNullException.ThrowIfNull(identity); + if (TryGetBuiltInBackdropReference(identity, out RenderFragmentReference? reference)) + { + handle = MapReference(reference!); + return true; + } + + handle = null; + return false; + } + + public ImmutableArray Commit() + { + VerifyActive(); + ImmutableArray fragments = [.. _fragments]; + HashSet reachable = SelectReachableFragments(); + ValidateNoOrphanedTargetEffects(reachable); + ValidatePublicationFanOut(reachable); + var commit = new NodeRecordingCommit( + fragments, + [.. _publications], + [.. _resources], + [.. _nestedRequests], + [.. _builtInBackdropBindings], + _dropped is null ? [] : [.. _dropped]); + + try + { + if (_parent is null) + _host.Commit(commit); + else + _parent.Absorb(commit); + + State = NodeRecordingTransactionState.Committed; + return commit.Publications; + } + catch (Exception ex) + { + Rollback(ex); + throw; + } + } + + public void Rollback(Exception primaryFailure) + { + ArgumentNullException.ThrowIfNull(primaryFailure); + if (State != NodeRecordingTransactionState.Active) + { + Request.Options.Owner.RecordPrimaryFailure(primaryFailure); + Request.Options.Owner.ThrowIfFailed(); + return; + } + + State = NodeRecordingTransactionState.RolledBack; + Request.Options.Owner.RecordPrimaryFailure(primaryFailure); + for (int index = _resources.Count - 1; index >= 0; index--) + { + try + { + RenderResource resource = _resources[index]; + if (resource.RegistrationState == RenderResourceRegistrationState.Pending) + Request.Options.Owner.ResourceRegistry.Rollback(resource); + else + Request.Options.Owner.ResourceRegistry.Release(resource); + } + catch (Exception ex) + { + Request.Options.Owner.RecordCleanupFailure(ex); + } + } + + + for (int index = _nestedRequests.Count - 1; index >= 0; index--) + { + try + { + _nestedRequests[index].Request.Dispose(); + } + catch (Exception ex) + { + Request.Options.Owner.RecordCleanupFailure(ex); + } + } + + Request.Options.Owner.ThrowIfFailed(); + } + + public void VerifyActive() + { + if (State != NodeRecordingTransactionState.Active) + { + throw new InvalidOperationException( + "The render-node recording context and its fragment handles are no longer active."); + } + } + + public void VerifyOwns(RenderFragmentReference reference) + { + VerifyActive(); + if (!_ownedReferences.Contains(reference)) + { + throw new InvalidOperationException( + "The render fragment belongs to a different recording transaction."); + } + } + + private IReadOnlyList MapReferences( + IEnumerable references) + { + VerifyActive(); + var result = new List(); + foreach (RenderFragmentReference reference in references) + { + _ownedReferences.Add(reference); + result.Add(new RenderFragmentHandle(this, reference)); + } + + return result; + } + + private void Absorb(NodeRecordingCommit child) + { + VerifyActive(); + _fragments.AddRange(child.Fragments); + _resources.AddRange(child.Resources); + _nestedRequests.AddRange(child.NestedRequests); + if (!child.Dropped.IsEmpty) + { + _dropped ??= new HashSet(ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference dropped in child.Dropped) + _dropped.Add(dropped); + } + + foreach (BuiltInBackdropBinding binding in child.BuiltInBackdropBindings) + { + _builtInBackdropBindings.RemoveAll(item => ReferenceEquals(item.Identity, binding.Identity)); + _builtInBackdropBindings.Add(binding); + } + } + + private HashSet SelectReachableFragments() + { + var reachable = new HashSet( + _publications, + ReferenceEqualityComparer.Instance); + for (int index = _fragments.Count - 1; index >= 0; index--) + { + RenderFragmentReference reference = _fragments[index].Reference; + if (!reachable.Contains(reference)) + continue; + + foreach (RenderFragmentReference input in reference.Inputs) + reachable.Add(input); + } + + return reachable; + } + + // Drop is not transitive and a parent never receives handles to a child's internal fragments. + private void ValidateNoOrphanedTargetEffects( + HashSet reachable) + { + foreach (RecordedRenderFragmentEntry entry in _fragments) + { + RenderFragmentReference reference = entry.Reference; + if (!ReferenceEquals(entry.Origin, _origin) + || !IsTargetEffect(reference.Kind) + || reachable.Contains(reference) + || _dropped?.Contains(reference) == true) + { + continue; + } + + throw new InvalidOperationException( + "A recorded target-effect fragment was neither published nor consumed. " + + "Publish it, wrap it in a fragment you publish, or call Drop to abandon it " + + $"deliberately. Fragment kind: {reference.Kind}; recorded by: " + + $"{entry.Origin.GetType().FullName}."); + } + } + + private static bool IsTargetEffect(RenderFragmentKind kind) + => kind is RenderFragmentKind.TargetCommand + or RenderFragmentKind.RawTargetCommand + or RenderFragmentKind.TargetScope + or RenderFragmentKind.RawTargetScope + or RenderFragmentKind.TargetLayerScope; + + private void PublishCore(RenderFragmentReference reference) + { + if (_dropped?.Contains(reference) == true) + { + throw new InvalidOperationException( + "The render fragment was already dropped and cannot be published."); + } + + _publications.Add(reference); + } + + private void ValidatePublicationFanOut( + HashSet reachable) + { + var counts = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (RecordedRenderFragmentEntry entry in _fragments) + { + if (!reachable.Contains(entry.Reference)) + continue; + + foreach (RenderFragmentReference input in entry.Reference.Inputs) + CountUse(input, counts); + } + + foreach (RenderFragmentReference publication in _publications) + CountUse(publication, counts); + } + + private static void CountUse( + RenderFragmentReference reference, + Dictionary counts) + { + counts.TryGetValue(reference, out int count); + count++; + counts[reference] = count; + if (count > 1 && !reference.AllowsFanOut) + { + throw new InvalidOperationException( + "A target-effect render fragment cannot be consumed or published more than once."); + } + } + + private bool TryGetBuiltInBackdropReference( + object identity, + out RenderFragmentReference? reference) + { + VerifyActive(); + for (int index = _builtInBackdropBindings.Count - 1; index >= 0; index--) + { + BuiltInBackdropBinding binding = _builtInBackdropBindings[index]; + if (ReferenceEquals(binding.Identity, identity)) + { + reference = binding.Reference; + return true; + } + } + + if (_parent is not null) + return _parent.TryGetBuiltInBackdropReference(identity, out reference); + return Request.Options.Owner.TryGetBuiltInBackdrop(identity, out reference); + } + + private RenderFragmentHandle MapReference(RenderFragmentReference reference) + { + VerifyActive(); + _ownedReferences.Add(reference); + return new RenderFragmentHandle(this, reference); + } +} + +internal interface IRenderRequestRecordingHost +{ + RenderRequest Request { get; } + + bool IsRenderCacheEnabled { get; } + + IReadOnlyList RecordNode( + NodeRecordingTransaction parent, + RenderNode node, + IReadOnlyList inputs, + bool subtree); + + RecordedNestedRenderRequest RecordNestedRequest( + RenderNode root, + RenderRequestOptions options); + + void Commit(NodeRecordingCommit commit); +} + +internal sealed record NodeRecordingCommit( + ImmutableArray Fragments, + ImmutableArray Publications, + ImmutableArray Resources, + ImmutableArray NestedRequests, + ImmutableArray BuiltInBackdropBindings, + ImmutableArray Dropped); + +internal sealed record RecordedRenderFragmentEntry( + RenderFragmentReference Reference, + object Origin, + string Role); + +internal sealed record BuiltInBackdropBinding( + object Identity, + RenderFragmentReference Reference); + +internal enum NodeRecordingTransactionState : byte +{ + Active, + Committed, + RolledBack, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs new file mode 100644 index 0000000000..ca33ef8159 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/ProgramCache.cs @@ -0,0 +1,806 @@ +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text; + +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; + +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +/// +/// Identifies the backend lifetime and compile contract in which a merged shader program is valid. +/// +internal sealed class ProgramCacheContextKey : IEquatable +{ + public ProgramCacheContextKey( + object deviceIdentity, + object contextIdentity, + object backendCapabilityClass, + string colorAlphaFormatContract, + object compileOptionsIdentity) + { + DeviceIdentity = deviceIdentity ?? throw new ArgumentNullException(nameof(deviceIdentity)); + ContextIdentity = contextIdentity ?? throw new ArgumentNullException(nameof(contextIdentity)); + BackendCapabilityClass = backendCapabilityClass + ?? throw new ArgumentNullException(nameof(backendCapabilityClass)); + ColorAlphaFormatContract = colorAlphaFormatContract + ?? throw new ArgumentNullException(nameof(colorAlphaFormatContract)); + CompileOptionsIdentity = compileOptionsIdentity + ?? throw new ArgumentNullException(nameof(compileOptionsIdentity)); + } + + public object DeviceIdentity { get; } + + public object ContextIdentity { get; } + + public object BackendCapabilityClass { get; } + + public string ColorAlphaFormatContract { get; } + + public object CompileOptionsIdentity { get; } + + public bool Equals(ProgramCacheContextKey? other) + => other is not null + && Equals(DeviceIdentity, other.DeviceIdentity) + && Equals(ContextIdentity, other.ContextIdentity) + && Equals(BackendCapabilityClass, other.BackendCapabilityClass) + && string.Equals( + ColorAlphaFormatContract, + other.ColorAlphaFormatContract, + StringComparison.Ordinal) + && Equals(CompileOptionsIdentity, other.CompileOptionsIdentity); + + public override bool Equals(object? obj) + => obj is ProgramCacheContextKey other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine( + DeviceIdentity, + ContextIdentity, + BackendCapabilityClass, + ColorAlphaFormatContract, + CompileOptionsIdentity); +} + +internal readonly record struct ProgramCacheStatistics( + long Hits, + long Misses, + long Creations, + long Evictions, + int RetainedPrograms, + long RetainedBytes); + +/// +/// Owns one backend-validated immutable runtime effect. Runtime values live in fresh +/// and collections for each +/// execution lease, so no binding can leak between frames. A runtime builder cannot be used here because +/// disposing it also disposes the supplied effect. +/// +internal sealed class CachedSkRuntimeEffect : IDisposable +{ + private CachedSkRuntimeEffect(SKRuntimeEffect effect, int retainedBytes) + { + Effect = effect; + RetainedBytes = retainedBytes; + } + + public SKRuntimeEffect Effect { get; } + + public int RetainedBytes { get; } + + public static CachedSkRuntimeEffect Create(SkslMergedProgram program) + { + ArgumentNullException.ThrowIfNull(program); + return Create(program.Source, program.SourceByteCount); + } + + public static CachedSkRuntimeEffect Create(string source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + return Create(source, Encoding.UTF8.GetByteCount(source)); + } + + private static CachedSkRuntimeEffect Create(string source, int retainedBytes) + { + SKRuntimeEffect? effect = SKRuntimeEffect.CreateShader(source, out string? errorText); + if (effect is null || !string.IsNullOrWhiteSpace(errorText)) + { + effect?.Dispose(); + throw new InvalidOperationException( + $"SkSL program validation failed: {errorText ?? "the backend returned no program"}"); + } + + return new CachedSkRuntimeEffect(effect, Math.Max(1, retainedBytes)); + } + + public void Dispose() => Effect.Dispose(); +} + +internal static class SkRuntimeEffectProgramCache +{ + private const long DefaultRetainedByteBudget = 16 * 1024 * 1024; + private const string ColorAlphaFormatContract = "linear-premultiplied-rgba16f"; + private static readonly object s_cpuDestinationContext = new(); + private static readonly object s_defaultCompileOptions = new(); + private static readonly ConditionalWeakTable s_destinationContextIdentities = new(); + + public static ProgramCache Create() + => new( + resetRuntimeBindings: static _ => { }, + retainedByteSize: static program => program.RetainedBytes, + maxRetainedBytes: DefaultRetainedByteBudget, + shareLeasedPrograms: true); + + public static ProgramCacheContextKey CreateContextKey( + RenderCacheDeviceContextIdentity context, + SkslBackendBudget budget) + { + ArgumentNullException.ThrowIfNull(budget); + context.ThrowIfUninitialized(nameof(context)); + return new ProgramCacheContextKey( + context.DeviceIdentity, + context.ContextIdentity, + budget.CapabilityClass, + ColorAlphaFormatContract, + s_defaultCompileOptions); + } + + public static ProgramCacheLease Acquire( + ProgramCache cache, + string source, + SkslBackendBudget budget, + ProgramCacheContextKey context) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentNullException.ThrowIfNull(budget); + ArgumentNullException.ThrowIfNull(context); + ShaderProgramIdentity identity = ShaderProgramIdentity.CreateStandaloneSksl( + source, + budget); + return cache.GetOrCreate( + identity, + context, + source, + static value => CachedSkRuntimeEffect.Create(value)); + } + + public static ProgramCacheLease AcquireForDestination( + ProgramCache cache, + RenderTarget destination, + string source) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(destination); + destination.VerifyAccess(); + GRRecordingContext? graphicsContext = destination.RawValue.Context; + object contextIdentity = graphicsContext is null + ? s_cpuDestinationContext + : s_destinationContextIdentities.GetValue( + graphicsContext, + static _ => new object()); + cache.SynchronizeContext(cache, contextIdentity); + SkslBackendBudget budget = SkslBackendBudgetResolver.Resolve(graphicsContext?.Backend); + ProgramCacheContextKey contextKey = CreateContextKey( + new RenderCacheDeviceContextIdentity(cache, contextIdentity), + budget); + return Acquire(cache, source, budget, contextKey); + } +} + +internal static class SpirvShaderProgramCache +{ + private const long DefaultRetainedByteBudget = 16 * 1024 * 1024; + private const string ColorAlphaFormatContract = "linear-premultiplied-rgba16f"; + private static readonly object s_defaultCompileOptions = new(); + + public static ProgramCache Create() + => new( + resetRuntimeBindings: static _ => { }, + retainedByteSize: static program => program.RetainedByteSize, + maxRetainedBytes: DefaultRetainedByteBudget, + shareLeasedPrograms: true); + + public static ProgramCacheContextKey CreateContextKey(RenderCacheDeviceContextIdentity context) + { + context.ThrowIfUninitialized(nameof(context)); + return new ProgramCacheContextKey( + context.DeviceIdentity, + context.ContextIdentity, + SkslBackendBudgetResolver.SpirvVulkan.CapabilityClass, + ColorAlphaFormatContract, + s_defaultCompileOptions); + } + + public static ProgramCacheLease Acquire( + ProgramCache cache, + ShaderDescription description, + IGraphicsContext graphicsContext, + ProgramCacheContextKey context) + { + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(description); + ArgumentNullException.ThrowIfNull(graphicsContext); + ArgumentNullException.ThrowIfNull(context); + SpirvShaderLowering lowering = description.SpirvLowering + ?? throw new ArgumentException("The shader description has no SPIR-V lowering.", nameof(description)); + ShaderProgramIdentity identity = ShaderProgramIdentity.CreateSpirv( + description, + lowering, + SkslBackendBudgetResolver.SpirvVulkan); + return cache.GetOrCreate( + identity, + context, + new SpirvProgramCreationState(graphicsContext, lowering), + static state => GLSLFilterPipeline.Create( + state.GraphicsContext, + state.Lowering.FragmentShaderSource, + ShaderOutputCoverage.ProvablyFull) + ?? throw new InvalidOperationException("Failed to compile the SPIR-V shader program.")); + } + + private readonly record struct SpirvProgramCreationState( + IGraphicsContext GraphicsContext, + SpirvShaderLowering Lowering); +} + +/// A checkout that keeps one cached program alive until the lease is returned. +internal sealed class ProgramCacheLease : IDisposable + where TProgram : class, IDisposable +{ + private ProgramCache? _owner; + private TProgram? _program; + + internal ProgramCacheLease( + ProgramCache owner, + ProgramCache.Entry? entry, + TProgram program, + bool isCacheHit, + bool isTransient) + { + _owner = owner; + Entry = entry; + _program = program; + IsCacheHit = isCacheHit; + IsTransient = isTransient; + } + + internal ProgramCache.Entry? Entry { get; } + + public TProgram Program + => _program ?? throw new ObjectDisposedException(nameof(ProgramCacheLease)); + + public bool IsCacheHit { get; } + + public bool IsTransient { get; } + + public void Dispose() + { + ProgramCache? owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + return; + + TProgram program = Interlocked.Exchange(ref _program, null) + ?? throw new InvalidOperationException("A program-cache lease lost its checked-out program."); + owner.Release(Entry, program); + } +} + +/// +/// Renderer-owned cache for compiled shader programs. The merged-program hash selects a bucket only; exact +/// and backend-context equality select an entry. Mutable programs use +/// exclusive leases, while immutable programs may opt into shared leases. +/// +internal sealed class ProgramCache : IDisposable + where TProgram : class, IDisposable +{ + private readonly object _gate = new(); + private readonly Action _resetRuntimeBindings; + private readonly Func _retainedByteSize; + private readonly long _maxRetainedBytes; + private readonly bool _shareLeasedPrograms; + private readonly Dictionary> _buckets = []; + private readonly Dictionary _activeContexts = []; + private readonly LinkedList _lru = []; + private long _retainedBytes; + private long _hits; + private long _misses; + private long _creations; + private long _evictions; + private ExceptionDispatchInfo? _deferredCleanupFailure; + private bool _disposed; + + public ProgramCache( + Action resetRuntimeBindings, + Func retainedByteSize, + long maxRetainedBytes, + bool shareLeasedPrograms = false) + { + _resetRuntimeBindings = resetRuntimeBindings + ?? throw new ArgumentNullException(nameof(resetRuntimeBindings)); + _retainedByteSize = retainedByteSize + ?? throw new ArgumentNullException(nameof(retainedByteSize)); + ArgumentOutOfRangeException.ThrowIfNegative(maxRetainedBytes); + _maxRetainedBytes = maxRetainedBytes; + _shareLeasedPrograms = shareLeasedPrograms; + } + + public ProgramCacheStatistics Statistics + { + get + { + lock (_gate) + { + return new ProgramCacheStatistics( + _hits, + _misses, + _creations, + _evictions, + _lru.Count, + _retainedBytes); + } + } + } + + /// + /// Finds or creates a program for a merged source. Runtime-only values are deliberately absent from the key and + /// are cleared before the lease is returned and again when it is discharged. + /// + public ProgramCacheLease GetOrCreate( + SkslMergedProgram program, + ProgramCacheContextKey context, + Func create) + { + ArgumentNullException.ThrowIfNull(program); + ArgumentNullException.ThrowIfNull(create); + return GetOrCreate(program.Identity, context, program, create); + } + + /// + /// Finds or creates a program by its complete merged identity. This overload is also the collision-test seam: + /// callers may construct identities with a forced bucket hash while equality still compares full source and + /// binding signature. + /// + public ProgramCacheLease GetOrCreate( + ShaderProgramIdentity identity, + ProgramCacheContextKey context, + Func create) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(create); + return GetOrCreateCore( + identity, + context, + create, + static factory => factory()); + } + + /// + /// Finds or creates a program using an explicit factory state. A static factory keeps warmed lookups from + /// allocating a capturing closure. + /// + public ProgramCacheLease GetOrCreate( + ShaderProgramIdentity identity, + ProgramCacheContextKey context, + TState factoryState, + Func create) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(create); + return GetOrCreateCore(identity, context, factoryState, create); + } + + private ProgramCacheLease GetOrCreateCore( + ShaderProgramIdentity identity, + ProgramCacheContextKey context, + TState factoryState, + Func create) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Entry? entry = FindEntry(identity, context); + if (entry is not null) + { + _hits++; + Touch(entry); + if (_shareLeasedPrograms || !entry.IsLeased) + { + if (!_shareLeasedPrograms) + { + try + { + _resetRuntimeBindings(entry.Program); + } + catch (Exception ex) + { + RemoveEntry(entry, countEviction: true); + RecordCleanupFailure(DisposeProgramsBestEffort([entry.Program])); + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; + } + } + + entry.LeaseCount++; + return new ProgramCacheLease( + this, + entry, + entry.Program, + isCacheHit: true, + isTransient: false); + } + + TProgram reentrant = CreateResetProgram(factoryState, create, out _); + return new ProgramCacheLease( + this, + entry: null, + reentrant, + isCacheHit: true, + isTransient: true); + } + + _misses++; + TProgram created = CreateResetProgram(factoryState, create, out long retainedBytes); + if (_maxRetainedBytes == 0 || retainedBytes > _maxRetainedBytes) + { + return new ProgramCacheLease( + this, + entry: null, + created, + isCacheHit: false, + isTransient: true); + } + + var inserted = new Entry(identity, context, created, retainedBytes) + { + LeaseCount = 1, + }; + inserted.LruNode = _lru.AddFirst(inserted); + if (!_buckets.TryGetValue(identity.BucketHash, out List? bucket)) + { + bucket = []; + _buckets.Add(identity.BucketHash, bucket); + } + + bucket.Add(inserted); + _retainedBytes = checked(_retainedBytes + retainedBytes); + List evicted = TrimToBudget(); + RecordCleanupFailure(DisposeProgramsBestEffort(evicted)); + return new ProgramCacheLease( + this, + inserted, + inserted.Program, + isCacheHit: false, + isTransient: false); + } + } + + /// + /// Invalidates every program compiled for one context. Leased programs are detached immediately and disposed + /// only when their outer lease is returned. + /// + public int EvictContext(object deviceIdentity, object contextIdentity) + { + ArgumentNullException.ThrowIfNull(deviceIdentity); + ArgumentNullException.ThrowIfNull(contextIdentity); + return EvictWhere(context => + Equals(context.DeviceIdentity, deviceIdentity) + && Equals(context.ContextIdentity, contextIdentity)); + } + + /// + /// Sets the active context for one cache-owned device domain and evicts entries from its preceding context. + /// Leased entries are detached immediately and disposed after their last lease returns. + /// + public int SynchronizeContext(object deviceIdentity, object contextIdentity) + { + ArgumentNullException.ThrowIfNull(deviceIdentity); + ArgumentNullException.ThrowIfNull(contextIdentity); + List disposable; + int count; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_activeContexts.TryGetValue(deviceIdentity, out object? current) + && Equals(current, contextIdentity)) + { + return 0; + } + + _activeContexts[deviceIdentity] = contextIdentity; + Entry[] matches = _lru + .Where(entry => + Equals(entry.Context.DeviceIdentity, deviceIdentity) + && !Equals(entry.Context.ContextIdentity, contextIdentity)) + .ToArray(); + count = matches.Length; + disposable = new List(count); + foreach (Entry entry in matches) + { + RemoveEntry(entry, countEviction: true); + if (!entry.IsLeased) + disposable.Add(entry.Program); + } + } + + DisposeProgramsBestEffort(disposable)?.Throw(); + return count; + } + + /// + /// Invalidates every program compiled for one device, including all of its context generations. + /// + public int EvictDevice(object deviceIdentity) + { + ArgumentNullException.ThrowIfNull(deviceIdentity); + return EvictWhere(context => Equals(context.DeviceIdentity, deviceIdentity)); + } + + public void Dispose() + { + List disposable; + ExceptionDispatchInfo? firstFailure; + lock (_gate) + { + if (_disposed) + return; + + _disposed = true; + _activeContexts.Clear(); + Entry[] entries = [.. _lru]; + disposable = new List(entries.Length); + foreach (Entry entry in entries) + { + RemoveEntry(entry, countEviction: true); + if (!entry.IsLeased) + disposable.Add(entry.Program); + } + + firstFailure = _deferredCleanupFailure; + _deferredCleanupFailure = null; + } + + ExceptionDispatchInfo? disposalFailure = DisposeProgramsBestEffort(disposable); + (firstFailure ?? disposalFailure)?.Throw(); + } + + internal void Release(Entry? entry, TProgram program) + { + ExceptionDispatchInfo? primaryFailure = null; + if (!_shareLeasedPrograms) + { + try + { + _resetRuntimeBindings(program); + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + } + + List disposable = []; + lock (_gate) + { + if (entry is null) + { + disposable.Add(program); + } + else + { + if (!ReferenceEquals(entry.Program, program) || entry.LeaseCount <= 0) + { + throw new InvalidOperationException( + "A program-cache lease does not match an active cached checkout."); + } + + entry.LeaseCount--; + if (primaryFailure is not null) + { + if (!entry.IsEvicted) + RemoveEntry(entry, countEviction: true); + if (!entry.IsLeased) + disposable.Add(program); + } + else if (!entry.IsLeased && (entry.IsEvicted || _disposed)) + { + if (!entry.IsEvicted) + RemoveEntry(entry, countEviction: true); + disposable.Add(program); + } + else if (!entry.IsLeased) + { + disposable.AddRange(TrimToBudget()); + } + } + } + + ExceptionDispatchInfo? disposalFailure = DisposeProgramsBestEffort(disposable); + if (primaryFailure is not null) + { + if (disposalFailure is not null) + { + lock (_gate) + RecordCleanupFailure(disposalFailure); + } + + primaryFailure.Throw(); + } + + disposalFailure?.Throw(); + } + + private TProgram CreateResetProgram( + TState factoryState, + Func create, + out long retainedBytes) + { + TProgram program = create(factoryState) + ?? throw new InvalidOperationException("The program factory returned null."); + _creations++; + try + { + _resetRuntimeBindings(program); + retainedBytes = _retainedByteSize(program); + if (retainedBytes <= 0) + { + throw new InvalidOperationException( + "A compiled program must report a positive retained byte size."); + } + + return program; + } + catch (Exception ex) + { + RecordCleanupFailure(DisposeProgramsBestEffort([program])); + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; + } + } + + private Entry? FindEntry( + ShaderProgramIdentity identity, + ProgramCacheContextKey context) + { + if (!_buckets.TryGetValue(identity.BucketHash, out List? bucket)) + return null; + + foreach (Entry candidate in bucket) + { + if (!candidate.IsEvicted + && candidate.Identity.Equals(identity) + && candidate.Context.Equals(context)) + { + return candidate; + } + } + + return null; + } + + private void Touch(Entry entry) + { + LinkedListNode? node = entry.LruNode; + if (node is null || ReferenceEquals(_lru.First, node)) + return; + + _lru.Remove(node); + _lru.AddFirst(node); + } + + private List TrimToBudget() + { + var disposable = new List(); + while (_retainedBytes > _maxRetainedBytes) + { + LinkedListNode? candidate = _lru.Last; + while (candidate is not null && candidate.Value.IsLeased) + candidate = candidate.Previous; + if (candidate is null) + break; + + Entry entry = candidate.Value; + RemoveEntry(entry, countEviction: true); + disposable.Add(entry.Program); + } + + return disposable; + } + + private int EvictWhere(Func predicate) + { + List disposable; + int count; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Entry[] matches = _lru.Where(entry => predicate(entry.Context)).ToArray(); + count = matches.Length; + disposable = new List(count); + foreach (Entry entry in matches) + { + RemoveEntry(entry, countEviction: true); + if (!entry.IsLeased) + disposable.Add(entry.Program); + } + } + + DisposeProgramsBestEffort(disposable)?.Throw(); + return count; + } + + private void RemoveEntry(Entry entry, bool countEviction) + { + if (entry.IsEvicted) + return; + + entry.IsEvicted = true; + if (entry.LruNode is not null) + { + _lru.Remove(entry.LruNode); + entry.LruNode = null; + } + + if (_buckets.TryGetValue(entry.Identity.BucketHash, out List? bucket)) + { + bucket.Remove(entry); + if (bucket.Count == 0) + _buckets.Remove(entry.Identity.BucketHash); + } + + _retainedBytes -= entry.RetainedBytes; + if (countEviction) + _evictions++; + } + + private void RecordCleanupFailure(ExceptionDispatchInfo? failure) + { + if (failure is not null && _deferredCleanupFailure is null) + _deferredCleanupFailure = failure; + } + + private static ExceptionDispatchInfo? DisposeProgramsBestEffort(IEnumerable programs) + { + ExceptionDispatchInfo? firstFailure = null; + foreach (TProgram program in programs) + { + try + { + program.Dispose(); + } + catch (Exception ex) + { + firstFailure ??= ExceptionDispatchInfo.Capture(ex); + } + } + + return firstFailure; + } + + internal sealed class Entry( + ShaderProgramIdentity identity, + ProgramCacheContextKey context, + TProgram program, + long retainedBytes) + { + public ShaderProgramIdentity Identity { get; } = identity; + + public ProgramCacheContextKey Context { get; } = context; + + public TProgram Program { get; } = program; + + public long RetainedBytes { get; } = retainedBytes; + + public LinkedListNode? LruNode { get; set; } + + public int LeaseCount { get; set; } + + public bool IsLeased => LeaseCount != 0; + + public bool IsEvicted { get; set; } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs new file mode 100644 index 0000000000..815078efeb --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RecordedRenderGraph.cs @@ -0,0 +1,302 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Rendering.Cache; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RecordedRenderGraph +{ + public RecordedRenderGraph( + RenderRequestId requestId, + ImmutableArray fragments, + ImmutableArray values, + ImmutableArray publicationRoots, + ImmutableArray provenance, + ImmutableArray cacheCandidates, + ImmutableArray resources, + ImmutableArray nestedRequests) + { + RequestId = requestId; + Fragments = fragments; + Values = values; + PublicationRoots = publicationRoots; + Provenance = provenance; + CacheCandidates = cacheCandidates; + Resources = resources; + NestedRequests = nestedRequests; + } + + public RenderRequestId RequestId { get; } + + public ImmutableArray Fragments { get; } + + public ImmutableArray Values { get; } + + public ImmutableArray PublicationRoots { get; } + + public ImmutableArray Provenance { get; } + + public ImmutableArray CacheCandidates { get; } + + public ImmutableArray Resources { get; } + + public ImmutableArray NestedRequests { get; } +} + +internal sealed class RecordedRenderGraphBuilder +{ + private readonly List _fragments = []; + private readonly List _values = []; + private readonly List _publicationRoots = []; + private readonly List _provenance = []; + private readonly List _cacheCandidates = []; + private readonly List _resources = []; + private readonly List _nestedRequests = []; + private bool _built; + + public RecordedRenderGraphBuilder(RenderRequestId requestId) + { + if (requestId.Value <= 0) + { + throw new ArgumentException("A graph requires an initialized request ID.", nameof(requestId)); + } + + RequestId = requestId; + } + + public RenderRequestId RequestId { get; } + + public RenderProvenanceId AddProvenance(object origin, string role) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(origin); + ArgumentException.ThrowIfNullOrWhiteSpace(role); + + RenderProvenanceId id = new(RequestId, _provenance.Count + 1L); + _provenance.Add(new RootProvenance(id, origin, role, _provenance.Count)); + return id; + } + + public RenderValueId AddValue( + IEnumerable inputs, + RenderProvenanceId provenanceId, + object? payload = null) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(inputs); + ValidateProvenance(provenanceId); + ImmutableArray inputCopy = [.. inputs]; + foreach (RenderValueId input in inputCopy) + { + ValidateExistingValue(input); + } + + RenderValueId id = new(RequestId, _values.Count + 1L); + _values.Add(new RecordedRenderValue(id, inputCopy, provenanceId, payload)); + return id; + } + + public RenderFragmentId AddFragment( + IEnumerable values, + RenderProvenanceId provenanceId, + object? payload = null) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(values); + ValidateProvenance(provenanceId); + ImmutableArray valueCopy = [.. values]; + foreach (RenderValueId value in valueCopy) + { + ValidateExistingValue(value); + } + + RenderFragmentId id = new(RequestId, _fragments.Count + 1L); + _fragments.Add(new RecordedRenderFragment(id, _fragments.Count, valueCopy, provenanceId, payload)); + return id; + } + + public void PublishRoot(RenderFragmentId fragmentId) + { + EnsureMutable(); + ValidateExistingFragment(fragmentId); + _publicationRoots.Add(fragmentId); + } + + public RenderCacheCandidateId AddCacheCandidate( + RenderFragmentId fragmentId, + object cacheKey, + RenderNodeCache? cache = null) + { + EnsureMutable(); + ValidateExistingFragment(fragmentId); + ArgumentNullException.ThrowIfNull(cacheKey); + + RenderCacheCandidateId id = new(RequestId, _cacheCandidates.Count + 1L); + _cacheCandidates.Add(new RenderCacheCandidate( + id, + fragmentId, + cacheKey, + cache, + _cacheCandidates.Count)); + return id; + } + + public void AddResource(RenderResourceRegistration resource) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(resource); + if (!_resources.Contains(resource)) + { + _resources.Add(resource); + } + } + + public void AddNestedRequest(RecordedNestedRenderRequest nestedRequest) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(nestedRequest); + if (nestedRequest.Request.ParentId != RequestId) + { + throw new InvalidOperationException("The nested request does not belong to this graph's request."); + } + + _nestedRequests.Add(nestedRequest); + } + + public void Append(NodeRecordingCommit commit) + { + EnsureMutable(); + ArgumentNullException.ThrowIfNull(commit); + + var available = new HashSet(ReferenceEqualityComparer.Instance); + foreach (RecordedRenderFragmentEntry entry in commit.Fragments) + { + RenderFragmentReference reference = entry.Reference; + if (reference.Id is not null) + { + throw new InvalidOperationException("A recorded fragment was already committed to a graph."); + } + + foreach (RenderFragmentReference input in reference.Inputs) + { + if (input.Id is null && !available.Contains(input)) + { + throw new InvalidOperationException( + "A recorded fragment input must be committed earlier in the request graph."); + } + } + + available.Add(reference); + } + + var provenance = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (RecordedRenderFragmentEntry entry in commit.Fragments) + { + if (!provenance.TryGetValue(entry.Origin, out RenderProvenanceId provenanceId)) + { + provenanceId = AddProvenance(entry.Origin, entry.Role); + provenance.Add(entry.Origin, provenanceId); + } + + RenderFragmentReference reference = entry.Reference; + ImmutableArray inputValues = + [.. reference.Inputs.SelectMany(static item => item.ValueIds)]; + if (reference.ValueCardinality.Maximum != 0 || reference.ValueCardinality.Minimum != 0) + { + reference.ValueIds = [AddValue(inputValues, provenanceId, reference)]; + } + + reference.Id = AddFragment(reference.ValueIds, provenanceId, reference); + } + + foreach (RenderResource resource in commit.Resources) + { + AddResource(resource.Slot); + } + + foreach (RecordedNestedRenderRequest nestedRequest in commit.NestedRequests) + { + AddNestedRequest(nestedRequest); + } + } + + public RecordedRenderGraph Build() + { + EnsureMutable(); + _built = true; + return new RecordedRenderGraph( + RequestId, + [.. _fragments], + [.. _values], + [.. _publicationRoots], + [.. _provenance], + [.. _cacheCandidates], + [.. _resources], + [.. _nestedRequests]); + } + + private void ValidateProvenance(RenderProvenanceId id) + { + if (id.RequestId != RequestId || id.Value <= 0 || id.Value > _provenance.Count) + { + throw new InvalidOperationException("The provenance ID does not belong to this request graph."); + } + } + + private void ValidateExistingValue(RenderValueId id) + { + if (id.RequestId != RequestId || id.Value <= 0 || id.Value > _values.Count) + { + throw new InvalidOperationException("The value ID does not identify an earlier value in this request graph."); + } + } + + private void ValidateExistingFragment(RenderFragmentId id) + { + if (id.RequestId != RequestId || id.Value <= 0 || id.Value > _fragments.Count) + { + throw new InvalidOperationException("The fragment ID does not belong to this request graph."); + } + } + + private void EnsureMutable() + { + if (_built) + { + throw new InvalidOperationException("A recorded render graph builder cannot change after Build."); + } + } +} + +internal sealed record RecordedRenderFragment( + RenderFragmentId Id, + int AuthoredOrder, + ImmutableArray Values, + RenderProvenanceId ProvenanceId, + object? Payload); + +internal sealed record RecordedRenderValue( + RenderValueId Id, + ImmutableArray Inputs, + RenderProvenanceId ProvenanceId, + object? Payload); + +internal sealed record RootProvenance( + RenderProvenanceId Id, + object Origin, + string Role, + int AuthoredOrder); + +internal sealed record RenderCacheCandidate( + RenderCacheCandidateId Id, + RenderFragmentId FragmentId, + object CacheKey, + RenderNodeCache? Cache, + int AuthoredOrder); + +internal readonly record struct RenderFragmentId(RenderRequestId RequestId, long Value); + +internal readonly record struct RenderValueId(RenderRequestId RequestId, long Value); + +internal readonly record struct RenderProvenanceId(RenderRequestId RequestId, long Value); + +internal readonly record struct RenderCacheCandidateId(RenderRequestId RequestId, long Value); diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs new file mode 100644 index 0000000000..a9d9f0c714 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RegionAnalyzer.cs @@ -0,0 +1,1559 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RegionAnalyzer +{ + public RegionAnalysis Analyze( + RenderRequestOptions options, + IReadOnlyList roots, + TargetDependencyPlan? targetDependencies = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(roots); + + targetDependencies ??= TargetDependencyLowerer.Lower( + roots.ToImmutableArray(), + options.TargetDomain); + + ImmutableArray topologicalOrder = GetTopologicalOrder(roots); + ImmutableHashSet backingTargetBackdropCaptures = + FindBackingTargetBackdropCaptures(topologicalOrder, targetDependencies); + IReadOnlyDictionary targetDomains = + ResolveTargetDomains( + roots, + options.TargetDomain, + targetDependencies, + backingTargetBackdropCaptures); + ImmutableDictionary metadata = + ResolveForwardMetadata(topologicalOrder, targetDomains, options); + RenderNodeMeasurement measurement = Measure(options, roots); + Rect finalCommitBounds = options.RequestedRegion switch + { + { Width: 0 } empty => empty, + { Height: 0 } empty => empty, + { } requested => requested.Intersect(measurement.OutputBounds), + null => measurement.OutputBounds, + }; + RequiredRegion finalCommitRegion = RequiredRegion.Region(finalCommitBounds); + + var fragmentRequirements = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + { + RequiredRegion requirement = GetRootRequirement( + root, + finalCommitBounds, + options.TargetDomain); + UnionRequirement(fragmentRequirements, root, requirement); + } + + var targetRequirements = new Dictionary( + ReferenceEqualityComparer.Instance); + IReadOnlyDictionary referencesById = + topologicalOrder.ToDictionary(GetId); + int remainingPasses = checked(topologicalOrder.Length + targetDependencies.Steps.Length + 1); + bool changed; + do + { + changed = PropagateValueRequirements( + topologicalOrder, + targetDomains, + fragmentRequirements, + targetRequirements); + changed |= PropagateTargetTokenRequirements( + targetDependencies, + referencesById, + targetRequirements, + fragmentRequirements); + remainingPasses--; + if (changed && remainingPasses == 0) + { + throw new InvalidOperationException( + "Target-token region propagation did not converge within the finite request graph."); + } + } + while (changed); + + var fragmentRegions = ImmutableDictionary.CreateBuilder(); + var valueRegions = ImmutableDictionary.CreateBuilder(); + var targetAccessRegions = ImmutableDictionary.CreateBuilder(); + foreach (RenderFragmentReference reference in topologicalOrder) + { + RenderFragmentId fragmentId = GetId(reference); + RequiredRegion requirement = GetRequirement(fragmentRequirements, reference); + fragmentRegions.Add(fragmentId, requirement); + foreach (RenderValueId valueId in reference.ValueIds) + valueRegions.Add(valueId, requirement); + + if (targetRequirements.TryGetValue(reference, out RequiredRegion targetRequirement)) + targetAccessRegions.Add(fragmentId, targetRequirement); + } + + return new RegionAnalysis( + measurement, + options.TargetDomain, + options.RequestedRegion, + finalCommitBounds, + finalCommitRegion, + fragmentRegions.ToImmutable(), + valueRegions.ToImmutable(), + targetAccessRegions.ToImmutable(), + metadata, + backingTargetBackdropCaptures); + } + + private static bool PropagateValueRequirements( + ImmutableArray topologicalOrder, + IReadOnlyDictionary targetDomains, + Dictionary fragmentRequirements, + Dictionary targetRequirements) + { + bool changed = false; + for (int index = topologicalOrder.Length - 1; index >= 0; index--) + { + RenderFragmentReference reference = topologicalOrder[index]; + RequiredRegion requirement = GetRequirement(fragmentRequirements, reference); + + RequiredRegion? targetRequirement = GetTargetAccessRequirement( + reference, + requirement, + targetDomains[reference]); + if (targetRequirement is { } target) + changed |= UnionRequirement(targetRequirements, reference, target); + + ImmutableArray inputRequirements = GetInputRequirements( + reference, + requirement, + targetDomains[reference]); + if (inputRequirements.Length != reference.Inputs.Length) + { + throw new InvalidOperationException( + "Region analysis must produce exactly one requirement per fragment input."); + } + + for (int inputIndex = 0; inputIndex < reference.Inputs.Length; inputIndex++) + { + changed |= UnionRequirement( + fragmentRequirements, + reference.Inputs[inputIndex], + inputRequirements[inputIndex]); + } + } + + return changed; + } + + private static bool PropagateTargetTokenRequirements( + TargetDependencyPlan plan, + IReadOnlyDictionary referencesById, + IReadOnlyDictionary targetRequirements, + Dictionary fragmentRequirements) + { + IReadOnlyDictionary producers = plan.Steps + .ToDictionary(static step => step.OutputToken); + IReadOnlyDictionary scopes = plan.Scopes + .ToDictionary(static scope => scope.Id); + bool changed = false; + + foreach (TargetDependencyStep consumer in plan.Steps) + { + RenderFragmentReference consumerReference = referencesById[consumer.FragmentId]; + if (!targetRequirements.TryGetValue( + consumerReference, + out RequiredRegion targetRequirement) + || targetRequirement.IsEmpty) + { + continue; + } + + TargetTokenId token = consumer.InputToken; + TargetScopeId requirementScope = consumer.ScopeId; + RequiredRegion requirement = targetRequirement; + while (producers.TryGetValue(token, out TargetDependencyStep producer)) + { + RenderFragmentReference producerReference = referencesById[producer.FragmentId]; + TargetScopeId fragmentScope = ResolveFragmentOutputScope( + producerReference, + producer.ScopeId, + scopes); + RequiredRegion fragmentRequirement = MapRequirementBetweenScopes( + requirement, + requirementScope, + fragmentScope, + scopes, + referencesById); + + if (producer.Kind != TargetDependencyKind.Capture) + { + changed |= UnionRequirement( + fragmentRequirements, + producerReference, + fragmentRequirement); + } + + requirement = MapRequirementBetweenScopes( + requirement, + requirementScope, + producer.ScopeId, + scopes, + referencesById); + requirementScope = producer.ScopeId; + token = producer.InputToken; + } + } + + return changed; + } + + private static TargetScopeId ResolveFragmentOutputScope( + RenderFragmentReference reference, + TargetScopeId executionScope, + IReadOnlyDictionary scopes) + { + TargetScopePlan scope = scopes[executionScope]; + return scope.OwnerFragmentId == reference.Id && scope.ParentId is { } parentId + ? parentId + : executionScope; + } + + private static RequiredRegion MapRequirementBetweenScopes( + RequiredRegion requirement, + TargetScopeId sourceScopeId, + TargetScopeId destinationScopeId, + IReadOnlyDictionary scopes, + IReadOnlyDictionary referencesById) + { + if (requirement.IsEmpty || sourceScopeId == destinationScopeId) + return requirement; + + TargetScopePlan sourceScope = scopes[sourceScopeId]; + Rect mapped = requirement.IsFull + ? requirement.Resolve( + sourceScope.ResolvedDomain + ?? throw new InvalidOperationException( + "A Full target-token requirement cannot cross an unresolved target scope.")) + : requirement.Value; + + var sourceAncestors = new Dictionary(); + TargetScopeId? cursor = sourceScopeId; + int depth = 0; + while (cursor is { } current) + { + sourceAncestors.Add(current, depth++); + cursor = scopes[current].ParentId; + } + + var destinationPath = new List(); + cursor = destinationScopeId; + while (cursor is { } current && !sourceAncestors.ContainsKey(current)) + { + destinationPath.Add(current); + cursor = scopes[current].ParentId; + } + + TargetScopeId commonAncestor = cursor + ?? throw new InvalidOperationException( + "Target-token scopes must belong to one rooted scope tree."); + cursor = sourceScopeId; + while (cursor != commonAncestor) + { + TargetScopePlan child = scopes[cursor!.Value]; + mapped = MapChildToParent(mapped, child, referencesById); + cursor = child.ParentId; + } + + for (int index = destinationPath.Count - 1; index >= 0; index--) + { + TargetScopePlan child = scopes[destinationPath[index]]; + mapped = MapParentToChild(mapped, child, referencesById); + } + + return RequiredRegion.Region(mapped); + } + + private static Rect MapChildToParent( + Rect requirement, + TargetScopePlan child, + IReadOnlyDictionary referencesById) + { + Rect mapped = child.OwnerFragmentId is { } ownerId + ? referencesById[ownerId].Payload switch + { + TargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(requirement), + RawTargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(requirement), + _ => requirement, + } + : requirement; + return mapped; + } + + private static Rect MapParentToChild( + Rect requirement, + TargetScopePlan child, + IReadOnlyDictionary referencesById) + { + Rect mapped = child.OwnerFragmentId is { } ownerId + ? referencesById[ownerId].Payload switch + { + TargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.GetRequiredInputBounds(requirement), + RawTargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.GetRequiredInputBounds(requirement), + _ => requirement, + } + : requirement; + return child.ResolvedDomain is { } domain + ? mapped.Intersect(domain) + : mapped; + } + + private static ImmutableArray GetTopologicalOrder( + IReadOnlyList roots) + { + var result = ImmutableArray.CreateBuilder(); + var visiting = new HashSet(ReferenceEqualityComparer.Instance); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + { + ArgumentNullException.ThrowIfNull(root); + Visit(root, visiting, visited, result); + } + + return result.ToImmutable(); + + static void Visit( + RenderFragmentReference reference, + HashSet visiting, + HashSet visited, + ImmutableArray.Builder result) + { + if (visited.Contains(reference)) + return; + if (!visiting.Add(reference)) + throw new InvalidOperationException("The recorded render graph contains a fragment cycle."); + + foreach (RenderFragmentReference input in reference.Inputs) + Visit(input, visiting, visited, result); + + visiting.Remove(reference); + visited.Add(reference); + result.Add(reference); + } + } + + private static IReadOnlyDictionary ResolveTargetDomains( + IReadOnlyList roots, + Rect? rootDomain, + TargetDependencyPlan targetDependencies, + IReadOnlySet backingTargetBackdropCaptures) + { + var result = new Dictionary( + ReferenceEqualityComparer.Instance); + var visitedDomains = new Dictionary>( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + Visit(root, rootDomain, result, visitedDomains); + + IReadOnlyDictionary references = result.Keys + .Where(static reference => reference.Id is not null) + .ToDictionary(static reference => reference.Id!.Value); + IReadOnlyDictionary scopes = targetDependencies.Scopes + .ToDictionary(static scope => scope.Id); + foreach (TargetDependencyStep capture in targetDependencies.Steps + .Where(static step => step.Kind == TargetDependencyKind.Capture)) + { + RenderFragmentReference reference = references[capture.FragmentId]; + TargetScopePlan captureScope = scopes[capture.ScopeId]; + if (backingTargetBackdropCaptures.Contains(capture.FragmentId)) + { + // A value replay map materializes its input before replaying it through a transform. A backdrop + // reads the backing target outside that materialization boundary; resolving it inside the map + // would inverse-rasterize the target only to transform it forward again during replay. + TargetScopePlan current = captureScope; + while (current.ParentId is { } parentId) + { + RenderFragmentReference? owner = current.OwnerFragmentId is { } ownerId + ? references[ownerId] + : null; + if (owner?.Payload is LayerRenderFragmentPayload + or TargetLayerScopeRenderFragmentPayload) + { + break; + } + + if (owner?.Payload is TargetScopeRenderFragmentPayload scope + && scope.Description.BuiltInBackdropCapturesBackingTarget) + { + captureScope = scopes[parentId]; + } + + current = scopes[parentId]; + } + } + + result[reference] = captureScope.ResolvedDomain; + } + return result; + + static void Visit( + RenderFragmentReference reference, + Rect? domain, + Dictionary result, + Dictionary> visitedDomains) + { + if (!visitedDomains.TryGetValue(reference, out HashSet? domains)) + { + domains = []; + visitedDomains.Add(reference, domains); + } + if (!domains.Add(domain)) + return; + + if (result.TryGetValue(reference, out Rect? existing)) + { + bool isReusableCapture = reference.Kind is RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture; + if (existing != domain + && (reference.BoundsRequirement == RenderFragmentBoundsRequirement.OwningTargetDomain + || (reference.HasTargetEffects && !reference.CanBeUsedAsValueInput)) + && !isReusableCapture) + { + throw new InvalidOperationException( + "A target-effect fragment cannot be lowered into two different owning target domains."); + } + } + else + { + result.Add(reference, domain); + } + + Rect? inputDomain = reference.Payload switch + { + TargetScopeRenderFragmentPayload scope when domain is { } finite + => scope.Description.Bounds.GetRequiredInputBounds(finite), + RawTargetScopeRenderFragmentPayload scope when domain is { } finite + => scope.Description.Bounds.GetRequiredInputBounds(finite), + LayerRenderFragmentPayload layer => layer.Domain ?? domain, + TargetLayerScopeRenderFragmentPayload layer + => ResolveTargetRegion(layer.Region, domain), + _ => domain, + }; + foreach (RenderFragmentReference input in reference.Inputs) + Visit(input, inputDomain, result, visitedDomains); + } + } + + private static ImmutableHashSet FindBackingTargetBackdropCaptures( + ImmutableArray topologicalOrder, + TargetDependencyPlan targetDependencies) + { + IReadOnlyDictionary references = topologicalOrder + .ToDictionary(GetId); + IReadOnlyDictionary scopes = targetDependencies.Scopes + .ToDictionary(static scope => scope.Id); + var result = ImmutableHashSet.CreateBuilder(); + foreach (TargetDependencyStep capture in targetDependencies.Steps + .Where(static step => step.Kind == TargetDependencyKind.Capture)) + { + if (references[capture.FragmentId].Kind != RenderFragmentKind.BuiltInBackdropCapture) + continue; + + TargetScopePlan current = scopes[capture.ScopeId]; + while (current.ParentId is { } parentId) + { + RenderFragmentReference? owner = current.OwnerFragmentId is { } ownerId + ? references[ownerId] + : null; + if (owner?.Payload is LayerRenderFragmentPayload + or TargetLayerScopeRenderFragmentPayload) + { + break; + } + + if (owner?.Payload is TargetScopeRenderFragmentPayload scope + && scope.Description.BuiltInBackdropCapturesBackingTarget) + { + result.Add(capture.FragmentId); + break; + } + + current = scopes[parentId]; + } + } + + return result.ToImmutable(); + } + + private static ImmutableDictionary ResolveForwardMetadata( + ImmutableArray topologicalOrder, + IReadOnlyDictionary targetDomains, + RenderRequestOptions options) + { + var result = ImmutableDictionary.CreateBuilder(); + foreach (RenderFragmentReference reference in topologicalOrder) + { + Rect resolvedBounds = ResolveForwardBounds(reference, targetDomains[reference]); + RenderRectValidation.ThrowIfInvalidResult( + resolvedBounds, + "A resolved fragment contains invalid forward bounds."); + if (!reference.HasSymbolicBoundsDependency + && resolvedBounds != reference.RecordedBounds) + { + // Concrete mappings are deliberately evaluated both while recording and here. Exact equality + // enforces the public contract that bounds delegates are deterministic over an immutable snapshot; + // a tolerance would hide mutable captures rather than accommodate numeric drift from identical inputs. + throw new InvalidOperationException( + "A forward bounds mapping changed between recording and graph-wide metadata resolution."); + } + + EffectiveScale resolvedScale = reference.HasSymbolicBoundsDependency + ? ResolveForwardScale(reference, resolvedBounds, options) + : reference.RecordedEffectiveScale; + Func? resolvedHitTest = reference.HasSymbolicBoundsDependency + ? ResolveForwardHitTest(reference, resolvedBounds) + : null; + reference.ApplyResolvedMetadata(resolvedBounds, resolvedScale, resolvedHitTest); + result.Add( + GetId(reference), + new ResolvedFragmentMetadata( + resolvedBounds, + ResolveQueryBounds(reference), + resolvedScale)); + } + + return result.ToImmutable(); + } + + private static Rect ResolveForwardBounds( + RenderFragmentReference reference, + Rect? targetDomain) + { + if (reference.BoundsRequirement == RenderFragmentBoundsRequirement.OwningTargetDomain) + { + return targetDomain + ?? throw new RenderTargetDomainRequiredException(reference.Kind == RenderFragmentKind.FilterEffectSegment + ? "A CustomEffect without transformBounds requires a finite owning target domain from a " + + "destination, finite Layer, or explicit TargetDomain." + : "A symbolic full-target capture requires a finite owning target domain."); + } + + IReadOnlyList inputBounds = reference.Inputs + .Select(static input => input.Bounds) + .ToArray(); + return reference.Kind switch + { + RenderFragmentKind.ContributeValues when inputBounds.Count == 0 + => reference.RecordedBounds, + RenderFragmentKind.ContributeValues + or RenderFragmentKind.Opacity + or RenderFragmentKind.Blend + => inputBounds[0], + RenderFragmentKind.OpacityMask => inputBounds[0], + RenderFragmentKind.Shader + => ((ShaderRenderFragmentPayload)reference.Payload!).Description.Bounds + .TransformBounds(inputBounds[0]), + RenderFragmentKind.Geometry + => ((GeometryRenderFragmentPayload)reference.Payload!).Description.Bounds + .TransformBounds(inputBounds[0]), + RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + => ((OpaqueRenderFragmentPayload)reference.Payload!).Description.Bounds + .TransformBounds(inputBounds), + RenderFragmentKind.FilterEffectSegment + => ResolveLegacyFilterBounds(reference, inputBounds), + RenderFragmentKind.Layer + => ResolveLayerBounds( + reference, + ((LayerRenderFragmentPayload)reference.Payload!).Domain + ?? throw new InvalidOperationException( + "An owning-domain Layer must resolve before finite bounds mapping.")), + RenderFragmentKind.TargetLayerScope => UnionInputBounds(reference), + RenderFragmentKind.TargetScope + => ((TargetScopeRenderFragmentPayload)reference.Payload!).Description.Bounds + .TransformBounds(inputBounds[0]), + RenderFragmentKind.RawTargetScope + => ((RawTargetScopeRenderFragmentPayload)reference.Payload!).Description.Bounds + .TransformBounds(inputBounds[0]), + _ => reference.RecordedBounds, + }; + } + + private static Rect ResolveLegacyFilterBounds( + RenderFragmentReference reference, + IReadOnlyList inputBounds) + { + var payload = (FilterEffectSegmentRenderFragmentPayload)reference.Payload!; + if (payload.BoundsItems.IsDefaultOrEmpty) + return reference.RecordedBounds; + + Rect bounds = default; + for (int index = 0; index < payload.StreamInputCount; index++) + bounds = bounds.Union(inputBounds[index]); + foreach (IFEItem item in payload.BoundsItems) + bounds = item.TransformBounds(bounds); + return bounds; + } + + private static EffectiveScale ResolveForwardScale( + RenderFragmentReference reference, + Rect resolvedBounds, + RenderRequestOptions options) + { + EffectiveScale[] inputScales = reference.Inputs + .Select(static input => input.EffectiveScale) + .ToArray(); + switch (reference.Kind) + { + case RenderFragmentKind.ContributeValues: + case RenderFragmentKind.Opacity: + case RenderFragmentKind.Blend: + case RenderFragmentKind.OpacityMask: + return inputScales[0]; + case RenderFragmentKind.Shader: + { + var payload = (ShaderRenderFragmentPayload)reference.Payload!; + bool materializes = payload.Description.Kind == ShaderDescriptionKind.WholeSource; + if (payload.WorkingScalePolicy is { } policy) + { + return policy.Resolve( + reference.Inputs, + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale); + } + + if (!materializes) + return inputScales[0]; + + return ResolveMaterializedScale(inputScales, resolvedBounds, options); + } + case RenderFragmentKind.Geometry: + { + var payload = (GeometryRenderFragmentPayload)reference.Payload!; + return payload.WorkingScalePolicy is { } policy + ? policy.Resolve( + reference.Inputs, + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale) + : ResolveMaterializedScale(inputScales, resolvedBounds, options); + } + case RenderFragmentKind.FilterEffectSegment: + { + var payload = (FilterEffectSegmentRenderFragmentPayload)reference.Payload!; + Rect[] inputBounds = reference.Inputs + .Take(payload.StreamInputCount) + .Select(static input => input.Bounds) + .ToArray(); + EffectiveScale[] streamScales = reference.Inputs + .Take(payload.StreamInputCount) + .Select(static input => input.EffectiveScale) + .ToArray(); + Rect[] bufferBounds = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + inputBounds, + payload.BoundsItems, + resolvedBounds); + return payload.WorkingScalePolicy is { } policy + ? policy.Resolve( + streamScales, + inputBounds, + bufferBounds, + options.OutputScale, + options.MaxWorkingScale) + : FilterEffectWorkingScalePolicy.ResolveMaterialized( + streamScales, + bufferBounds, + options.OutputScale, + options.MaxWorkingScale); + } + case RenderFragmentKind.OpaqueSource: + case RenderFragmentKind.OpaqueMap: + case RenderFragmentKind.OpaqueCombine: + case RenderFragmentKind.OpaqueExpand: + return ((OpaqueRenderFragmentPayload)reference.Payload!).Description.Scale.Resolve( + inputScales, + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale); + case RenderFragmentKind.TargetCapture: + { + TargetCaptureScaleContract scale = + ((TargetCaptureRenderFragmentPayload)reference.Payload!).Description.Scale; + return scale.PreservesTargetSupply + ? EffectiveScale.Unbounded + : scale.ResolveDeclared( + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale); + } + case RenderFragmentKind.TargetScope: + return ((TargetScopeRenderFragmentPayload)reference.Payload!).Description.Scale.Resolve( + inputScales, + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale); + case RenderFragmentKind.RawTargetScope: + return ((RawTargetScopeRenderFragmentPayload)reference.Payload!).Description.Scale.Resolve( + inputScales, + resolvedBounds, + options.OutputScale, + options.MaxWorkingScale); + default: + return reference.RecordedEffectiveScale; + } + } + + private static Func ResolveForwardHitTest( + RenderFragmentReference reference, + Rect resolvedBounds) + { + return reference.Kind switch + { + RenderFragmentKind.ContributeValues + or RenderFragmentKind.Opacity + or RenderFragmentKind.Blend + or RenderFragmentKind.OpacityMask + or RenderFragmentKind.Shader + => reference.Inputs[0].HitTest, + RenderFragmentKind.Geometry + => CreateResolvedHitTest( + ((GeometryRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + => CreateResolvedHitTest( + ((OpaqueRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + RenderFragmentKind.FilterEffectSegment => resolvedBounds.Contains, + RenderFragmentKind.MaterializedInput + => CreateResolvedHitTest( + ((MaterializedInputRenderFragmentPayload)reference.Payload!).Description.HitTest, + resolvedBounds, + reference.Inputs, + []), + RenderFragmentKind.TargetCapture + => CreateResolvedHitTest( + ((TargetCaptureRenderFragmentPayload)reference.Payload!).Description.HitTest, + resolvedBounds, + reference.Inputs, + []), + RenderFragmentKind.BuiltInBackdropCapture + => CreateResolvedHitTest( + ((BuiltInBackdropCaptureRenderFragmentPayload)reference.Payload!).Description.HitTest, + resolvedBounds, + reference.Inputs, + []), + RenderFragmentKind.Layer or RenderFragmentKind.TargetLayerScope + => point => reference.Inputs.Any(input => input.HitTest(point)), + RenderFragmentKind.TargetScope + => CreateResolvedHitTest( + ((TargetScopeRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + RenderFragmentKind.RawTargetScope + => CreateResolvedHitTest( + ((RawTargetScopeRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + RenderFragmentKind.RawTargetCommand + => CreateResolvedHitTest( + ((RawTargetCommandRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + RenderFragmentKind.TargetCommand + => CreateResolvedHitTest( + ((TargetCommandRenderFragmentPayload)reference.Payload!).Description, + resolvedBounds, + reference.Inputs), + _ => throw new InvalidOperationException( + $"Fragment kind '{reference.Kind}' has no symbolic hit-test lowering rule."), + }; + } + + private static Func CreateResolvedHitTest( + GeometryDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + OpaqueRenderDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + TargetScopeDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + RawTargetScopeDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + RawTargetCommandDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + TargetCommandDescription description, + Rect outputBounds, + IReadOnlyList inputs) + => CreateResolvedHitTest(description.HitTest, outputBounds, inputs, description.Resources); + + private static Func CreateResolvedHitTest( + RenderHitTestContract contract, + Rect outputBounds, + IReadOnlyList inputs, + IReadOnlyList resources) + { + RenderHitTestInput[] views = inputs + .Select(static input => new RenderHitTestInput(input.Bounds, input.HitTest)) + .ToArray(); + return point => contract.Evaluate(outputBounds, views, resources, point); + } + + private static EffectiveScale ResolveMaterializedScale( + EffectiveScale[] inputScales, + Rect resolvedBounds, + RenderRequestOptions options) + { + float workingScale = RenderScaleUtilities.ResolveWorkingScale( + inputScales, + options.OutputScale, + options.MaxWorkingScale); + workingScale = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + resolvedBounds, + workingScale); + return EffectiveScale.At(workingScale); + } + + private static Rect ResolveLayerBounds( + RenderFragmentReference reference, + Rect domain) + { + Rect bounds = default; + foreach (RenderFragmentReference input in reference.Inputs) + { + if (input.ContributesValuesToTarget) + bounds = bounds.Union(input.Bounds); + if (TargetWriteMetadataResolver.Resolve(input, domain) is { } affected) + bounds = bounds.Union(affected); + } + + return bounds.Intersect(domain); + } + + private static Rect UnionInputBounds(RenderFragmentReference reference) + { + Rect bounds = default; + foreach (RenderFragmentReference input in reference.Inputs) + bounds = bounds.Union(input.Bounds); + return bounds; + } + + private static RenderNodeMeasurement Measure( + RenderRequestOptions options, + IReadOnlyList roots) + { + Rect outputBounds = default; + Rect queryBounds = default; + int minimum = 0; + int? maximum = 0; + float densestSupply = 0; + bool hasContributingValues = false; + bool hasTargetEffects = false; + + foreach (RenderFragmentReference root in roots) + { + minimum = checked(minimum + root.ValueCardinality.Minimum); + maximum = maximum is null || root.ValueCardinality.Maximum is null + ? null + : checked(maximum.Value + root.ValueCardinality.Maximum.Value); + hasContributingValues |= root.ContributesValuesToTarget; + hasTargetEffects |= root.HasTargetEffects; + + if (root.ContributesValuesToTarget) + outputBounds = outputBounds.Union(root.Bounds); + if (TargetWriteMetadataResolver.Resolve(root, options.TargetDomain) is { } affected) + outputBounds = outputBounds.Union(affected); + queryBounds = queryBounds.Union(ResolveQueryBounds(root)); + + if (!root.EffectiveScale.IsUnbounded) + densestSupply = MathF.Max(densestSupply, root.EffectiveScale.Value); + } + + if (options.TargetDomain is { } targetDomain) + outputBounds = outputBounds.Intersect(targetDomain); + + EffectiveScale effectiveScale = densestSupply > 0 + ? EffectiveScale.At(densestSupply) + : EffectiveScale.Unbounded; + return new RenderNodeMeasurement( + outputBounds, + queryBounds, + effectiveScale, + RenderValueCardinality.Range(minimum, maximum), + roots.Count > 0, + hasContributingValues, + hasTargetEffects); + } + + private static Rect ResolveQueryBounds(RenderFragmentReference reference) + { + if (reference.Kind is RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture) + { + return reference.ContributesValuesToTarget ? reference.Bounds : Rect.Empty; + } + + if (reference.Payload is TargetCommandRenderFragmentPayload command) + return command.Description.QueryBounds; + if (reference.Payload is RawTargetCommandRenderFragmentPayload rawCommand) + return rawCommand.Description.QueryBounds; + if (reference.Payload is LayerRenderFragmentPayload layerPayload) + { + if (layerPayload is { DomainIsQueryFootprint: true, Domain: { } queryFootprint }) + return queryFootprint; + + Rect layerQuery = Rect.Empty; + foreach (RenderFragmentReference input in reference.Inputs) + layerQuery = layerQuery.Union(ResolveQueryBounds(input)); + return layerQuery.Intersect(layerPayload.Domain ?? reference.Bounds); + } + if (reference.ContributesValuesToTarget) + return reference.Bounds.Union(ResolveDeclaredQueryFootprint(reference)); + if (reference.Kind == RenderFragmentKind.OpacityMask) + { + return reference.Inputs.IsDefaultOrEmpty + ? Rect.Empty + : ResolveQueryBounds(reference.Inputs[0]); + } + + Rect result = default; + foreach (RenderFragmentReference input in reference.Inputs) + result = result.Union(ResolveQueryBounds(input)); + + if (result.Width == 0 || result.Height == 0) + return Rect.Empty; + + return reference.Payload switch + { + TargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(result), + RawTargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(result), + TargetLayerScopeRenderFragmentPayload layer + => layer.Region.Kind == TargetRegionKind.Region + ? result.Intersect(layer.Region.Value) + : layer.Region.Kind == TargetRegionKind.Empty ? Rect.Empty : result, + _ => result, + }; + } + + // A fixed-size viewport declares a query footprint wider than what it draws, and every value-contributing + // ancestor above it reports only its own content-derived bounds. The footprint is therefore resolved on its + // own descent, mapped through the same scopes the ordinary descent maps through. + private static Rect ResolveDeclaredQueryFootprint(RenderFragmentReference reference) + { + if (reference.Payload is LayerRenderFragmentPayload + { DomainIsQueryFootprint: true, Domain: { } queryFootprint }) + { + return queryFootprint; + } + + Rect result = default; + foreach (RenderFragmentReference input in reference.Inputs) + result = result.Union(ResolveDeclaredQueryFootprint(input)); + + if (result.Width == 0 || result.Height == 0) + return Rect.Empty; + + return reference.Payload switch + { + TargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(result), + RawTargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.TransformBounds(result), + TargetLayerScopeRenderFragmentPayload layer + => layer.Region.Kind == TargetRegionKind.Region + ? result.Intersect(layer.Region.Value) + : layer.Region.Kind == TargetRegionKind.Empty ? Rect.Empty : result, + _ => result, + }; + } + + private static RequiredRegion GetRootRequirement( + RenderFragmentReference root, + Rect finalCommitBounds, + Rect? targetDomain) + { + RequiredRegion result = RequiredRegion.Empty; + if (root.ContributesValuesToTarget) + result = result.Union(RequiredRegion.Region(finalCommitBounds.Intersect(root.Bounds))); + + if (TargetWriteMetadataResolver.Resolve(root, targetDomain) is { } affected) + { + result = result.Union( + RequiredRegion.Region(finalCommitBounds.Intersect(affected))); + } + + return result; + } + + private static ImmutableArray GetInputRequirements( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + Rect? targetDomain) + { + if (reference.Inputs.IsDefaultOrEmpty) + return []; + if (outputRequirement.IsEmpty) + return ImmutableArray.CreateRange( + Enumerable.Repeat(RequiredRegion.Empty, reference.Inputs.Length)); + + return reference.Payload switch + { + ShaderRenderFragmentPayload shader + => MapUnary(reference, outputRequirement, shader.Description.Bounds), + GeometryRenderFragmentPayload geometry + => MapUnary(reference, outputRequirement, geometry.Description.Bounds), + TargetScopeRenderFragmentPayload scope + => MapTargetScope( + reference, + outputRequirement, + scope.Description.Bounds, + targetDomain), + RawTargetScopeRenderFragmentPayload + => FullInputs(reference), + OpaqueRenderFragmentPayload opaque + => MapOpaque(reference, outputRequirement, opaque.Description.Bounds), + TargetCommandRenderFragmentPayload or RawTargetCommandRenderFragmentPayload + => FullInputs(reference), + FilterEffectSegmentRenderFragmentPayload legacy + => MapLegacyFilter(reference, outputRequirement, legacy, targetDomain), + BlendRenderFragmentPayload blend + when BlendModeRenderNode.RequiresFullTargetRegion(blend.BlendMode) + => MapDestructiveBlendInput(reference, outputRequirement), + OpacityRenderFragmentPayload or BlendRenderFragmentPayload + => MapScopedIdentityInputs(reference, outputRequirement, targetDomain), + OpacityMaskRenderFragmentPayload + => MapOpacityMask(reference, outputRequirement, targetDomain), + LayerRenderFragmentPayload layer + => MapScopeInputs(reference, outputRequirement, layer.Domain ?? reference.Bounds), + TargetLayerScopeRenderFragmentPayload layer + => MapScopeInputs( + reference, + outputRequirement, + ResolveTargetRegion(layer.Region, targetDomain)), + _ => MapIdentityInputs(reference, outputRequirement), + }; + } + + private static ImmutableArray MapUnary( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + RenderBoundsContract bounds) + { + if (reference.Inputs.Length != 1) + throw new InvalidOperationException("A unary bounds contract requires exactly one input fragment."); + if (bounds.RequiresFullInput) + return [RequiredRegion.Full]; + if (outputRequirement.IsFull + && bounds.StructuralIdentity is RenderBoundsStructuralIdentity + { + Kind: RenderBoundsContractKind.Identity, + }) + { + return [RequiredRegion.Full]; + } + + Rect requested = outputRequirement.Resolve(reference.Bounds); + Rect required = bounds.GetRequiredInputBounds(requested); + return [RequiredRegion.Region(required.Intersect(reference.Inputs[0].Bounds))]; + } + + private static ImmutableArray MapTargetScope( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + RenderBoundsContract bounds, + Rect? targetDomain) + { + if (reference.Inputs.Length != 1) + throw new InvalidOperationException("A target scope requires exactly one input fragment."); + + RequiredRegion required; + if (bounds.RequiresFullInput) + { + required = RequiredRegion.Full; + } + else if (outputRequirement.IsFull + && bounds.StructuralIdentity is RenderBoundsStructuralIdentity + { + Kind: RenderBoundsContractKind.Identity, + }) + { + required = RequiredRegion.Full; + } + else + { + Rect requested = outputRequirement.Resolve(ResolveSemanticBounds(reference, targetDomain)); + required = RequiredRegion.Region(bounds.GetRequiredInputBounds(requested)); + } + + Rect? inputTargetDomain = targetDomain is { } domain + ? bounds.GetRequiredInputBounds(domain) + : null; + return [RestrictToSemanticCoverage(reference.Inputs[0], required, inputTargetDomain)]; + } + + private static ImmutableArray MapOpaque( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + OpaqueRenderBoundsContract bounds) + { + if (RequiresFullInputs(bounds)) + return FullInputs(reference); + if (outputRequirement.IsFull && IsIdentityMap(bounds)) + return FullInputs(reference); + + Rect requested = outputRequirement.Resolve(reference.Bounds); + Rect[] inputBounds = reference.Inputs.Select(static input => input.Bounds).ToArray(); + IReadOnlyList required = bounds.GetRequiredInputBounds(requested, inputBounds); + var result = ImmutableArray.CreateBuilder(required.Count); + for (int index = 0; index < required.Count; index++) + { + result.Add(RequiredRegion.Region(required[index].Intersect(inputBounds[index]))); + } + + return result.MoveToImmutable(); + } + + private static bool RequiresFullInputs(OpaqueRenderBoundsContract bounds) + { + if (bounds.Kind == OpaqueRenderBoundsKind.FullInputs) + return true; + + return bounds.StructuralIdentity is OpaqueRenderBoundsStructuralIdentity + { + Kind: OpaqueRenderBoundsKind.Map, + ForwardIdentity: RenderBoundsStructuralIdentity + { + Kind: RenderBoundsContractKind.FullInput or RenderBoundsContractKind.CustomFullInput, + }, + }; + } + + private static bool IsIdentityMap(OpaqueRenderBoundsContract bounds) + { + return bounds.StructuralIdentity is OpaqueRenderBoundsStructuralIdentity + { + Kind: OpaqueRenderBoundsKind.Map, + ForwardIdentity: RenderBoundsStructuralIdentity + { + Kind: RenderBoundsContractKind.Identity, + }, + }; + } + + private static ImmutableArray MapScopedIdentityInputs( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + Rect? targetDomain) + { + var result = ImmutableArray.CreateBuilder(reference.Inputs.Length); + foreach (RenderFragmentReference input in reference.Inputs) + result.Add(RestrictToSemanticCoverage(input, outputRequirement, targetDomain)); + return result.MoveToImmutable(); + } + + private static ImmutableArray MapDestructiveBlendInput( + RenderFragmentReference reference, + RequiredRegion outputRequirement) + { + if (reference.Inputs.Length != 1) + { + throw new InvalidOperationException( + "A destructive blend command requires exactly one source input."); + } + + return [outputRequirement.Intersect(reference.Inputs[0].Bounds)]; + } + + private static ImmutableArray MapLegacyFilter( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + FilterEffectSegmentRenderFragmentPayload payload, + Rect? targetDomain) + { + if (outputRequirement.IsFull + || reference.BoundsRequirement != RenderFragmentBoundsRequirement.Finite) + { + return FullInputs(reference); + } + + Rect requestedOutput = outputRequirement.Resolve(ResolveSemanticBounds(reference, targetDomain)); + if (!LegacyFilterSamplingSupport.TryResolveSampledInput(payload.BoundsItems, requestedOutput, out Rect requested)) + return FullInputs(reference); + + var result = ImmutableArray.CreateBuilder(reference.Inputs.Length); + for (int index = 0; index < reference.Inputs.Length; index++) + { + // A brush dependency is sampled by an opaque callback over the whole brush frame, so its + // region cannot be narrowed by the stream's backward region. + result.Add(index < payload.StreamInputCount + ? RestrictToSemanticCoverage( + reference.Inputs[index], + RequiredRegion.Region(requested), + targetDomain) + : RequiredRegion.Full); + } + + return result.MoveToImmutable(); + } + + private static ImmutableArray MapOpacityMask( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + Rect? targetDomain) + { + var result = ImmutableArray.CreateBuilder(reference.Inputs.Length); + result.Add(RestrictToSemanticCoverage(reference.Inputs[0], outputRequirement, targetDomain)); + for (int index = 1; index < reference.Inputs.Length; index++) + result.Add(RequiredRegion.Full); + return result.MoveToImmutable(); + } + + private static RequiredRegion RestrictToSemanticCoverage( + RenderFragmentReference input, + RequiredRegion requirement, + Rect? targetDomain) + { + RequiredRegion result = requirement.Intersect(input.Bounds); + if (TargetWriteMetadataResolver.Resolve(input, targetDomain) is { } affected) + result = result.Union(requirement.Intersect(affected)); + return result; + } + + private static Rect ResolveSemanticBounds( + RenderFragmentReference reference, + Rect? targetDomain) + { + Rect result = reference.Bounds; + if (TargetWriteMetadataResolver.Resolve(reference, targetDomain) is { } affected) + result = result.Union(affected); + return result; + } + + private static ImmutableArray MapScopeInputs( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + Rect domain) + { + if (domain.Width == 0 || domain.Height == 0) + { + return ImmutableArray.CreateRange( + Enumerable.Repeat(RequiredRegion.Empty, reference.Inputs.Length)); + } + + var result = ImmutableArray.CreateBuilder(reference.Inputs.Length); + foreach (RenderFragmentReference input in reference.Inputs) + { + RequiredRegion inputRequirement = RequiredRegion.Empty; + if (input.ContributesValuesToTarget) + { + inputRequirement = inputRequirement.Union( + outputRequirement.Intersect(input.Bounds.Intersect(domain))); + } + + if (TargetWriteMetadataResolver.Resolve(input, domain) is { } affected) + { + inputRequirement = inputRequirement.Union( + outputRequirement.Intersect(affected.Intersect(domain))); + } + + result.Add(inputRequirement); + } + + return result.MoveToImmutable(); + } + + private static ImmutableArray MapIdentityInputs( + RenderFragmentReference reference, + RequiredRegion outputRequirement) + { + var result = ImmutableArray.CreateBuilder(reference.Inputs.Length); + foreach (RenderFragmentReference input in reference.Inputs) + { + result.Add(outputRequirement.IsFull + ? RequiredRegion.Full + : outputRequirement.Intersect(input.Bounds)); + } + return result.MoveToImmutable(); + } + + private static ImmutableArray FullInputs(RenderFragmentReference reference) + => ImmutableArray.CreateRange( + Enumerable.Repeat(RequiredRegion.Full, reference.Inputs.Length)); + + private static RequiredRegion? GetTargetAccessRequirement( + RenderFragmentReference reference, + RequiredRegion outputRequirement, + Rect? targetDomain) + { + return reference.Payload switch + { + TargetCaptureRenderFragmentPayload capture + => MapTargetAccess(outputRequirement, capture.Description.SourceRegion, targetDomain), + BuiltInBackdropCaptureRenderFragmentPayload capture + => MapTargetAccess(outputRequirement, capture.Description.SourceRegion, targetDomain), + TargetCommandRenderFragmentPayload command + when command.Description.Access == TargetAccess.Readback + => MapTargetAccess(RequiredRegion.Full, command.Description.AffectedRegion, targetDomain), + TargetCommandRenderFragmentPayload command + => MapTargetAccess(outputRequirement, command.Description.AffectedRegion, targetDomain), + BlendRenderFragmentPayload blend + when BlendModeRenderNode.RequiresFullTargetRegion(blend.BlendMode) + => MapTargetAccess(outputRequirement, TargetRegion.Full, targetDomain), + RawTargetCommandRenderFragmentPayload + => outputRequirement.IsEmpty ? RequiredRegion.Empty : RequiredRegion.Full, + RawTargetScopeRenderFragmentPayload + => outputRequirement.IsEmpty ? RequiredRegion.Empty : RequiredRegion.Full, + TargetLayerScopeRenderFragmentPayload layer + => MapTargetAccess(outputRequirement, layer.Region, targetDomain), + LayerRenderFragmentPayload layer + => outputRequirement.Intersect(layer.Domain ?? reference.Bounds), + _ => null, + }; + } + + private static RequiredRegion MapTargetAccess( + RequiredRegion requirement, + TargetRegion access, + Rect? targetDomain) + { + if (requirement.IsEmpty || access.Kind == TargetRegionKind.Empty) + return RequiredRegion.Empty; + if (access.Kind == TargetRegionKind.Full && requirement.IsFull) + return RequiredRegion.Full; + + Rect domain = ResolveTargetRegion(access, targetDomain); + return requirement.IsFull + ? RequiredRegion.Region(domain) + : requirement.Intersect(domain); + } + + private static Rect ResolveTargetRegion(TargetRegion region, Rect? targetDomain) + { + return region.Kind switch + { + TargetRegionKind.Empty => Rect.Empty, + TargetRegionKind.Region => region.Value, + TargetRegionKind.Full when targetDomain is { } domain => domain, + TargetRegionKind.Full => throw new RenderTargetDomainRequiredException( + "A target-less request with Full target access requires a finite TargetDomain."), + _ => throw new InvalidOperationException("The target region is uninitialized."), + }; + } + + private static bool UnionRequirement( + Dictionary requirements, + RenderFragmentReference reference, + RequiredRegion requirement) + { + RequiredRegion previous = GetRequirement(requirements, reference); + RequiredRegion combined = previous.Union(requirement); + requirements[reference] = combined; + return combined != previous; + } + + private static RequiredRegion GetRequirement( + Dictionary requirements, + RenderFragmentReference reference) + => requirements.TryGetValue(reference, out RequiredRegion requirement) + ? requirement + : RequiredRegion.Empty; + + private static RenderFragmentId GetId(RenderFragmentReference reference) + => reference.Id + ?? throw new InvalidOperationException( + "Region analysis requires every fragment to be committed to the request graph."); +} + +internal sealed class RegionAnalysis +{ + public RegionAnalysis( + RenderNodeMeasurement measurement, + Rect? targetDomain, + Rect? requestedRegion, + Rect finalCommitBounds, + RequiredRegion finalCommitRegion, + ImmutableDictionary fragmentRequirements, + ImmutableDictionary valueRequirements, + ImmutableDictionary targetAccessRequirements, + ImmutableDictionary metadata, + ImmutableHashSet backingTargetBackdropCaptures) + { + Measurement = measurement; + TargetDomain = targetDomain; + RequestedRegion = requestedRegion; + FinalCommitBounds = finalCommitBounds; + FinalCommitRegion = finalCommitRegion; + FragmentRequirements = fragmentRequirements; + ValueRequirements = valueRequirements; + TargetAccessRequirements = targetAccessRequirements; + Metadata = metadata; + BackingTargetBackdropCaptures = backingTargetBackdropCaptures; + } + + public RenderNodeMeasurement Measurement { get; } + + public Rect RootOutputExtent => Measurement.OutputBounds; + + public Rect QueryBounds => Measurement.QueryBounds; + + public Rect? TargetDomain { get; } + + public Rect? RequestedRegion { get; } + + public Rect FinalCommitBounds { get; } + + public RequiredRegion FinalCommitRegion { get; } + + public ImmutableDictionary FragmentRequirements { get; } + + public ImmutableDictionary ValueRequirements { get; } + + public ImmutableDictionary TargetAccessRequirements { get; } + + public ImmutableDictionary Metadata { get; } + + public ImmutableHashSet BackingTargetBackdropCaptures { get; } + + public RequiredRegion GetFragmentRequirement(RenderFragmentReference reference) + => FragmentRequirements[GetId(reference)]; + + public RequiredRegion GetValueRequirement(RenderValueId valueId) + => ValueRequirements[valueId]; + + public RequiredRegion GetTargetAccessRequirement(RenderFragmentReference reference) + => TargetAccessRequirements.TryGetValue(GetId(reference), out RequiredRegion requirement) + ? requirement + : RequiredRegion.Empty; + + public ResolvedFragmentMetadata GetMetadata(RenderFragmentReference reference) + => Metadata[GetId(reference)]; + + private static RenderFragmentId GetId(RenderFragmentReference reference) + => reference.Id + ?? throw new InvalidOperationException("The fragment was not committed to the request graph."); +} + +internal readonly record struct ResolvedFragmentMetadata( + Rect Bounds, + Rect QueryBounds, + EffectiveScale EffectiveScale); + +internal readonly record struct RequiredRegion +{ + private readonly RequiredRegionKind _kind; + private readonly Rect _value; + + private RequiredRegion(RequiredRegionKind kind, Rect value = default) + { + _kind = kind; + _value = value; + } + + public static RequiredRegion Empty { get; } = new(RequiredRegionKind.Empty); + + public static RequiredRegion Full { get; } = new(RequiredRegionKind.Full); + + public static RequiredRegion Region(Rect value) + { + RenderRectValidation.ThrowIfInvalidResult( + value, + "A required region must be finite and have non-negative dimensions."); + return value.Width == 0 || value.Height == 0 + ? Empty + : new RequiredRegion(RequiredRegionKind.Region, value); + } + + public bool IsEmpty => _kind == RequiredRegionKind.Empty; + + public bool IsFull => _kind == RequiredRegionKind.Full; + + public Rect Value + => _kind == RequiredRegionKind.Region + ? _value + : throw new InvalidOperationException("Only a finite required region has a Rect value."); + + public RequiredRegion Union(RequiredRegion other) + { + ThrowIfUninitialized(); + other.ThrowIfUninitialized(); + if (IsFull || other.IsFull) + return Full; + if (IsEmpty) + return other; + if (other.IsEmpty) + return this; + return Region(_value.Union(other._value)); + } + + public RequiredRegion Intersect(Rect bounds) + { + ThrowIfUninitialized(); + RenderRectValidation.ThrowIfInvalidInput(bounds, nameof(bounds)); + if (IsEmpty) + return Empty; + if (IsFull) + return bounds.Width == 0 || bounds.Height == 0 ? Empty : Region(bounds); + return Region(_value.Intersect(bounds)); + } + + public Rect Resolve(Rect fullBounds) + { + ThrowIfUninitialized(); + RenderRectValidation.ThrowIfInvalidInput(fullBounds, nameof(fullBounds)); + return _kind switch + { + RequiredRegionKind.Empty => Rect.Empty, + RequiredRegionKind.Full => fullBounds, + RequiredRegionKind.Region => _value, + _ => throw new InvalidOperationException("The required region is uninitialized."), + }; + } + + private void ThrowIfUninitialized() + { + if (_kind == RequiredRegionKind.Uninitialized) + { + throw new InvalidOperationException( + "default(RequiredRegion) is uninitialized; use Empty, Full, or Region."); + } + } +} + +internal enum RequiredRegionKind : byte +{ + Uninitialized, + Empty, + Full, + Region, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderCacheResolver.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderCacheResolver.cs new file mode 100644 index 0000000000..c18b98ce3d --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderCacheResolver.cs @@ -0,0 +1,1906 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +internal readonly record struct RenderCacheFormatIdentity( + string PixelFormat, + string AlphaType, + string ColorSpace) +{ + public static RenderCacheFormatIdentity LinearPremultipliedRgba16Float { get; } = + new("RGBA16Float", "Premultiplied", "LinearSrgb"); + + public void ThrowIfUninitialized(string parameterName) + { + if (string.IsNullOrWhiteSpace(PixelFormat) + || string.IsNullOrWhiteSpace(AlphaType) + || string.IsNullOrWhiteSpace(ColorSpace)) + { + throw new ArgumentException( + "A render-cache format identity must name its pixel, alpha, and color-space contracts.", + parameterName); + } + } +} + +internal readonly record struct RenderCacheDeviceContextIdentity( + object DeviceIdentity, + object ContextIdentity) +{ + public void ThrowIfUninitialized(string parameterName) + { + if (DeviceIdentity is null || ContextIdentity is null) + { + throw new ArgumentException( + "A render-cache device identity requires non-null device and context components.", + parameterName); + } + } +} + +internal readonly record struct RenderCacheResolutionContext +{ + public RenderCacheResolutionContext( + RenderCacheFormatIdentity format, + RenderCacheDeviceContextIdentity deviceContext, + bool allowPersistentLookup = true, + bool allowCapturePublication = true, + Vector deviceGridOffset = default) + { + format.ThrowIfUninitialized(nameof(format)); + deviceContext.ThrowIfUninitialized(nameof(deviceContext)); + Format = format; + DeviceContext = deviceContext; + AllowPersistentLookup = allowPersistentLookup; + AllowCapturePublication = allowCapturePublication; + DeviceGridOffset = deviceGridOffset; + } + + public RenderCacheFormatIdentity Format { get; } + + public RenderCacheDeviceContextIdentity DeviceContext { get; } + + public bool AllowPersistentLookup { get; } + + public bool AllowCapturePublication { get; } + + public Vector DeviceGridOffset { get; } +} + +/// +/// Complete runtime identity for one materialized render-cache value. The hash is a bucket hint only; +/// compares every retained component. +/// +internal sealed class RenderOutputCacheIdentity : IEquatable +{ + private readonly object _candidateKey; + private readonly RenderFragmentOutputIdentity _fragment; + private readonly Rect _bounds; + private readonly RequiredRegion _coverage; + private readonly int _densityBits; + private readonly RenderCacheFormatIdentity _format; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly FusionMode _fusionMode; + private readonly RenderCacheDeviceContextIdentity _deviceContext; + private readonly Vector _deviceGridOffset; + + public RenderOutputCacheIdentity( + object candidateKey, + RenderFragmentOutputIdentity fragment, + Rect bounds, + RequiredRegion coverage, + float density, + RenderCacheFormatIdentity format, + RenderIntent intent, + RenderRequestPurpose purpose, + FusionMode fusionMode, + RenderCacheDeviceContextIdentity deviceContext, + Vector deviceGridOffset = default) + { + ArgumentNullException.ThrowIfNull(candidateKey); + ArgumentNullException.ThrowIfNull(fragment); + if (!RenderRectValidation.IsFiniteNonNegative(bounds)) + throw new ArgumentException("Cache bounds must be finite and non-negative.", nameof(bounds)); + if (!float.IsFinite(density) || density <= 0) + throw new ArgumentOutOfRangeException(nameof(density), density, "Cache density must be finite and positive."); + format.ThrowIfUninitialized(nameof(format)); + deviceContext.ThrowIfUninitialized(nameof(deviceContext)); + if (!Enum.IsDefined(intent)) + throw new ArgumentOutOfRangeException(nameof(intent)); + if (!Enum.IsDefined(purpose)) + throw new ArgumentOutOfRangeException(nameof(purpose)); + if (!Enum.IsDefined(fusionMode)) + throw new ArgumentOutOfRangeException(nameof(fusionMode)); + + _candidateKey = candidateKey; + _fragment = fragment; + _bounds = bounds; + _coverage = coverage; + _densityBits = BitConverter.SingleToInt32Bits(density); + _format = format; + _intent = intent; + _purpose = purpose; + _fusionMode = fusionMode; + _deviceContext = deviceContext; + _deviceGridOffset = deviceGridOffset; + } + + public object CandidateKey => _candidateKey; + + public Rect Bounds => _bounds; + + public RequiredRegion Coverage => _coverage; + + public float Density => BitConverter.Int32BitsToSingle(_densityBits); + + public RenderCacheFormatIdentity Format => _format; + + public RenderIntent Intent => _intent; + + public RenderRequestPurpose Purpose => _purpose; + + public FusionMode FusionMode => _fusionMode; + + public RenderCacheDeviceContextIdentity DeviceContext => _deviceContext; + + public Vector DeviceGridOffset => _deviceGridOffset; + + public bool Equals(RenderOutputCacheIdentity? other) + => other is not null + && Equals(_candidateKey, other._candidateKey) + && _fragment.Equals(other._fragment) + && _bounds.Equals(other._bounds) + && _coverage.Equals(other._coverage) + && _densityBits == other._densityBits + && _format.Equals(other._format) + && _intent == other._intent + && _purpose == other._purpose + && _fusionMode == other._fusionMode + && _deviceContext.Equals(other._deviceContext) + && _deviceGridOffset.Equals(other._deviceGridOffset); + + public override bool Equals(object? obj) + => obj is RenderOutputCacheIdentity other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine( + _candidateKey, + _fragment, + _bounds, + _coverage, + _densityBits, + _format, + HashCode.Combine(_intent, _purpose, _fusionMode, _deviceContext, _deviceGridOffset)); +} + +/// +/// An acquired cache entry. Payload ownership remains defined by the lookup implementation; the resolver only +/// retains this opaque handle and never reads or disposes the payload. +/// +internal sealed class RenderCacheEntry +{ + public RenderCacheEntry(RenderOutputCacheIdentity identity, object payload) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(payload); + Identity = identity; + Payload = payload; + } + + public RenderOutputCacheIdentity Identity { get; } + + public object Payload { get; } +} + +internal interface IRenderCacheLookup +{ + /// + /// One resolver call observes a stable lookup snapshot. Implementations must not change the result for the + /// same candidate and complete identity until that call returns. + /// + bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry); +} + +internal sealed class RenderNodeCacheLookup : IRenderCacheLookup +{ + public static RenderNodeCacheLookup Instance { get; } = new(); + + private RenderNodeCacheLookup() + { + } + + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry) + { + if (candidate.Cache?.TryGetCachedOutput(identity, out RenderNodeCachedOutput? output) == true) + { + entry = new RenderCacheEntry(identity, output!); + return true; + } + + entry = null; + return false; + } +} + +internal enum RenderCacheResolutionKind : byte +{ + Bypass, + Hit, + MissCapture, + Superseded, +} + +internal enum RenderCacheBypassReason : byte +{ + None, + CacheDisabled, + MetadataOnlyPurpose, + PersistentLookupDisabled, + CapturePublicationDisabled, + EmptyRequirement, + OutsideCacheRules, + ExternalInputExceedsBufferBudget, + TargetTokenDependency, + RawTargetWork, + DeviceGridDependentOutput, + NotMaterializable, + UnstableBoundaryPlan, +} + +internal sealed record RenderCacheHitSubstitution( + RenderCacheCandidateId CandidateId, + RenderFragmentId OriginalProducerId, + ImmutableArray OriginalValueIds, + RenderProvenanceId ProvenanceId, + RenderOutputCacheIdentity Identity, + RenderCacheEntry Entry); + +/// +/// Describes a capture to insert immediately after the original producer. The executor keeps the actual payload +/// request-owned and unpublished; this descriptor becomes publishable only after complete-request success. +/// +internal sealed record RenderCacheMissCapture( + RenderCacheCandidateId CandidateId, + RenderFragmentId ProducerId, + ImmutableArray ValueIds, + RenderProvenanceId ProvenanceId, + RenderOutputCacheIdentity Identity); + +internal sealed record RenderCacheDecision( + RenderCacheCandidate Candidate, + RenderCacheResolutionKind Kind, + RenderCacheBypassReason BypassReason, + RenderOutputCacheIdentity? Identity, + RenderCacheHitSubstitution? Hit, + RenderCacheMissCapture? MissCapture, + RenderCacheCandidateId? SupersededBy); + +internal static class RenderMaterializationDensityPolicy +{ + public static float Clamp( + RenderFragmentReference fragment, + float density) + { + ArgumentNullException.ThrowIfNull(fragment); + if (fragment.Kind is RenderFragmentKind.MaterializedInput + or RenderFragmentKind.BuiltInBackdropCapture) + { + return density; + } + if (fragment.Kind == RenderFragmentKind.ContributeValues + && fragment.Inputs.Length == 1) + { + return Clamp(fragment.Inputs[0], density); + } + + Rect logicalBounds = fragment.Kind == RenderFragmentKind.Layer + && fragment.Payload is LayerRenderFragmentPayload layer + ? layer.Domain ?? fragment.Bounds + : fragment.Bounds; + return RequiresRasterApron(fragment) + ? RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(logicalBounds, density) + : RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(logicalBounds, density); + } + + private static bool RequiresRasterApron(RenderFragmentReference fragment) + { + if (fragment.Kind == RenderFragmentKind.OpaqueSource + && fragment.Payload is OpaqueRenderFragmentPayload opaque) + { + return opaque.Description.HasDirectReplayMaterializationContract; + } + + return fragment.Kind == RenderFragmentKind.TargetScope + && fragment.Payload is TargetScopeRenderFragmentPayload targetScope + && targetScope.Description.IsValueReplayMap; + } +} + +internal sealed record RenderMaterializationDemandResolution( + IReadOnlyDictionary Demands, + IReadOnlySet MaterializedFragments, + IReadOnlySet PreviewDropEligibleMaterializations); + +internal static class RenderMaterializationDemandResolver +{ + private enum DemandUse : byte + { + ReplayTarget, + MaterializeValue, + } + + private readonly record struct PendingDemand( + RenderFragmentReference Fragment, + float Demand, + DemandUse Use, + bool UseSupplyFallback, + bool? IsEffectClassConsumer); + + public static RenderMaterializationDemandResolution Resolve( + IReadOnlyList roots, + float outputScale, + float maxWorkingScale, + IReadOnlySet? cacheBoundaries = null) + { + ArgumentNullException.ThrowIfNull(roots); + if (!float.IsFinite(outputScale) || outputScale <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(outputScale), + outputScale, + "The output density must be finite and positive."); + } + + var result = new Dictionary( + ReferenceEqualityComparer.Instance); + var replayDemands = new Dictionary( + ReferenceEqualityComparer.Instance); + var materializedDemands = new Dictionary( + ReferenceEqualityComparer.Instance); + var materializedUses = new HashSet( + ReferenceEqualityComparer.Instance); + var effectClassUses = new HashSet( + ReferenceEqualityComparer.Instance); + var otherUses = new HashSet( + ReferenceEqualityComparer.Instance); + var pending = new Stack(); + float rootDemand = MathF.Min( + outputScale, + RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale)); + for (int index = roots.Count - 1; index >= 0; index--) + { + pending.Push(new PendingDemand( + roots[index], + rootDemand, + DemandUse.ReplayTarget, + UseSupplyFallback: false, + IsEffectClassConsumer: null)); + } + + while (pending.TryPop(out var item)) + { + RenderFragmentReference fragment = item.Fragment; + if (item.Use == DemandUse.ReplayTarget + && cacheBoundaries?.Contains(fragment) == true) + { + pending.Push(new PendingDemand( + fragment, + item.Demand, + DemandUse.MaterializeValue, + item.UseSupplyFallback, + IsEffectClassConsumer: false)); + continue; + } + + float demand = ResolveDemand( + fragment, + item.Demand, + item.UseSupplyFallback, + maxWorkingScale); + bool outputDemandChanged = MergeDemand(result, fragment, demand); + if (outputDemandChanged && materializedUses.Contains(fragment)) + { + pending.Push(new PendingDemand( + fragment, + demand, + DemandUse.MaterializeValue, + UseSupplyFallback: false, + IsEffectClassConsumer: null)); + } + + if (item.Use == DemandUse.MaterializeValue) + { + materializedUses.Add(fragment); + if (item.IsEffectClassConsumer is true) + effectClassUses.Add(fragment); + else if (item.IsEffectClassConsumer is false) + otherUses.Add(fragment); + float selectedDemand = result[fragment].Value; + if (!MergeProcessedDemand(materializedDemands, fragment, selectedDemand)) + continue; + + EnqueueMaterializedInputs( + fragment, + selectedDemand, + maxWorkingScale, + pending); + continue; + } + + if (!MergeProcessedDemand(replayDemands, fragment, item.Demand)) + continue; + + EnqueueReplayInputs(fragment, item.Demand, maxWorkingScale, pending); + } + + effectClassUses.ExceptWith(otherUses); + return new RenderMaterializationDemandResolution( + result, + materializedUses, + effectClassUses); + } + + private static float ResolveDemand( + RenderFragmentReference fragment, + float requestedDemand, + bool useSupplyFallback, + float maxWorkingScale) + { + if (!fragment.EffectiveScale.IsUnbounded) + return fragment.EffectiveScale.Value; + + float demand = requestedDemand; + // A target command does not provide a caller density. Preserve the legacy + // Layer contract by negotiating from its densest concrete child supply. + if (useSupplyFallback && fragment.Kind == RenderFragmentKind.Layer) + { + foreach (RenderFragmentReference input in fragment.Inputs) + { + if (!input.EffectiveScale.IsUnbounded) + demand = MathF.Max(demand, input.EffectiveScale.Value); + } + } + + demand = MathF.Min( + demand, + RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale)); + return RenderMaterializationDensityPolicy.Clamp( + fragment, + demand); + } + + private static bool MergeDemand( + IDictionary demands, + RenderFragmentReference fragment, + float demand) + { + if (demands.TryGetValue(fragment, out EffectiveScale existing) + && existing.Value >= demand) + { + return false; + } + + demands[fragment] = EffectiveScale.At(demand); + return true; + } + + private static bool MergeProcessedDemand( + IDictionary demands, + RenderFragmentReference fragment, + float demand) + { + if (demands.TryGetValue(fragment, out float existing) && existing >= demand) + return false; + + demands[fragment] = demand; + return true; + } + + private static void EnqueueReplayInputs( + RenderFragmentReference fragment, + float targetDemand, + float maxWorkingScale, + Stack pending) + { + switch (fragment.Kind) + { + case RenderFragmentKind.Opacity: + case RenderFragmentKind.Blend: + case RenderFragmentKind.TargetLayerScope: + EnqueueInputs(fragment, targetDemand, DemandUse.ReplayTarget, pending); + return; + case RenderFragmentKind.RawTargetScope: + // A raw scope hands an unguarded canvas to an opaque callback, so its declared scale + // contract is the only thing that says how the replayed input is consumed. The backward + // half is identity unless the author asked for MapInputSupply, so a scope that carries its + // enlargement in the destination matrix stays unchanged, and one that resamples its input + // gets the density it declared instead of rasterizing at the target's and stretching. + float rawScopeInputDemand = + fragment.Payload is RawTargetScopeRenderFragmentPayload rawScopePayload + ? ResolveMappedInputDemand( + rawScopePayload.Description.Scale, + targetDemand, + maxWorkingScale) + : targetDemand; + EnqueueInputs(fragment, rawScopeInputDemand, DemandUse.ReplayTarget, pending); + return; + case RenderFragmentKind.TargetScope: + TargetScopeDescription targetScope = + ((TargetScopeRenderFragmentPayload)fragment.Payload!).Description; + // Only a scope that says its transform is in the input's own coordinates. One defined + // against the ambient target transform - TransformOperator.Append - has that scale carried + // by the destination already, so pre-scaling the input would rasterize it twice as large + // and then draw it scaled again. + float inputDemand = targetScope.TransformSpace == RenderScopeTransformSpace.InputLogical + ? ResolveMappedInputDemand( + targetScope.Scale, + targetDemand, + maxWorkingScale) + : targetDemand; + EnqueueInputs(fragment, inputDemand, DemandUse.ReplayTarget, pending); + return; + case RenderFragmentKind.OpacityMask: + if (fragment.Inputs.Length > 0) + { + for (int index = fragment.Inputs.Length - 1; index >= 1; index--) + { + pending.Push(new PendingDemand( + fragment.Inputs[index], + targetDemand, + DemandUse.MaterializeValue, + UseSupplyFallback: false, + IsEffectClassConsumer: IsEffectClassConsumer(fragment))); + } + + pending.Push(new PendingDemand( + fragment.Inputs[0], + targetDemand, + DemandUse.ReplayTarget, + UseSupplyFallback: false, + IsEffectClassConsumer: null)); + } + return; + case RenderFragmentKind.TargetCommand: + RenderInputDemandContract commandDemand = + fragment.Payload is TargetCommandRenderFragmentPayload commandPayload + ? commandPayload.Description.InputDemand + : default; + for (int index = fragment.Inputs.Length - 1; index >= 0; index--) + { + pending.Push(new PendingDemand( + fragment.Inputs[index], + ResolveMappedInputDemand( + commandDemand, + index, + targetDemand, + maxWorkingScale), + DemandUse.MaterializeValue, + UseSupplyFallback: true, + IsEffectClassConsumer: IsEffectClassConsumer(fragment))); + } + return; + case RenderFragmentKind.RawTargetCommand: + return; + case RenderFragmentKind.ContributeValues: + EnqueueInputs( + fragment, + targetDemand, + DemandUse.MaterializeValue, + pending, + IsEffectClassConsumer(fragment)); + return; + default: + pending.Push(new PendingDemand( + fragment, + targetDemand, + DemandUse.MaterializeValue, + UseSupplyFallback: false, + IsEffectClassConsumer: false)); + return; + } + } + + private static void EnqueueMaterializedInputs( + RenderFragmentReference fragment, + float valueDemand, + float maxWorkingScale, + Stack pending) + { + switch (fragment.Kind) + { + case RenderFragmentKind.Layer: + EnqueueInputs(fragment, valueDemand, DemandUse.ReplayTarget, pending); + return; + case RenderFragmentKind.TargetScope: + TargetScopeDescription targetScope = + ((TargetScopeRenderFragmentPayload)fragment.Payload!).Description; + float targetScopeInputDemand = + targetScope.TransformSpace == RenderScopeTransformSpace.InputLogical + ? ResolveMappedInputDemand( + targetScope.Scale, + valueDemand, + maxWorkingScale) + : valueDemand; + EnqueueInputs( + fragment, + targetScopeInputDemand, + DemandUse.ReplayTarget, + pending); + return; + case RenderFragmentKind.OpaqueMap: + OpaqueRenderDescription description = + ((OpaqueRenderFragmentPayload)fragment.Payload!).Description; + float inputDemand = ResolveMappedInputDemand( + description.Scale, + valueDemand, + maxWorkingScale); + EnqueueInputs(fragment, inputDemand, DemandUse.MaterializeValue, pending); + return; + case RenderFragmentKind.OpaqueCombine: + case RenderFragmentKind.OpaqueExpand: + OpaqueRenderDescription many = + ((OpaqueRenderFragmentPayload)fragment.Payload!).Description; + if (many.InputDemand.IsUnchanged) + { + EnqueueInputs( + fragment, + valueDemand, + DemandUse.MaterializeValue, + pending, + IsEffectClassConsumer(fragment)); + return; + } + + for (int index = fragment.Inputs.Length - 1; index >= 0; index--) + { + pending.Push(new PendingDemand( + fragment.Inputs[index], + ResolveMappedInputDemand(many.InputDemand, index, valueDemand, maxWorkingScale), + DemandUse.MaterializeValue, + UseSupplyFallback: false, + IsEffectClassConsumer: IsEffectClassConsumer(fragment))); + } + return; + case RenderFragmentKind.Shader: + ShaderDescription shader = + ((ShaderRenderFragmentPayload)fragment.Payload!).Description; + EnqueueInputs( + fragment, + ResolveMappedInputDemand(shader.InputDemand, 0, valueDemand, maxWorkingScale), + DemandUse.MaterializeValue, + pending, + IsEffectClassConsumer(fragment)); + return; + case RenderFragmentKind.Geometry: + GeometryDescription geometry = + ((GeometryRenderFragmentPayload)fragment.Payload!).Description; + EnqueueInputs( + fragment, + ResolveMappedInputDemand(geometry.InputDemand, 0, valueDemand, maxWorkingScale), + DemandUse.MaterializeValue, + pending, + IsEffectClassConsumer(fragment)); + return; + case RenderFragmentKind.MaterializedInput: + case RenderFragmentKind.TargetCapture: + case RenderFragmentKind.BuiltInBackdropCapture: + return; + default: + EnqueueInputs( + fragment, + valueDemand, + DemandUse.MaterializeValue, + pending, + IsEffectClassConsumer(fragment)); + return; + } + } + + private static void EnqueueInputs( + RenderFragmentReference fragment, + float demand, + DemandUse use, + Stack pending, + bool? isEffectClassConsumer = null) + { + for (int index = fragment.Inputs.Length - 1; index >= 0; index--) + { + pending.Push(new PendingDemand( + fragment.Inputs[index], + demand, + use, + UseSupplyFallback: false, + IsEffectClassConsumer: isEffectClassConsumer)); + } + } + + private static float ResolveMappedInputDemand( + RenderInputDemandContract inputDemand, + int inputIndex, + float outputDemand, + float maxWorkingScale) + => BoundMappedInputDemand( + inputDemand.Resolve(inputIndex, EffectiveScale.At(outputDemand)).Value, + maxWorkingScale); + + private static float ResolveMappedInputDemand( + RenderScaleContract scale, + float outputDemand, + float maxWorkingScale) + => BoundMappedInputDemand( + scale.MapOutputDemandToInput(EffectiveScale.At(outputDemand)).Value, + maxWorkingScale); + + private static float BoundMappedInputDemand(float mapped, float maxWorkingScale) + { + // Cap amplification at the request ceiling before another map can observe it. The pending + // input's ResolveDemand pass applies its own logical-bounds buffer budget if it materializes. + return MathF.Min( + mapped, + RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale)); + } + + private static bool IsEffectClassConsumer(RenderFragmentReference fragment) + => fragment.Payload is FilterEffectSegmentRenderFragmentPayload + or ShaderRenderFragmentPayload + or GeometryRenderFragmentPayload; +} + +internal sealed class RenderCacheResolution +{ + public RenderCacheResolution(ImmutableArray decisions) + { + Decisions = decisions; + Hits = [.. decisions + .Where(static item => item.Hit is not null) + .Select(static item => item.Hit!)]; + MissCaptures = [.. decisions + .Where(static item => item.MissCapture is not null) + .Select(static item => item.MissCapture!)]; + } + + public ImmutableArray Decisions { get; } + + public ImmutableArray Hits { get; } + + public ImmutableArray MissCaptures { get; } + + public RenderCacheDecision GetDecision(RenderCacheCandidateId id) + => Decisions.FirstOrDefault(item => item.Candidate.Id == id) + ?? throw new KeyNotFoundException("The cache candidate is not part of this resolution."); + + public HashSet CollectPrunedHitProducers() + { + var result = new HashSet(); + foreach (RenderCacheHitSubstitution hit in Hits) + { + result.Add(hit.OriginalProducerId); + } + + return result; + } +} + +internal sealed record RenderCachePlanningResult( + RenderCacheResolution Resolution, + IReadOnlyDictionary MaterializationDemands, + IReadOnlySet MaterializedFragments, + IReadOnlySet PreviewDropEligibleMaterializations, + int ResolutionPasses); + +/// +/// Resolves cache candidates only after target dependencies, metadata, and required regions are known. It does +/// not mutate the recorded graph: substitutions and capture points refer back to the original producer/value and +/// provenance IDs, leaving every fragment input and target-token edge intact. +/// +internal sealed class RenderCacheResolver +{ + private const int MaximumResolutionPasses = 4; + + public RenderCachePlanningResult Resolve( + RenderRequest request, + RecordedRenderGraph graph, + RegionAnalysis regions, + IReadOnlyList roots, + RenderCacheResolutionContext context, + IRenderCacheLookup? lookup = null) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(regions); + ArgumentNullException.ThrowIfNull(roots); + context.Format.ThrowIfUninitialized(nameof(context)); + context.DeviceContext.ThrowIfUninitialized(nameof(context)); + ValidateRequest(request, graph); + + var index = new ResolverIndex(graph); + var lookupMemo = new LookupMemo(lookup); + var planningBoundaries = new HashSet( + ReferenceEqualityComparer.Instance); + var visitedBoundarySets = new HashSet>( + RenderFragmentReferenceSetComparer.Instance) + { + planningBoundaries, + }; + RenderMaterializationDemandResolution? uncachedDemandResolution = null; + // Selecting a hit or miss changes a fragment from target replay to value + // materialization, which can change descendant density and therefore identity. + // Resolve every candidate independently while finding the fixed point. Parent-hit + // supersedence is an execution selection and must not remove a child from density + // planning before the ancestor identity that selected the hit is stable. + for (int pass = 1; pass <= MaximumResolutionPasses; pass++) + { + RenderMaterializationDemandResolution demandResolution = + RenderMaterializationDemandResolver.Resolve( + roots, + request.Options.OutputScale, + request.Options.MaxWorkingScale, + planningBoundaries); + IReadOnlyDictionary demands = demandResolution.Demands; + uncachedDemandResolution ??= demandResolution; + HashSet nextPlanningBoundaries = + ResolvePlanningBoundaries( + request, + index, + regions, + demands, + context, + lookupMemo); + if (nextPlanningBoundaries.SetEquals(planningBoundaries)) + { + RenderCacheResolution resolution = ResolveFinal( + request, + index, + regions, + demands, + context, + lookupMemo); + return new RenderCachePlanningResult( + resolution, + demands, + demandResolution.MaterializedFragments, + demandResolution.PreviewDropEligibleMaterializations, + pass); + } + + if (!visitedBoundarySets.Add(nextPlanningBoundaries)) + { + return CreateUnstableBoundaryFallback( + graph, + uncachedDemandResolution!, + pass); + } + + planningBoundaries = nextPlanningBoundaries; + } + + return CreateUnstableBoundaryFallback( + graph, + uncachedDemandResolution!, + MaximumResolutionPasses); + } + + private static HashSet ResolvePlanningBoundaries( + RenderRequest request, + ResolverIndex index, + RegionAnalysis regions, + IReadOnlyDictionary materializationDemands, + RenderCacheResolutionContext context, + LookupMemo lookupMemo) + { + var result = new HashSet( + ReferenceEqualityComparer.Instance); + Dictionary? identityMemo = + context.AllowCapturePublication + ? null + : []; + foreach (RenderCacheCandidate candidate in index.Graph.CacheCandidates) + { + RecordedRenderFragment recorded = index.Fragments[candidate.FragmentId]; + RenderFragmentReference reference = index.References[candidate.FragmentId]; + CandidateEvaluation evaluation = EvaluateCandidate( + request, + reference, + recorded, + regions, + context, + materializationDemands, + index.DeviceGridAffectedReferences, + index.TransformDependentReferences); + if (evaluation.BypassReason != RenderCacheBypassReason.None) + continue; + + if (context.AllowCapturePublication) + { + result.Add(reference); + continue; + } + + RenderOutputCacheIdentity identity = CreateIdentity( + request, + candidate, + reference, + evaluation, + regions, + context, + materializationDemands, + identityMemo!); + if (context.AllowPersistentLookup + && lookupMemo.TryGet(candidate, identity, out _)) + { + result.Add(reference); + } + } + + return result; + } + + private static RenderCacheResolution ResolveFinal( + RenderRequest request, + ResolverIndex index, + RegionAnalysis regions, + IReadOnlyDictionary materializationDemands, + RenderCacheResolutionContext context, + LookupMemo lookupMemo) + { + CandidateTopology? topology = + context.AllowPersistentLookup + && lookupMemo.HasLookup + && index.Graph.CacheCandidates.Length > 1 + ? index.GetTopology() + : null; + IReadOnlyList candidates = topology is null + ? index.Graph.CacheCandidates + : topology.ParentFirst; + var identityMemo = new Dictionary(); + var decisions = new Dictionary(); + var selectedHits = new List(); + foreach (RenderCacheCandidate candidate in candidates) + { + if (topology is not null) + { + RenderCacheCandidateId superseding = selectedHits + .FirstOrDefault(parent => topology.Descendants[parent].Contains(candidate.Id)); + if (superseding.Value > 0) + { + decisions.Add( + candidate.Id, + Superseded(candidate, superseding)); + continue; + } + } + + RenderCacheDecision decision = ResolveCandidate( + request, + candidate, + index.Fragments[candidate.FragmentId], + index.References[candidate.FragmentId], + regions, + context, + materializationDemands, + lookupMemo, + identityMemo, + index.DeviceGridAffectedReferences, + index.TransformDependentReferences); + decisions.Add(candidate.Id, decision); + if (decision.Kind == RenderCacheResolutionKind.Hit) + selectedHits.Add(candidate.Id); + } + + return new RenderCacheResolution( + [.. index.Graph.CacheCandidates.Select(candidate => decisions[candidate.Id])]); + } + + private static void ValidateRequest( + RenderRequest request, + RecordedRenderGraph graph) + { + if (request.Id != graph.RequestId) + { + throw new ArgumentException( + "The recorded graph belongs to a different render request.", + nameof(graph)); + } + if (request.State != RenderRequestState.RegionsResolved) + { + throw new InvalidOperationException( + "Render-cache resolution requires completed graph, target-dependency, metadata, and region discovery."); + } + } + + private static RenderCachePlanningResult CreateUnstableBoundaryFallback( + RecordedRenderGraph graph, + RenderMaterializationDemandResolution uncachedDemandResolution, + int resolutionPasses) + { + var resolution = new RenderCacheResolution( + [.. graph.CacheCandidates.Select(candidate => + Bypass(candidate, RenderCacheBypassReason.UnstableBoundaryPlan))]); + return new RenderCachePlanningResult( + resolution, + uncachedDemandResolution.Demands, + uncachedDemandResolution.MaterializedFragments, + uncachedDemandResolution.PreviewDropEligibleMaterializations, + resolutionPasses); + } + + private static RenderCacheDecision Superseded( + RenderCacheCandidate candidate, + RenderCacheCandidateId superseding) + => new( + candidate, + RenderCacheResolutionKind.Superseded, + RenderCacheBypassReason.None, + null, + null, + null, + superseding); + + private static RenderCacheDecision ResolveCandidate( + RenderRequest request, + RenderCacheCandidate candidate, + RecordedRenderFragment recorded, + RenderFragmentReference reference, + RegionAnalysis regions, + RenderCacheResolutionContext context, + IReadOnlyDictionary materializationDemands, + LookupMemo lookupMemo, + IDictionary identityMemo, + IReadOnlySet deviceGridAffectedReferences, + IReadOnlySet transformDependentReferences) + { + CandidateEvaluation evaluation = EvaluateCandidate( + request, + reference, + recorded, + regions, + context, + materializationDemands, + deviceGridAffectedReferences, + transformDependentReferences); + if (evaluation.BypassReason != RenderCacheBypassReason.None) + return Bypass(candidate, evaluation.BypassReason); + + RenderOutputCacheIdentity identity = CreateIdentity( + request, + candidate, + reference, + evaluation, + regions, + context, + materializationDemands, + identityMemo); + + if (context.AllowPersistentLookup + && lookupMemo.TryGet(candidate, identity, out RenderCacheEntry? entry)) + { + return new RenderCacheDecision( + candidate, + RenderCacheResolutionKind.Hit, + RenderCacheBypassReason.None, + identity, + new RenderCacheHitSubstitution( + candidate.Id, + recorded.Id, + recorded.Values, + recorded.ProvenanceId, + identity, + entry!), + null, + null); + } + + if (!context.AllowCapturePublication) + return Bypass(candidate, RenderCacheBypassReason.CapturePublicationDisabled, identity); + + return new RenderCacheDecision( + candidate, + RenderCacheResolutionKind.MissCapture, + RenderCacheBypassReason.None, + identity, + null, + new RenderCacheMissCapture( + candidate.Id, + recorded.Id, + recorded.Values, + recorded.ProvenanceId, + identity), + null); + } + + private static RenderOutputCacheIdentity CreateIdentity( + RenderRequest request, + RenderCacheCandidate candidate, + RenderFragmentReference reference, + CandidateEvaluation evaluation, + RegionAnalysis regions, + RenderCacheResolutionContext context, + IReadOnlyDictionary materializationDemands, + IDictionary identityMemo) + => new( + candidate.CacheKey, + RenderFragmentOutputIdentity.Create( + reference, + graphRequestId: request.Id, + materializationDemands, + identityMemo, + request.Options.OutputScale, + request.Options.MaxWorkingScale, + regions), + evaluation.Metadata.Bounds, + evaluation.Coverage, + evaluation.Density, + context.Format, + request.Options.Intent, + request.Options.Purpose, + request.Options.FusionMode, + context.DeviceContext, + evaluation.DeviceGridOffset); + + private static CandidateEvaluation EvaluateCandidate( + RenderRequest request, + RenderFragmentReference reference, + RecordedRenderFragment recorded, + RegionAnalysis regions, + RenderCacheResolutionContext context, + IReadOnlyDictionary materializationDemands, + IReadOnlySet deviceGridAffectedReferences, + IReadOnlySet transformDependentReferences) + { + if (!request.Options.CachePolicy.IsEnabled) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.CacheDisabled); + if (request.Options.Purpose is RenderRequestPurpose.Bounds or RenderRequestPurpose.HitTest) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.MetadataOnlyPurpose); + if (!context.AllowPersistentLookup && !context.AllowCapturePublication) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.PersistentLookupDisabled); + if (ContainsRawTargetWork(reference)) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.RawTargetWork); + if (RenderFragmentTargetDependency.HasExternalTargetDependency(reference)) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.TargetTokenDependency); + if (!reference.CanBeUsedAsValueInput || recorded.Values.IsDefaultOrEmpty) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.NotMaterializable); + if (reference.Kind == RenderFragmentKind.MaterializedInput + && reference.Payload is MaterializedInputRenderFragmentPayload input + && (input.Description.DeviceBounds.Width > RenderScaleUtilities.MaxBufferDimension + || input.Description.DeviceBounds.Height > RenderScaleUtilities.MaxBufferDimension)) + { + return CandidateEvaluation.Bypass( + RenderCacheBypassReason.ExternalInputExceedsBufferBudget); + } + + if (!regions.FragmentRequirements.TryGetValue(recorded.Id, out RequiredRegion requirement) + || !regions.Metadata.TryGetValue(recorded.Id, out ResolvedFragmentMetadata metadata)) + { + return CandidateEvaluation.Bypass(RenderCacheBypassReason.EmptyRequirement); + } + if (requirement.IsEmpty) + return CandidateEvaluation.Bypass(RenderCacheBypassReason.EmptyRequirement); + + float density = ResolveMaterializationDensity( + reference, + materializationDemands); + if (transformDependentReferences.Contains(reference) + || (deviceGridAffectedReferences.Contains(reference) + && DeviceGridAlignment.NormalizePhase(context.DeviceGridOffset, density) != default)) + { + return CandidateEvaluation.Bypass(RenderCacheBypassReason.DeviceGridDependentOutput); + } + + if (!TryResolveCacheCaptureSize( + reference, + regions, + metadata, + requirement, + density, + out PixelSize captureSize)) + { + return CandidateEvaluation.Bypass(RenderCacheBypassReason.NotMaterializable); + } + if (!request.Options.CachePolicy.Rules.Match(captureSize)) + { + return CandidateEvaluation.Bypass(RenderCacheBypassReason.OutsideCacheRules); + } + + return CandidateEvaluation.Eligible( + metadata, + requirement, + density, + deviceGridAffectedReferences.Contains(reference) + ? context.DeviceGridOffset + : default); + } + + private static bool TryResolveCacheCaptureSize( + RenderFragmentReference reference, + RegionAnalysis regions, + ResolvedFragmentMetadata metadata, + RequiredRegion requirement, + float density, + out PixelSize result) + { + if (reference.Kind == RenderFragmentKind.ContributeValues + && !reference.Inputs.IsDefaultOrEmpty) + { + if (reference.Inputs.Length != 1 + || reference.Inputs[0].Id is not { } inputId + || !regions.Metadata.TryGetValue(inputId, out ResolvedFragmentMetadata inputMetadata) + || !regions.FragmentRequirements.TryGetValue(inputId, out RequiredRegion inputRequirement)) + { + result = default; + return false; + } + + RenderFragmentReference input = reference.Inputs[0]; + return TryResolveCacheCaptureSize( + input, + regions, + inputMetadata, + inputRequirement, + RenderMaterializationDensityPolicy.Clamp(input, density), + out result); + } + + if (reference.Kind == RenderFragmentKind.MaterializedInput + && reference.Payload is MaterializedInputRenderFragmentPayload materializedInput) + { + result = materializedInput.Description.DeviceBounds.Size; + return true; + } + + Rect captureBounds = reference.Kind == RenderFragmentKind.Layer + && reference.Payload is LayerRenderFragmentPayload layer + ? layer.Domain ?? reference.Bounds + : reference.Kind is RenderFragmentKind.Opacity + or RenderFragmentKind.OpacityMask + or RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + or RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture + ? reference.Bounds + : requirement.IsFull + ? metadata.Bounds + : requirement.Value; + PixelRect deviceBounds = PixelRect.FromRect(captureBounds, density); + bool requiresRasterApron = + reference.Kind == RenderFragmentKind.TargetScope + && reference.Payload is TargetScopeRenderFragmentPayload targetScope + && targetScope.Description.IsValueReplayMap + || reference.Kind == RenderFragmentKind.OpaqueSource + && reference.Payload is OpaqueRenderFragmentPayload opaque + && opaque.Description.HasDirectReplayMaterializationContract; + if (requiresRasterApron) + { + deviceBounds = RenderScaleUtilities.AddRasterApron(deviceBounds); + } + + result = deviceBounds.Size; + return true; + } + + private static float ResolveMaterializationDensity( + RenderFragmentReference reference, + IReadOnlyDictionary materializationDemands) + { + if (!materializationDemands.TryGetValue(reference, out EffectiveScale demand)) + { + throw new InvalidOperationException( + "A cache candidate is not reachable from the request publication roots."); + } + + float density = demand.Value; + return RenderMaterializationDensityPolicy.Clamp(reference, density); + } + + private static bool ContainsRawTargetWork(RenderFragmentReference reference) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var pending = new Stack(); + pending.Push(reference); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!visited.Add(current)) + continue; + if (current.Kind is RenderFragmentKind.RawTargetScope + or RenderFragmentKind.RawTargetCommand + || current.Kind == RenderFragmentKind.FilterEffectSegment + && !FilterEffectSegmentDirectReplaySupport.CanMaterialize(current)) + { + return true; + } + + foreach (RenderFragmentReference input in current.Inputs) + pending.Push(input); + } + return false; + } + + private static RenderCacheDecision Bypass( + RenderCacheCandidate candidate, + RenderCacheBypassReason reason, + RenderOutputCacheIdentity? identity = null) + => new( + candidate, + RenderCacheResolutionKind.Bypass, + reason, + identity, + null, + null, + null); + + private readonly record struct CandidateEvaluation( + RenderCacheBypassReason BypassReason, + ResolvedFragmentMetadata Metadata, + RequiredRegion Coverage, + float Density, + Vector DeviceGridOffset) + { + public static CandidateEvaluation Bypass(RenderCacheBypassReason reason) + => new(reason, default, default, default, default); + + public static CandidateEvaluation Eligible( + ResolvedFragmentMetadata metadata, + RequiredRegion coverage, + float density, + Vector deviceGridOffset = default) + => new(RenderCacheBypassReason.None, metadata, coverage, density, deviceGridOffset); + } + + private sealed class ResolverIndex + { + private CandidateTopology? _topology; + + public ResolverIndex(RecordedRenderGraph graph) + { + Graph = graph; + Fragments = new Dictionary( + graph.Fragments.Length); + References = new Dictionary( + graph.Fragments.Length); + foreach (RecordedRenderFragment fragment in graph.Fragments) + { + if (fragment.Payload is not RenderFragmentReference reference) + { + throw new InvalidOperationException( + "A cache-planning fragment is missing its semantic reference."); + } + + Fragments.Add(fragment.Id, fragment); + References.Add(fragment.Id, reference); + } + + var deviceGridReferences = ResolveDeviceGridReferences(References.Values); + DeviceGridAffectedReferences = deviceGridReferences.Affected; + TransformDependentReferences = deviceGridReferences.TransformDependent; + } + + public RecordedRenderGraph Graph { get; } + + public Dictionary Fragments { get; } + + public Dictionary References { get; } + + public HashSet DeviceGridAffectedReferences { get; } + + public HashSet TransformDependentReferences { get; } + + public CandidateTopology GetTopology() + => _topology ??= BuildCandidateTopology(Graph, References); + + private static ( + HashSet Affected, + HashSet TransformDependent) ResolveDeviceGridReferences( + IEnumerable references) + { + RenderFragmentReference[] all = references.ToArray(); + var consumers = new Dictionary>( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference reference in all) + consumers.Add(reference, []); + foreach (RenderFragmentReference reference in all) + { + foreach (RenderFragmentReference input in reference.Inputs) + consumers[input].Add(reference); + } + + RenderFragmentReference[] phaseUnsafeMaskScopes = + [ + .. all.Where(IsPhaseUnsafeMaskScope), + ]; + RenderFragmentReference[] sensitive = + [ + .. all.Where(IsDeviceGridSensitive), + .. phaseUnsafeMaskScopes, + ]; + HashSet affected = ExpandConnectedReferences( + sensitive, + consumers); + RenderFragmentReference[] transformRoots = + [ + .. sensitive.Where(reference => HasGridRemappingAncestor(reference, consumers)), + ]; + HashSet transformDependent = ExpandConnectedReferences( + transformRoots, + consumers); + transformDependent.UnionWith(ExpandConnectedReferences( + phaseUnsafeMaskScopes, + consumers)); + return (affected, transformDependent); + } + + private static HashSet ExpandConnectedReferences( + IEnumerable roots, + IReadOnlyDictionary> consumers) + { + var result = new HashSet( + ReferenceEqualityComparer.Instance); + var pending = new Stack(roots); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!result.Add(current)) + continue; + foreach (RenderFragmentReference input in current.Inputs) + pending.Push(input); + } + + var visitedAncestors = new HashSet( + ReferenceEqualityComparer.Instance); + pending = new Stack(roots); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!visitedAncestors.Add(current)) + continue; + result.Add(current); + foreach (RenderFragmentReference consumer in consumers[current]) + pending.Push(consumer); + } + + return result; + } + + private static bool IsDeviceGridSensitive(RenderFragmentReference reference) + { + if (reference.Kind is RenderFragmentKind.FilterEffectSegment + or RenderFragmentKind.Shader + or RenderFragmentKind.Geometry) + { + return true; + } + + if (reference.Payload is OpaqueRenderFragmentPayload opaque) + { + bool declaresPhaseDependence = opaque.Description.DeviceGridSensitivity + == RenderDeviceGridSensitivity.PhaseDependent; + bool isDrawableBrushHost = reference.Kind == RenderFragmentKind.OpaqueCombine + && opaque.Description.HasDirectReplayMaterializationContract; + if (declaresPhaseDependence || isDrawableBrushHost) + return true; + } + + if (reference.Payload is TargetScopeRenderFragmentPayload scope + && scope.Description.DeviceGridSensitivity == RenderDeviceGridSensitivity.PhaseDependent) + { + return true; + } + + return false; + } + + private static bool IsPhaseUnsafeMaskScope(RenderFragmentReference reference) + { + if (reference.Kind != RenderFragmentKind.TargetLayerScope) + return false; + + var visited = new HashSet( + ReferenceEqualityComparer.Instance); + var pending = new Stack(); + pending.Push(reference); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!visited.Add(current)) + continue; + if (current.Kind == RenderFragmentKind.Blend + && ((BlendRenderFragmentPayload)current.Payload!).BlendMode + is BlendMode.DstIn or BlendMode.SrcIn or BlendMode.DstATop) + { + return true; + } + + foreach (RenderFragmentReference input in current.Inputs) + pending.Push(input); + } + + return false; + } + + private static bool HasGridRemappingAncestor( + RenderFragmentReference reference, + IReadOnlyDictionary> consumers) + { + var visited = new HashSet( + ReferenceEqualityComparer.Instance); + var pending = new Stack(consumers[reference]); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!visited.Add(current)) + continue; + if (RenderFragmentDeviceGrid.ResolveMapping(current) + == RenderDeviceGridMapping.Remapped) + { + return true; + } + + foreach (RenderFragmentReference consumer in consumers[current]) + pending.Push(consumer); + } + + return false; + } + } + + internal sealed record CandidateTopology( + Dictionary> Descendants, + RenderCacheCandidate[] ParentFirst); + + private sealed class LookupMemo(IRenderCacheLookup? lookup) + { + private readonly Dictionary> _entries = []; + + public bool HasLookup => lookup is not null; + + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry) + { + if (lookup is null) + { + entry = null; + return false; + } + + if (!_entries.TryGetValue(candidate.Id, out var candidates)) + { + candidates = []; + _entries.Add(candidate.Id, candidates); + } + else if (candidates.TryGetValue(identity, out entry)) + { + return entry is not null; + } + + bool found = lookup.TryGet(candidate, identity, out RenderCacheEntry? candidateEntry) + && candidateEntry is not null + && candidateEntry.Identity.Equals(identity); + entry = found ? candidateEntry : null; + candidates.Add(identity, entry); + return found; + } + } + + private sealed class RenderFragmentReferenceSetComparer + : IEqualityComparer> + { + public static RenderFragmentReferenceSetComparer Instance { get; } = new(); + + public bool Equals( + HashSet? x, + HashSet? y) + => ReferenceEquals(x, y) + || x is not null + && y is not null + && x.SetEquals(y); + + public int GetHashCode(HashSet set) + { + int hash = set.Count; + foreach (RenderFragmentReference reference in set) + hash ^= ReferenceEqualityComparer.Instance.GetHashCode(reference); + return hash; + } + } + + internal static CandidateTopology BuildCandidateTopology( + RecordedRenderGraph graph, + IReadOnlyDictionary references) + { + var result = new Dictionary>(); + var reachable = new HashSet(ReferenceEqualityComparer.Instance); + var pending = new Stack(); + foreach (RenderCacheCandidate parent in graph.CacheCandidates) + { + var descendants = new HashSet(); + bool reachableResolved = false; + foreach (RenderCacheCandidate child in graph.CacheCandidates) + { + if (parent.Id == child.Id) + continue; + if (parent.FragmentId == child.FragmentId) + { + if (parent.AuthoredOrder > child.AuthoredOrder) + descendants.Add(child.Id); + continue; + } + + if (!reachableResolved) + { + CollectReachableInputs(references[parent.FragmentId], reachable, pending); + reachableResolved = true; + } + + if (reachable.Contains(references[child.FragmentId])) + descendants.Add(child.Id); + } + result.Add(parent.Id, descendants); + } + RenderCacheCandidate[] parentFirst = [.. graph.CacheCandidates + .OrderByDescending(candidate => result[candidate.Id].Count) + .ThenByDescending(static candidate => candidate.AuthoredOrder)]; + return new CandidateTopology(result, parentFirst); + } + + private static void CollectReachableInputs( + RenderFragmentReference parent, + HashSet reachable, + Stack pending) + { + reachable.Clear(); + pending.Clear(); + foreach (RenderFragmentReference input in parent.Inputs) + pending.Push(input); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (!reachable.Add(current)) + continue; + foreach (RenderFragmentReference input in current.Inputs) + pending.Push(input); + } + } +} + +internal readonly record struct RenderFragmentOutputIdentityMemoKey(RenderFragmentReference Reference); + +internal sealed class RenderFragmentOutputIdentity : IEquatable +{ + private readonly RenderFragmentKind _kind; + private readonly Rect _bounds; + private readonly int? _scaleBits; + private readonly int? _materializationScaleBits; + private readonly RenderValueCardinality _cardinality; + private readonly bool _contributes; + private readonly object[] _runtimeComponents; + private readonly RenderFragmentOutputIdentity[] _inputs; + private readonly int _hash; + + private RenderFragmentOutputIdentity( + RenderFragmentReference reference, + EffectiveScale? materializationDemand, + object[] runtimeComponents, + RenderFragmentOutputIdentity[] inputs) + { + _kind = reference.Kind; + _bounds = reference.Bounds; + _scaleBits = reference.EffectiveScale.IsUnbounded + ? null + : BitConverter.SingleToInt32Bits(reference.EffectiveScale.Value); + _materializationScaleBits = materializationDemand is { } demand + ? BitConverter.SingleToInt32Bits(demand.Value) + : null; + _cardinality = reference.ValueCardinality; + _contributes = reference.ContributesValuesToTarget; + _runtimeComponents = runtimeComponents; + _inputs = inputs; + _hash = ComputeHash(); + } + + /// + /// Identities form a DAG - Create memoizes, so a shared input is one instance reached by several parents - + /// and hashing an input by recursion would walk every path through it rather than every edge. Each input's + /// hash is already final by the time this runs, because an identity is built after its inputs, so folding + /// it in once here makes the whole graph linear in its edges instead of exponential in its fan-out. + /// + private int ComputeHash() + { + var hash = new HashCode(); + hash.Add(_kind); + hash.Add(_bounds); + hash.Add(_scaleBits); + hash.Add(_materializationScaleBits); + hash.Add(_cardinality); + hash.Add(_contributes); + foreach (object component in _runtimeComponents) + hash.Add(component); + foreach (RenderFragmentOutputIdentity input in _inputs) + hash.Add(input._hash); + return hash.ToHashCode(); + } + + public static RenderFragmentOutputIdentity Create( + RenderFragmentReference reference, + RenderRequestId graphRequestId, + IReadOnlyDictionary? materializationDemands = null, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity, + RegionAnalysis? regions = null) + { + ArgumentNullException.ThrowIfNull(reference); + var memo = new Dictionary(); + return CreateCore( + reference, + graphRequestId, + materializationDemands, + memo, + outputScale, + maxWorkingScale, + regions); + } + + internal static RenderFragmentOutputIdentity Create( + RenderFragmentReference reference, + RenderRequestId graphRequestId, + IReadOnlyDictionary? materializationDemands, + IDictionary memo, + float outputScale, + float maxWorkingScale, + RegionAnalysis regions) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(memo); + return CreateCore( + reference, + graphRequestId, + materializationDemands, + memo, + outputScale, + maxWorkingScale, + regions); + } + + public bool Equals(RenderFragmentOutputIdentity? other) + => other is not null && EqualsCore(other, null); + + /// + /// The two graphs are separate objects, so nothing is shared between them and a plain recursion compares + /// one pair of nodes once per path that reaches it - exponential in the fan-out. Recording the pairs + /// already being proven equal makes it one comparison per pair instead. Re-reaching a pair can only mean + /// the same subgraph arrived by another route, because these graphs are acyclic and a failure returns + /// immediately rather than leaving a half-proven pair behind. + /// + private bool EqualsCore( + RenderFragmentOutputIdentity other, + HashSet<(RenderFragmentOutputIdentity Left, RenderFragmentOutputIdentity Right)>? proven) + { + // Create memoizes, so the same input reached from two parents inside one graph is one instance. + if (ReferenceEquals(this, other)) + return true; + + if (_hash != other._hash + || _kind != other._kind + || !_bounds.Equals(other._bounds) + || _scaleBits != other._scaleBits + || _materializationScaleBits != other._materializationScaleBits + || !_cardinality.Equals(other._cardinality) + || _contributes != other._contributes + || _runtimeComponents.Length != other._runtimeComponents.Length + || _inputs.Length != other._inputs.Length) + { + return false; + } + + for (int index = 0; index < _runtimeComponents.Length; index++) + { + if (!Equals(_runtimeComponents[index], other._runtimeComponents[index])) + return false; + } + + if (_inputs.Length == 0) + return true; + + proven ??= new HashSet<(RenderFragmentOutputIdentity, RenderFragmentOutputIdentity)>(); + if (!proven.Add((this, other))) + return true; + + for (int index = 0; index < _inputs.Length; index++) + { + if (!_inputs[index].EqualsCore(other._inputs[index], proven)) + return false; + } + + return true; + } + + public override bool Equals(object? obj) + => obj is RenderFragmentOutputIdentity other && Equals(other); + + public override int GetHashCode() => _hash; + + private static RenderFragmentOutputIdentity CreateCore( + RenderFragmentReference reference, + RenderRequestId requestId, + IReadOnlyDictionary? materializationDemands, + IDictionary memo, + float outputScale, + float maxWorkingScale, + RegionAnalysis? regions) + { + var memoKey = new RenderFragmentOutputIdentityMemoKey(reference); + if (memo.TryGetValue(memoKey, out RenderFragmentOutputIdentity? cached)) + return cached; + + RenderFragmentOutputIdentity[] inputs = reference.Inputs + .Select(input => CreateCore( + input, + requestId, + materializationDemands, + memo, + outputScale, + maxWorkingScale, + regions)) + .ToArray(); + var components = new List(); + AddRequestScopedComponents( + reference, + requestId, + components); + EffectiveScale? demand = materializationDemands?.TryGetValue( + reference, + out EffectiveScale selectedDemand) == true + ? selectedDemand + : null; + var identity = new RenderFragmentOutputIdentity( + reference, + demand, + components.ToArray(), + inputs); + memo.Add(memoKey, identity); + return identity; + } + + private static void AddRequestScopedComponents( + RenderFragmentReference reference, + RenderRequestId requestId, + ICollection components) + { + switch (reference.Payload) + { + case BuiltInBackdropCaptureRenderFragmentPayload capture: + components.Add(capture.Description.SourceRegion); + components.Add(capture.Description.Bounds); + components.Add(RequestLocalIdentity(reference, requestId, "backdrop")); + return; + case RawTargetScopeRenderFragmentPayload: + case RawTargetCommandRenderFragmentPayload: + components.Add(RequestLocalIdentity(reference, requestId, "raw-target")); + return; + default: + return; + } + } + + private static object RequestLocalIdentity( + RenderFragmentReference reference, + RenderRequestId requestId, + string role) + => new RequestLocalRenderCacheIdentity( + requestId.Value, + reference.Id?.Value ?? 0, + role); + + private sealed record RequestLocalRenderCacheIdentity( + long RequestId, + long FragmentId, + string Role); + +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderExecutionCallbackGuard.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderExecutionCallbackGuard.cs new file mode 100644 index 0000000000..32c7e6b15f --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderExecutionCallbackGuard.cs @@ -0,0 +1,45 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Prevents a deferred execution callback from launching an unplanned renderer recursively. +/// Planned nested requests execute through and do not use this guard. +/// +internal static class RenderExecutionCallbackGuard +{ + private static readonly AsyncLocal s_depth = new(); + + public static IDisposable Enter() + { + s_depth.Value = checked(s_depth.Value + 1); + return new Scope(); + } + + public static bool IsActive => s_depth.Value > 0; + + public static void ThrowIfRendererLaunchForbidden() + { + if (IsActive) + { + throw new InvalidOperationException( + "A RenderNodeRenderer cannot be launched from a deferred render execution callback. " + + "Record a nested render request during RenderNode.Process instead."); + } + } + + private sealed class Scope : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + int depth = s_depth.Value; + if (depth <= 0) + throw new InvalidOperationException("The render execution callback guard is unbalanced."); + s_depth.Value = depth - 1; + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderIdentityKeyValidator.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderIdentityKeyValidator.cs new file mode 100644 index 0000000000..924283e374 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderIdentityKeyValidator.cs @@ -0,0 +1,282 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +internal static class RenderIdentityKeyValidator +{ + private const string IdentityRejection = + "A value captured by a metadata callback must be a lightweight, immutable CPU value and cannot retain " + + "a resource, context, request graph, mutable payload, or captured delegate."; + + private static readonly Type s_runtimeType = typeof(Type).GetType(); + + public static void ThrowIfInvalid(object key, string parameterName) + { + ArgumentNullException.ThrowIfNull(key, parameterName); + + bool retainsLifetimeOrCapability = key is IDisposable + or RenderResource + or RenderNodeContext + or RenderRequest + or RenderRequestOptions + or RecordedRenderGraph + or RecordedRenderGraphBuilder + or RenderResourceSlot + or RenderResourceRegistration + or RenderFragmentHandle + or RenderExecutionInput + or RenderCallbackCanvas + or OpaqueRenderSession + or OpaqueRenderOutput + or GeometrySession + or ShaderExecutionContext + or ShaderUniformWriter + or ShaderResourceWriter + or TargetScopeSession + or TargetCommandSession + or RawTargetScopeSession + or RawTargetCommandSession; + bool mutablePayload = key is Array || IsKnownMutableCollection(key.GetType()); + bool capturedDelegate = key is Delegate callback && CapturesState(callback); + bool customType = key is Type type && !IsRuntimeType(type); + if (retainsLifetimeOrCapability || mutablePayload || capturedDelegate || customType) + { + throw new ArgumentException(IdentityRejection, parameterName); + } + } + + private static bool IsRuntimeType(Type type) => type.GetType() == s_runtimeType; + + /// + /// Rejects a captured value the callback's author could still change after recording. + /// + /// + /// + /// A metadata callback is evaluated repeatedly and its structural identity is only its + /// , so a capture that can change between evaluations makes the same identity + /// stand for different bounds, scales or hit tests. The named collection types are not the only way to + /// hold one: an ordinary class with a settable field does it too, which is what this reaches. + /// + /// + /// The test is structural rather than a list: a reference type passes only when every instance field is + /// and holds something that passes in turn, so a shell whose fields cannot be + /// reassigned and whose contents are themselves fixed is accepted however unfamiliar it is. A struct is + /// not asked to be readonly - the callback reads whatever the display class holds either way, which is + /// no more exposure than a captured already carries - but what it points at is still + /// followed. + /// + /// + public static void ThrowIfMutableCapture(object captured, string parameterName) + { + ArgumentNullException.ThrowIfNull(captured, parameterName); + CapturePath path = default; + Validate(captured, parameterName, path, depth: 0); + } + + private const int MaxCaptureDepth = 8; + + // The references on the way down to the value being read. This runs once per recorded callback per + // frame, so the walk carries its own cycle guard on the stack rather than allocating a set per call. + [InlineArray(MaxCaptureDepth)] + private struct CapturePath + { + private object? _element; + } + + private static void Validate(object value, string parameterName, Span path, int depth) + { + ThrowIfInvalid(value, parameterName); + + Type type = value.GetType(); + if (IsFixedLeaf(type)) + return; + + if (depth >= MaxCaptureDepth) + { + // Deeper than any lightweight identity value is, and following it further would cost more than + // reading the capture does. + throw new ArgumentException(IdentityRejection, parameterName); + } + + if (!type.IsValueType) + { + // A reference already on this path is a cycle, and one left over from a sibling branch was + // accepted there, so either way it has nothing left to say. + for (int index = 0; index < depth; index++) + { + if (ReferenceEquals(path[index], value)) + return; + } + + path[depth] = value; + } + + foreach (FieldInfo field in GetInstanceFields(type)) + { + if (!type.IsValueType && !field.IsInitOnly) + { + throw new ArgumentException( + $"'{type.Name}.{field.Name}' can be assigned after this callback is recorded, so the " + + "callback's result can change while its structural identity does not. " + + IdentityRejection, + parameterName); + } + + // A field whose declared type is fixed and has no subtype cannot hold anything that is not, so + // reading it would only box a number this walk has already accepted by its type. + if (IsSettledCaptureType(field.FieldType)) + continue; + + if (field.GetValue(value) is { } nested) + Validate(nested, parameterName, path, depth + 1); + } + } + + /// + /// Gets whether a field declared as is accepted by its declaration alone, so a + /// capture walk need not read its value. + /// + /// + /// A value type is its own runtime type and a sealed one has no subtype, so when either is fixed nothing + /// can be stored there that this walk would reject. + /// + internal static bool IsSettledCaptureType(Type type) + => (type.IsValueType || type.IsSealed) && IsFixedLeaf(type); + + private static readonly ConcurrentDictionary s_instanceFields = new(); + + internal static FieldInfo[] GetInstanceFields(Type type) + => s_instanceFields.GetOrAdd( + type, + static key => key.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + + private static readonly ConcurrentDictionary s_fixedLeaf = new(); + + // Types whose contents cannot change, so following their fields would only reach private + // implementation detail - an ImmutableArray's backing array reads as a mutable array from the outside. + // ReadOnlyMemory is deliberately absent: it is a read-only view, and the array it ordinarily wraps stays + // in the author's hands, so its contents are followed like any other capture. Nullable is absent for the + // opposite reason - the struct rule below already decides it from the type it wraps. + private static bool IsFixedLeaf(Type type) => s_fixedLeaf.GetOrAdd(type, static key => ComputeIsFixedLeaf(key)); + + private static bool ComputeIsFixedLeaf(Type type) + { + if (type.IsPrimitive || type.IsEnum || type.IsPointer) + return true; + if (type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) + || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid) + || type == typeof(Uri) || type == typeof(Version) || type == typeof(DBNull)) + { + return true; + } + + if (type.IsGenericType) + { + Type definition = type.GetGenericTypeDefinition(); + if (definition == typeof(System.Collections.Immutable.ImmutableArray<>) + || definition == typeof(System.Collections.Immutable.ImmutableList<>) + || definition == typeof(System.Collections.Immutable.ImmutableHashSet<>) + || definition == typeof(System.Collections.Immutable.ImmutableSortedSet<>) + || definition == typeof(System.Collections.Immutable.ImmutableDictionary<,>) + || definition == typeof(System.Collections.Immutable.ImmutableSortedDictionary<,>) + || definition == typeof(System.Collections.Immutable.ImmutableQueue<>) + || definition == typeof(System.Collections.Immutable.ImmutableStack<>)) + { + // What cannot change is the collection, not what it holds. A boxes[0].Value the author can + // still assign reads through an immutable array exactly as it reads through a field, so the + // exemption only stands when the elements are settled by their own declaration too. + return type.GetGenericArguments().All(IsSettledCaptureType); + } + } + + if (typeof(Type).IsAssignableFrom(type) || typeof(MemberInfo).IsAssignableFrom(type)) + return true; + + // A struct reachable only by copy is fixed when every field it carries is fixed in turn: nothing the + // callback's author still holds can reach into it. C# forbids a struct that contains itself, so this + // descent terminates, and it stops at the first reference type, which is never fixed by its + // declaration alone. + if (type.IsValueType && !typeof(IDisposable).IsAssignableFrom(type)) + { + foreach (FieldInfo field in GetInstanceFields(type)) + { + if (!IsFixedLeaf(field.FieldType)) + return false; + } + + return true; + } + + return false; + } + + public static bool CapturesState(Delegate callback) + { + ArgumentNullException.ThrowIfNull(callback); + return callback.GetInvocationList().Any(IsCapturedDelegate); + } + + private static bool IsCapturedDelegate(Delegate callback) + { + if (callback.Target is null) + return false; + + // Roslyn caches non-capturing lambdas on a sealed compiler-generated singleton and emits + // an instance method for them. Accept only that stateless shape; display classes, derived + // targets, and ordinary instance delegates remain rejected. + Type targetType = callback.Target.GetType(); + return !targetType.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) + || !targetType.IsSealed + || targetType.BaseType != typeof(object) + || targetType.GetFields( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly).Length != 0; + } + + private static bool IsKnownMutableCollection(Type type) + { + for (Type? current = type; current is not null; current = current.BaseType) + { + if (current == typeof(ArrayList) + || current == typeof(Hashtable) + || current == typeof(System.Collections.Queue) + || current == typeof(System.Collections.Stack)) + { + return true; + } + + if (!current.IsGenericType) + continue; + + Type definition = current.GetGenericTypeDefinition(); + if (definition == typeof(List<>) + || definition == typeof(Dictionary<,>) + || definition == typeof(HashSet<>) + || definition == typeof(SortedSet<>) + || definition == typeof(Queue<>) + || definition == typeof(Stack<>) + || definition == typeof(LinkedList<>) + || definition == typeof(SortedDictionary<,>) + || definition == typeof(SortedList<,>) + || definition == typeof(System.Collections.ObjectModel.Collection<>) + || definition == typeof(System.Collections.ObjectModel.ObservableCollection<>) + || definition == typeof(System.Collections.ObjectModel.ReadOnlyCollection<>) + || definition == typeof(System.Collections.ObjectModel.ReadOnlyDictionary<,>) + || definition == typeof(System.Collections.Concurrent.ConcurrentBag<>) + || definition == typeof(System.Collections.Concurrent.ConcurrentQueue<>) + || definition == typeof(System.Collections.Concurrent.ConcurrentStack<>) + || definition == typeof(System.Collections.Concurrent.ConcurrentDictionary<,>)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequest.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequest.cs new file mode 100644 index 0000000000..084f73e217 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequest.cs @@ -0,0 +1,193 @@ +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderRequest : IDisposable +{ + private static long s_nextRequestId; + private readonly List _children = []; + + public RenderRequest(RenderRequestOptions options, RenderRequest? parent = null) + { + ArgumentNullException.ThrowIfNull(options); + if (parent is not null && options.TargetBinding is null) + { + throw new ArgumentException( + "A nested request requires a typed separate-target binding.", + nameof(options)); + } + + if (parent is not null && !HasInheritedRequestPolicy(options, parent.Options)) + { + throw new ArgumentException( + "A nested request must be created from its parent options and inherit intent, purpose, cache, " + + "fusion, owner, and diagnostic policy.", + nameof(options)); + } + + long value = Interlocked.Increment(ref s_nextRequestId); + if (value <= 0) + { + throw new InvalidOperationException("The render request ID space was exhausted."); + } + + Id = new RenderRequestId(value); + ParentId = parent?.Id; + Options = options; + State = RenderRequestState.Created; + parent?.RegisterChild(this); + } + + public RenderRequestId Id { get; } + + public RenderRequestId? ParentId { get; } + + public RenderRequestOptions Options { get; } + + public RenderRequestState State { get; private set; } + + public void TransitionTo(RenderRequestState next) + { + if (!Enum.IsDefined(next)) + { + throw new ArgumentOutOfRangeException(nameof(next), next, "The request state is not defined."); + } + + RenderRequestState expected = State switch + { + RenderRequestState.Created => RenderRequestState.Recording, + RenderRequestState.Recording => RenderRequestState.Recorded, + RenderRequestState.Recorded => RenderRequestState.TargetDependenciesLowered, + RenderRequestState.TargetDependenciesLowered => RenderRequestState.MetadataResolved, + RenderRequestState.MetadataResolved => RenderRequestState.RegionsResolved, + RenderRequestState.RegionsResolved => RenderRequestState.CachesResolved, + RenderRequestState.CachesResolved => RenderRequestState.Planned, + RenderRequestState.Planned => RenderRequestState.Executing, + RenderRequestState.Executing => RenderRequestState.Completed, + _ => throw new InvalidOperationException($"Request state '{State}' cannot transition to another active state."), + }; + + if (next != expected) + { + throw new InvalidOperationException( + $"Request state '{State}' must transition to '{expected}', not '{next}'."); + } + + State = next; + } + + public void CompleteMetadataOnly() + { + if (Options.Purpose is not (RenderRequestPurpose.Bounds or RenderRequestPurpose.HitTest)) + { + throw new InvalidOperationException("Only Bounds and HitTest requests can complete without planning execution."); + } + + if (State == RenderRequestState.Completed) + return; + + if (State != RenderRequestState.MetadataResolved) + { + throw new InvalidOperationException("A metadata-only request completes after metadata resolution."); + } + + State = RenderRequestState.Completed; + } + + public void Fail(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + if (State is RenderRequestState.Completed or RenderRequestState.Failed or RenderRequestState.Disposed) + { + throw new InvalidOperationException($"Request state '{State}' cannot fail."); + } + + Options.Owner.RecordPrimaryFailure(exception); + State = RenderRequestState.Failed; + // Nested recorders share the family owner with their parent. Their failure must + // unwind to the family root before owner cleanup starts, otherwise parent + // transactions can observe their still-pending resources as prematurely disposed. + if (ParentId is null) + Options.Owner.Cleanup(); + } + + internal void FailFamilyMember() + { + if (State is RenderRequestState.Completed or RenderRequestState.Disposed) + { + throw new InvalidOperationException($"Request state '{State}' cannot fail with its request family."); + } + + State = RenderRequestState.Failed; + } + + public void Dispose() + { + if (State == RenderRequestState.Disposed) + { + return; + } + + for (int index = _children.Count - 1; index >= 0; index--) + _children[index].Dispose(); + + if (Options.OwnsOwner) + { + Options.Owner.Cleanup(); + } + + State = RenderRequestState.Disposed; + } + + private void RegisterChild(RenderRequest child) + { + if (State is RenderRequestState.Completed or RenderRequestState.Failed or RenderRequestState.Disposed) + { + throw new InvalidOperationException( + $"Request state '{State}' cannot accept another nested request."); + } + + _children.Add(child); + } + + private static bool HasInheritedRequestPolicy( + RenderRequestOptions nested, + RenderRequestOptions parent) + { + return ReferenceEquals(nested.NestedPolicyParent, parent) + && ReferenceEquals(nested.Owner, parent.Owner) + && nested.Intent == parent.Intent + && nested.Purpose == parent.Purpose + && nested.CachePolicy == parent.CachePolicy + && nested.FusionMode == parent.FusionMode; + } +} + +internal readonly record struct RenderRequestId +{ + public RenderRequestId(long value) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "A render request ID must be positive."); + } + + Value = value; + } + + public long Value { get; } +} + +internal enum RenderRequestState : byte +{ + Created, + Recording, + Recorded, + TargetDependenciesLowered, + MetadataResolved, + RegionsResolved, + CachesResolved, + Planned, + Executing, + Completed, + Failed, + Disposed, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs new file mode 100644 index 0000000000..5364a23352 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestCompiler.cs @@ -0,0 +1,786 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderRequestCompiler +{ + private readonly StructuralPlanCache? _structuralPlanCache; + private readonly RenderCacheResolutionContext? _renderCacheContext; + private readonly IRenderCacheLookup? _renderCacheLookup; + + public RenderRequestCompiler( + StructuralPlanCache? structuralPlanCache = null, + RenderCacheResolutionContext? renderCacheContext = null, + IRenderCacheLookup? renderCacheLookup = null) + { + _structuralPlanCache = structuralPlanCache; + _renderCacheContext = renderCacheContext; + _renderCacheLookup = renderCacheLookup; + } + + public RenderNodeMeasurement ResolveMetadata( + RenderRequest request, + RecordedRenderGraph graph) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(graph); + try + { + var measurements = new Dictionary( + ReferenceEqualityComparer.Instance); + ResolveMetadataFamily(request, graph, measurements); + if (request.Options.Purpose is RenderRequestPurpose.Bounds or RenderRequestPurpose.HitTest) + CompleteMetadataFamily(request, graph); + return measurements[request]; + } + catch (Exception ex) + { + FailFamily(request, graph, ex); + throw; + } + } + + public CompiledRenderRequest Compile( + RenderRequest request, + RecordedRenderGraph graph) + => Compile(request, graph, SkslBackendBudgetResolver.Portable); + + internal CompiledRenderRequest Compile( + RenderRequest request, + RecordedRenderGraph graph, + SkslBackendBudget shaderBudget) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(shaderBudget); + try + { + var measurements = new Dictionary( + ReferenceEqualityComparer.Instance); + ResolveMetadataFamily(request, graph, measurements); + int nextStructuralPlanSlot = 0; + CompiledRenderRequest compiled = CompileFamily( + request, + graph, + measurements, + shaderBudget, + ref nextStructuralPlanSlot); + _structuralPlanCache?.RetainFamilySlots(nextStructuralPlanSlot); + return compiled; + } + catch (Exception ex) + { + FailFamily(request, graph, ex); + throw; + } + } + + public CompiledRenderRequest CompileAfterMetadata( + RenderRequest request, + RecordedRenderGraph graph, + RenderNodeMeasurement measurement) + => CompileAfterMetadata( + request, + graph, + measurement, + SkslBackendBudgetResolver.Portable); + + internal CompiledRenderRequest CompileAfterMetadata( + RenderRequest request, + RecordedRenderGraph graph, + RenderNodeMeasurement measurement, + SkslBackendBudget shaderBudget) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(shaderBudget); + if (request.State != RenderRequestState.MetadataResolved) + { + throw new InvalidOperationException( + "A render request can be compiled only after metadata resolution."); + } + + try + { + var measurements = new Dictionary( + ReferenceEqualityComparer.Instance) + { + [request] = measurement, + }; + CollectNestedMetadata(graph, measurements); + int nextStructuralPlanSlot = 0; + CompiledRenderRequest compiled = CompileFamily( + request, + graph, + measurements, + shaderBudget, + ref nextStructuralPlanSlot); + _structuralPlanCache?.RetainFamilySlots(nextStructuralPlanSlot); + return compiled; + } + catch (Exception ex) + { + FailFamily(request, graph, ex); + throw; + } + } + + private void ResolveMetadataFamily( + RenderRequest request, + RecordedRenderGraph graph, + IDictionary measurements) + { + foreach (RecordedNestedRenderRequest nested in graph.NestedRequests) + ResolveMetadataFamily(nested.Request, nested.Graph, measurements); + + if (request.State != RenderRequestState.Recorded) + { + throw new InvalidOperationException( + "Render-request metadata can be resolved only after recording completes."); + } + + request.TransitionTo(RenderRequestState.TargetDependenciesLowered); + ImmutableArray roots = ResolveRoots(graph); + TargetDependencyPlan targetDependencies = TargetDependencyLowerer.Lower( + roots, + request.Options.TargetDomain); + RenderNodeMeasurement measurement = new RegionAnalyzer() + .Analyze(request.Options, roots, targetDependencies) + .Measurement; + request.TransitionTo(RenderRequestState.MetadataResolved); + measurements.Add(request, measurement); + } + + private void CollectNestedMetadata( + RecordedRenderGraph graph, + IDictionary measurements) + { + foreach (RecordedNestedRenderRequest nested in graph.NestedRequests) + { + if (nested.Request.State == RenderRequestState.Recorded) + { + ResolveMetadataFamily(nested.Request, nested.Graph, measurements); + } + else if (nested.Request.State == RenderRequestState.MetadataResolved) + { + CollectNestedMetadata(nested.Graph, measurements); + ImmutableArray roots = ResolveRoots(nested.Graph); + TargetDependencyPlan targetDependencies = TargetDependencyLowerer.Lower( + roots, + nested.Request.Options.TargetDomain); + measurements[nested.Request] = new RegionAnalyzer() + .Analyze(nested.Request.Options, roots, targetDependencies) + .Measurement; + } + else + { + throw new InvalidOperationException( + "A nested render request must be recorded or metadata-resolved before family compilation."); + } + } + } + + private CompiledRenderRequest CompileFamily( + RenderRequest request, + RecordedRenderGraph graph, + IReadOnlyDictionary measurements, + SkslBackendBudget shaderBudget, + ref int nextStructuralPlanSlot) + { + var nested = ImmutableArray.CreateBuilder(graph.NestedRequests.Length); + foreach (RecordedNestedRenderRequest recordedNested in graph.NestedRequests) + { + nested.Add(CompileFamily( + recordedNested.Request, + recordedNested.Graph, + measurements, + shaderBudget, + ref nextStructuralPlanSlot)); + } + + int structuralPlanSlot = nextStructuralPlanSlot++; + return CompileSingle( + request, + graph, + measurements[request], + shaderBudget, + nested.MoveToImmutable(), + structuralPlanSlot); + } + + private CompiledRenderRequest CompileSingle( + RenderRequest request, + RecordedRenderGraph graph, + RenderNodeMeasurement measurement, + SkslBackendBudget shaderBudget, + ImmutableArray nestedRequests, + int structuralPlanSlot) + { + if (request.State != RenderRequestState.MetadataResolved) + { + throw new InvalidOperationException( + "A render request can be compiled only after metadata resolution."); + } + + ImmutableArray roots = ResolveRoots(graph); + // Metadata resolution mutates symbolic fragment bounds used by target-scope lowering. + // Re-lower here so the final plan uses those resolved owning domains; the preliminary + // plan used to resolve metadata is not safe to reuse. + TargetDependencyPlan targetDependencies = TargetDependencyLowerer.Lower( + roots, + request.Options.TargetDomain); + RegionAnalysis regions = new RegionAnalyzer().Analyze( + request.Options, + roots, + targetDependencies); + if (regions.Measurement != measurement) + { + throw new InvalidOperationException( + "The supplied metadata does not match graph-wide region analysis."); + } + + request.TransitionTo(RenderRequestState.RegionsResolved); + RenderCacheResolutionContext cacheContext = _renderCacheContext + ?? new RenderCacheResolutionContext( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + new RenderCacheDeviceContextIdentity(request, request), + allowPersistentLookup: false, + allowCapturePublication: false); + RenderCachePlanningResult cachePlanning = new RenderCacheResolver().Resolve( + request, + graph, + regions, + roots, + cacheContext, + _renderCacheLookup); + IReadOnlyDictionary materializationDemands = + cachePlanning.MaterializationDemands; + IReadOnlySet previewDropEligibleMaterializations = + cachePlanning.PreviewDropEligibleMaterializations; + RenderCacheResolution cacheResolution = cachePlanning.Resolution; + request.TransitionTo(RenderRequestState.CachesResolved); + ExecutionIslandPlan executionPlan; + if (_structuralPlanCache is not null) + { + StructuralPlanIdentity structuralIdentity = StructuralPlanIdentity.Create( + request.Options.PlanIdentity, + graph, + shaderBudget, + cacheResolution); + executionPlan = _structuralPlanCache.GetOrCompile( + structuralIdentity, + graph, + () => new ExecutionIslandPlanner().Plan( + graph, + roots, + cacheResolution, + request.Options.FusionMode, + shaderBudget), + familySlot: structuralPlanSlot); + } + else + { + executionPlan = new ExecutionIslandPlanner().Plan( + graph, + roots, + cacheResolution, + request.Options.FusionMode, + shaderBudget); + } + + request.TransitionTo(RenderRequestState.Planned); + return new CompiledRenderRequest( + request, + graph, + regions, + roots, + materializationDemands, + cachePlanning.MaterializedFragments, + previewDropEligibleMaterializations, + targetDependencies, + cacheResolution, + executionPlan, + nestedRequests); + } + + internal static ImmutableArray ResolveRoots( + RecordedRenderGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + if (graph.PublicationRoots.IsDefaultOrEmpty) + return []; + + var byId = new Dictionary(); + foreach (RecordedRenderFragment fragment in graph.Fragments) + { + if (fragment.Payload is not RenderFragmentReference reference) + { + throw new InvalidOperationException( + "A recorded render fragment is missing its executable semantic reference."); + } + + byId.Add(fragment.Id, reference); + } + + var roots = ImmutableArray.CreateBuilder(graph.PublicationRoots.Length); + foreach (RenderFragmentId id in graph.PublicationRoots) + { + if (!byId.TryGetValue(id, out RenderFragmentReference? reference)) + throw new InvalidOperationException("A publication root does not identify a recorded fragment."); + roots.Add(reference); + } + + return roots.MoveToImmutable(); + } + + private static void CompleteMetadataFamily(RenderRequest root, RecordedRenderGraph graph) + { + foreach ((RenderRequest request, _) in EnumerateFamilyDepthFirst(root, graph)) + request.CompleteMetadataOnly(); + } + + private static void FailFamily( + RenderRequest root, + RecordedRenderGraph graph, + Exception exception) + { + RenderRequestOwner owner = root.Options.Owner; + if (owner.PrimaryFailure is null) + owner.RecordPrimaryFailure(exception); + owner.Cleanup(); + + foreach ((RenderRequest request, _) in EnumerateFamilyDepthFirst(root, graph)) + request.FailFamilyMember(); + } + + private static IEnumerable<(RenderRequest Request, RecordedRenderGraph Graph)> EnumerateFamilyDepthFirst( + RenderRequest root, + RecordedRenderGraph graph) + { + foreach (RecordedNestedRenderRequest nested in graph.NestedRequests) + { + foreach ((RenderRequest request, RecordedRenderGraph nestedGraph) in + EnumerateFamilyDepthFirst(nested.Request, nested.Graph)) + { + yield return (request, nestedGraph); + } + } + + yield return (root, graph); + } +} + +internal static class TargetDependencyLowerer +{ + public static TargetDependencyPlan Lower( + ImmutableArray roots, + Rect? rootDomain = null) + { + var builder = new Builder(); + TargetScopeId rootScope = builder.CreateScope( + parentId: null, + owner: null, + resolvedDomain: rootDomain); + foreach (RenderFragmentReference root in roots) + builder.LowerRoot(root, rootScope); + return builder.Build(); + } + + private sealed class Builder + { + private readonly List _steps = []; + private readonly List _scopes = []; + private readonly Dictionary _currentTokens = []; + private readonly HashSet _scheduledEffects = + new(ReferenceEqualityComparer.Instance); + private int _nextScopeId; + private int _nextTokenId; + + public TargetScopeId CreateScope( + TargetScopeId? parentId, + RenderFragmentReference? owner, + Rect? resolvedDomain, + bool inheritParentToken = false, + bool isOrderOnly = false) + { + var scopeId = new TargetScopeId(++_nextScopeId); + TargetTokenId token = inheritParentToken && parentId is { } parent + ? _currentTokens[parent] + : new TargetTokenId(++_nextTokenId); + _currentTokens.Add(scopeId, token); + _scopes.Add(new TargetScopePlan( + scopeId, + parentId, + owner?.Id, + token, + resolvedDomain, + isOrderOnly)); + return scopeId; + } + + public void LowerRoot( + RenderFragmentReference reference, + TargetScopeId scopeId, + bool compositeOutput = true) + { + switch (reference.Kind) + { + case RenderFragmentKind.Layer: + LowerFiniteLayer(reference, scopeId, compositeOutput); + return; + case RenderFragmentKind.TargetLayerScope: + LowerTargetLayerScope(reference, scopeId); + return; + case RenderFragmentKind.TargetCapture: + case RenderFragmentKind.BuiltInBackdropCapture: + LowerCapture(reference, scopeId); + if (compositeOutput && reference.ContributesValuesToTarget) + AddStep(reference, scopeId, TargetDependencyKind.Composite, FirstInputValue(reference), null); + return; + case RenderFragmentKind.TargetCommand: + case RenderFragmentKind.RawTargetCommand: + ValidateCommandDomain(reference, scopeId); + LowerCommand(reference, scopeId); + return; + case RenderFragmentKind.TargetScope: + LowerScopeWrapper(reference, scopeId, compositeOutput); + return; + case RenderFragmentKind.RawTargetScope: + ValidateFullDomain(reference, scopeId); + LowerScopeWrapper(reference, scopeId, compositeOutput); + return; + case RenderFragmentKind.ContributeValues: + LowerDependencies(reference, scopeId); + if (compositeOutput) + { + AddStep( + reference, + scopeId, + TargetDependencyKind.Composite, + FirstInputValue(reference), + null); + } + return; + case RenderFragmentKind.Blend + when RequiresFullTargetRegion(reference): + LowerDestructiveBlend(reference, scopeId); + return; + case RenderFragmentKind.Blend: + case RenderFragmentKind.Opacity: + LowerScopeWrapper(reference, scopeId, compositeOutput); + return; + case RenderFragmentKind.OpacityMask: + LowerOpacityMask(reference, scopeId, compositeOutput); + return; + default: + LowerDependencies(reference, scopeId); + if (compositeOutput && reference.ContributesValuesToTarget) + AddStep(reference, scopeId, TargetDependencyKind.Composite, FirstValue(reference), null); + return; + } + } + + public TargetDependencyPlan Build() => new([.. _steps], [.. _scopes]); + + private static bool RequiresFullTargetRegion(RenderFragmentReference reference) + { + return BlendModeRenderNode.RequiresFullTargetRegion( + ((BlendRenderFragmentPayload)reference.Payload!).BlendMode); + } + + private void LowerDestructiveBlend( + RenderFragmentReference reference, + TargetScopeId scopeId) + { + if (!_scheduledEffects.Add(reference)) + return; + + ValidateFullDomain(reference, scopeId); + LowerDependencies(reference, scopeId); + AddStep( + reference, + scopeId, + TargetDependencyKind.Command, + FirstInputValue(reference), + null); + } + + private void LowerFiniteLayer( + RenderFragmentReference reference, + TargetScopeId parentScope, + bool compositeOutput) + { + if (!_scheduledEffects.Add(reference)) + return; + + Rect domain = ((LayerRenderFragmentPayload)reference.Payload!).Domain + ?? reference.Bounds; + TargetScopeId childScope = CreateScope( + parentScope, + reference, + domain); + foreach (RenderFragmentReference input in reference.Inputs) + LowerRoot(input, childScope); + + if (compositeOutput && reference.ContributesValuesToTarget) + { + AddStep( + reference, + parentScope, + TargetDependencyKind.ScopeComposite, + FirstValue(reference), + null); + } + } + + private void LowerTargetLayerScope( + RenderFragmentReference reference, + TargetScopeId parentScope) + { + if (!_scheduledEffects.Add(reference)) + return; + + TargetRegion region = ((TargetLayerScopeRenderFragmentPayload)reference.Payload!).Region; + Rect domain = ResolveRegion(region, GetDomain(parentScope), reference); + bool isOrderOnly = region.Kind == TargetRegionKind.Empty; + TargetScopeId childScope = CreateScope( + parentScope, + reference, + domain, + isOrderOnly: isOrderOnly); + if (isOrderOnly) + return; + + foreach (RenderFragmentReference input in reference.Inputs) + LowerRoot(input, childScope); + + AddStep( + reference, + parentScope, + TargetDependencyKind.ScopeComposite, + FirstValue(reference), + null); + } + + private void LowerScopeWrapper( + RenderFragmentReference reference, + TargetScopeId scopeId, + bool compositeOutput) + { + if (!_scheduledEffects.Add(reference)) + return; + + Rect? authoredDomain = MapDomainIntoScope(reference, GetDomain(scopeId)); + TargetScopeId authoredScope = CreateScope( + scopeId, + reference, + authoredDomain, + inheritParentToken: true); + bool childHasEffects = false; + foreach (RenderFragmentReference input in reference.Inputs) + { + if (input.HasTargetEffects) + { + childHasEffects = true; + LowerRoot(input, authoredScope, compositeOutput); + } + } + + if (compositeOutput && reference.ContributesValuesToTarget && !childHasEffects) + { + AddStep(reference, authoredScope, TargetDependencyKind.Composite, FirstValue(reference), null); + } + + _currentTokens[scopeId] = _currentTokens[authoredScope]; + } + + private void LowerOpacityMask( + RenderFragmentReference reference, + TargetScopeId scopeId, + bool compositeOutput) + { + if (!_scheduledEffects.Add(reference)) + return; + + for (int i = 1; i < reference.Inputs.Length; i++) + { + RenderFragmentReference dependency = reference.Inputs[i]; + if (dependency.HasTargetEffects) + LowerRoot(dependency, scopeId, compositeOutput: false); + } + + Rect? authoredDomain = MapDomainIntoScope(reference, GetDomain(scopeId)); + TargetScopeId authoredScope = CreateScope( + scopeId, + reference, + authoredDomain, + inheritParentToken: true); + bool childHasEffects = false; + if (!reference.Inputs.IsDefaultOrEmpty) + { + RenderFragmentReference primary = reference.Inputs[0]; + if (primary.HasTargetEffects) + { + childHasEffects = true; + LowerRoot(primary, authoredScope, compositeOutput); + } + } + + if (compositeOutput && reference.ContributesValuesToTarget && !childHasEffects) + { + AddStep(reference, authoredScope, TargetDependencyKind.Composite, FirstValue(reference), null); + } + + _currentTokens[scopeId] = _currentTokens[authoredScope]; + } + + private void ValidateCaptureDomain( + RenderFragmentReference reference, + TargetScopeId scopeId) + { + Rect? targetDomain = GetDomain(scopeId); + TargetRegion region = reference.Payload switch + { + TargetCaptureRenderFragmentPayload capture => capture.Description.SourceRegion, + BuiltInBackdropCaptureRenderFragmentPayload capture => capture.Description.SourceRegion, + _ => throw new InvalidOperationException("The target-capture payload is invalid."), + }; + Rect resolvedSourceRegion = ResolveRegion(region, targetDomain, reference); + if (reference.Payload is TargetCaptureRenderFragmentPayload publicCapture) + { + publicCapture.Description.ValidateResolvedBounds( + resolvedSourceRegion, + targetDomain ?? resolvedSourceRegion); + } + } + + private void ValidateCommandDomain( + RenderFragmentReference reference, + TargetScopeId scopeId) + { + TargetRegion region = reference.Payload switch + { + TargetCommandRenderFragmentPayload command => command.Description.AffectedRegion, + RawTargetCommandRenderFragmentPayload => TargetRegion.Full, + _ => throw new InvalidOperationException("The target-command payload is invalid."), + }; + _ = ResolveRegion(region, GetDomain(scopeId), reference); + } + + private void ValidateFullDomain( + RenderFragmentReference reference, + TargetScopeId scopeId) + => _ = ResolveRegion(TargetRegion.Full, GetDomain(scopeId), reference); + + private void LowerCommand(RenderFragmentReference reference, TargetScopeId scopeId) + { + if (!_scheduledEffects.Add(reference)) + return; + LowerDependencies(reference, scopeId); + AddStep(reference, scopeId, TargetDependencyKind.Command, FirstInputValue(reference), null); + } + + private void LowerCapture(RenderFragmentReference reference, TargetScopeId scopeId) + { + if (!_scheduledEffects.Add(reference)) + return; + ValidateCaptureDomain(reference, scopeId); + RenderValueId? capturedValue = FirstValue(reference); + AddStep(reference, scopeId, TargetDependencyKind.Capture, capturedValue, capturedValue); + } + + private void LowerDependencies( + RenderFragmentReference reference, + TargetScopeId scopeId) + { + foreach (RenderFragmentReference input in reference.Inputs) + { + if (!input.HasTargetEffects) + continue; + + LowerRoot(input, scopeId, compositeOutput: false); + } + } + + private void AddStep( + RenderFragmentReference reference, + TargetScopeId scopeId, + TargetDependencyKind kind, + RenderValueId? targetReadValueId, + RenderValueId? producedValueId) + { + RenderFragmentId fragmentId = reference.Id + ?? throw new InvalidOperationException("A target dependency refers to an uncommitted fragment."); + TargetTokenId input = _currentTokens[scopeId]; + var output = new TargetTokenId(++_nextTokenId); + _currentTokens[scopeId] = output; + _steps.Add(new TargetDependencyStep( + fragmentId, + scopeId, + input, + output, + targetReadValueId, + producedValueId, + kind)); + } + + private static RenderValueId? FirstValue(RenderFragmentReference reference) + => reference.ValueIds.IsDefaultOrEmpty ? null : reference.ValueIds[0]; + + private static RenderValueId? FirstInputValue(RenderFragmentReference reference) + { + foreach (RenderFragmentReference input in reference.Inputs) + { + if (!input.ValueIds.IsDefaultOrEmpty) + return input.ValueIds[0]; + } + + return null; + } + + private Rect? GetDomain(TargetScopeId scopeId) + { + int index = scopeId.Value - 1; + if ((uint)index >= (uint)_scopes.Count || _scopes[index].Id != scopeId) + { + throw new InvalidOperationException("The target scope ID does not identify a created scope."); + } + + return _scopes[index].ResolvedDomain; + } + + private static Rect? MapDomainIntoScope( + RenderFragmentReference reference, + Rect? parentDomain) + { + if (parentDomain is not { } domain) + return null; + + return reference.Payload switch + { + TargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.GetRequiredInputBounds(domain), + RawTargetScopeRenderFragmentPayload scope + => scope.Description.Bounds.GetRequiredInputBounds(domain), + _ => domain, + }; + } + + private static Rect ResolveRegion( + TargetRegion region, + Rect? ownerDomain, + RenderFragmentReference owner) + { + return region.Kind switch + { + TargetRegionKind.Empty => Rect.Empty, + TargetRegionKind.Region => region.Value, + TargetRegionKind.Full when ownerDomain is { } domain => domain, + TargetRegionKind.Full => throw new RenderTargetDomainRequiredException( + $"A reachable Full target access on {owner.Kind} requires a finite owning target domain."), + _ => throw new InvalidOperationException("The target region is uninitialized."), + }; + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Diagnostics.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Diagnostics.cs new file mode 100644 index 0000000000..ea958bf529 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Diagnostics.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + public void PrepareBuiltInBackdropCaptures() + { + var prepared = new List(_backdropCaptures.Count); + try + { + foreach ((IBuiltInBackdropCaptureSink sink, MaterializedRenderValue value) in _backdropCaptures) + { + Bitmap bitmap = value.Target.Snapshot(); + var publication = new PendingBackdropPublication( + sink, + bitmap, + value.EffectiveScale.Value); + prepared.Add(publication); + _pendingBackdropPublications.Add(publication); + } + } + catch + { + foreach (PendingBackdropPublication publication in prepared) + { + publication.Bitmap?.Dispose(); + publication.Bitmap = null; + _pendingBackdropPublications.Remove(publication); + } + throw; + } + finally + { + foreach (MaterializedRenderValue value in _backdropCaptures.Select(static item => item.Value)) + ReleaseValueReference(value); + _backdropCaptures.Clear(); + } + } + + public void PublishBuiltInBackdropCaptures() + { + // The sink outlives this frame, so a frame that dropped part of itself has nothing fit to commit. + if (PreviewAllocationDropObserved) + { + RejectBuiltInBackdropCaptures(); + return; + } + + foreach (PendingBackdropPublication publication in _pendingBackdropPublications) + { + Bitmap bitmap = publication.Bitmap + ?? throw new InvalidOperationException("A backdrop capture was already discharged."); + try + { + bool accepted = publication.Sink.TryCommitBackdropCapture( + bitmap, + publication.Density); + publication.Bitmap = null; + if (!accepted) + bitmap.Dispose(); + } + catch + { + if (publication.Bitmap is not null) + { + publication.Bitmap = null; + bitmap.Dispose(); + } + throw; + } + } + + _pendingBackdropPublications.Clear(); + } + + public void RejectBuiltInBackdropCaptures() + { + List? failures = null; + foreach (PendingBackdropPublication publication in _pendingBackdropPublications) + { + Bitmap? bitmap = publication.Bitmap; + publication.Bitmap = null; + if (bitmap is null) + continue; + try + { + bitmap.Dispose(); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + _pendingBackdropPublications.Clear(); + + if (failures is null) + return; + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + throw new AggregateException("One or more staged backdrop captures failed to dispose.", failures); + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.DrawableBrush.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.DrawableBrush.cs new file mode 100644 index 0000000000..b6cf6f14c2 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.DrawableBrush.cs @@ -0,0 +1,313 @@ +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private static readonly AsyncLocal?> s_activeDrawableBrushes = new(); + + internal DrawableBrushMaterializer DrawableBrushMaterializer => _drawableBrushMaterializer; + + private RenderExecutionSessionToken CreateExecutionSessionToken() + => new(_drawableBrushMaterializer); + + private ImmediateCanvas CreateExecutorCanvas( + RenderTarget target, + float density, + float maxWorkingScale, + Size logicalSize, + RenderIntent intent, + PixelPoint deviceOrigin = default) + { + ImmediateCanvas canvas = ImmediateCanvas.CreateExecutorManaged( + target, + density, + maxWorkingScale, + logicalSize, + intent, + deviceOrigin); + canvas.DrawableBrushMaterializer = _drawableBrushMaterializer; + return canvas; + } + + private MaterializedDrawableBrush? MaterializeDrawableBrush( + DrawableBrush.Resource brush, + Rect bounds, + float scale) + { + ArgumentNullException.ThrowIfNull(brush); + RenderRectValidation.ThrowIfInvalidInput(bounds, nameof(bounds)); + if (!float.IsFinite(scale) || scale <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(scale), scale, "Drawable-brush density must be positive and finite."); + } + + if (bounds.Width == 0 || bounds.Height == 0 || brush.Drawable is not { } drawable) + return null; + + using DrawableBrushCycleScope cycle = EnterDrawableBrush(drawable); + Rect domain = new(default, bounds.Size); + + DrawableRenderNode? root = null; + RenderRequest? request = null; + CompiledRenderRequest? compiled = null; + RenderTargetLease? lease = null; + ImmediateCanvas? canvas = null; + SKImage? image = null; + Rect contentBounds = default; + ExceptionDispatchInfo? failure = null; + try + { + root = new DrawableRenderNode(drawable); + using (var graphics = new GraphicsContext2D(root, domain.Size, scale)) + { + drawable.GetOriginal()!.Render(graphics, drawable); + } + + var cacheContext = new RenderCacheResolutionContext( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + _targets.CacheDeviceContextIdentity, + allowPersistentLookup: false, + allowCapturePublication: false); + SkslBackendBudget shaderBudget = SkslBackendBudgetResolver.Resolve( + _targets.ExternalTarget?.RawValue.Context?.Backend); + + CompiledRenderRequest Compile(Rect targetDomain) + { + request = new RenderRequest( + new RenderRequestOptions( + _options.Intent, + _options.Purpose, + targetDomain, + requestedRegion: null, + outputScale: scale, + maxWorkingScale: _options.MaxWorkingScale, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: _options.FusionMode)); + var recorder = new RenderRequestRecorder(request); + RecordedRenderGraph graph = recorder.Record(root); + CompiledRenderRequest result = new RenderRequestCompiler( + structuralPlanCache: null, + renderCacheContext: cacheContext, + renderCacheLookup: null) + .Compile(request, graph, shaderBudget); + request = null; + return result; + } + + compiled = Compile(domain); + Rect intrinsicDomain = compiled.Measurement.QueryBounds; + if (intrinsicDomain.Width != 0 + && intrinsicDomain.Height != 0 + && intrinsicDomain != domain) + { + compiled.Dispose(); + compiled = Compile(intrinsicDomain); + } + + Rect executionBounds = compiled.ExecutionTargetBounds; + PixelRect deviceBounds = PixelRect.FromRect( + new Rect(default, executionBounds.Size), + scale); + Rect relativeContentBounds = compiled.SelectedOutputBounds + .WithX(compiled.SelectedOutputBounds.X - executionBounds.X) + .WithY(compiled.SelectedOutputBounds.Y - executionBounds.Y); + PixelRect contentDevice = PixelRect + .FromRect(relativeContentBounds, scale) + .Intersect(deviceBounds); + bool hasContent = contentDevice.Width != 0 && contentDevice.Height != 0; + lease = hasContent ? _targets.TryAcquire(deviceBounds.Size) : null; + if (hasContent && lease is null) + { + // TryAcquire throws for a delivery session, so a null lease here is a preview drop. The + // brush degrades to transparent, and a frame carrying that must not reach a cache. + MarkPreviewAllocationDropped(); + } + + if (lease is not null) + { + Rect rasterBounds = executionBounds.WithX(0).WithY(0); + canvas = CreateExecutorCanvas( + lease.Target, + scale, + _options.MaxWorkingScale, + rasterBounds.Size, + _options.Intent); + canvas.Clear(); + + var executor = new RenderRequestExecutor( + _targets, + _programCache, + spirvProgramCache: _spirvProgramCache, + shaderBackendPreference: _shaderBackendPreference); + using (canvas.PushTransform(Matrix.CreateTranslation(-executionBounds.X, -executionBounds.Y))) + { + executor.Execute( + compiled, + canvas, + replayBounds: compiled.ExecutionTargetBounds); + } + canvas.CloseWithoutFlush(); + canvas = null; + + lease.Target.PrepareForSampling( + RenderTargetSamplingIntent.SameContextTextureSampling( + _targets.ExternalTarget?.RawValue.Context)); + image = CreateIndependentImage( + lease.Target.Value, + new PixelRect( + contentDevice.X - deviceBounds.X, + contentDevice.Y - deviceBounds.Y, + contentDevice.Width, + contentDevice.Height)); + contentBounds = compiled.SelectedOutputBounds; + } + } + catch (Exception ex) + { + failure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + CloseAndCapture(canvas, ref failure); + DisposeAndCapture(lease, ref failure); + DisposeAndCapture(compiled, ref failure); + DisposeAndCapture(request, ref failure); + DisposeAndCapture(root, ref failure); + } + + if (failure is not null) + { + DisposeAndCapture(image, ref failure); + failure!.Throw(); + } + + return image is null ? null : new MaterializedDrawableBrush(image, contentBounds); + } + + private static DrawableBrushCycleScope EnterDrawableBrush(Drawable.Resource drawable) + { + Guid identity = EngineResourceIdentity.Of(drawable); + List active = s_activeDrawableBrushes.Value ??= []; + int cycleStart = active.IndexOf(identity); + if (cycleStart >= 0) + { + IEnumerable cycle = active.Skip(cycleStart).Append(identity); + throw new InvalidOperationException( + $"A drawable-brush materialization cycle was detected: {string.Join(" -> ", cycle)}."); + } + + active.Add(identity); + return new DrawableBrushCycleScope(active, identity); + } + + // Crop the raster copy rather than taking a subset surface snapshot, which would allocate a + // backend image per fill on the device. + private static SKImage CreateIndependentImage(SKSurface surface, PixelRect subset) + { + SKImage? owned = surface.Snapshot(); + try + { + SKImage raster = owned.ToRasterImage() + ?? throw new InvalidOperationException( + "The drawable-brush surface could not be copied to an independent image."); + if (!ReferenceEquals(owned, raster)) + { + owned.Dispose(); + owned = raster; + } + + if (subset.X == 0 + && subset.Y == 0 + && subset.Width == raster.Width + && subset.Height == raster.Height) + { + owned = null; + return raster; + } + + return raster.Subset(new SKRectI(subset.X, subset.Y, subset.Right, subset.Bottom)) + ?? throw new InvalidOperationException( + "The drawable-brush image could not be cropped to its content bounds."); + } + finally + { + owned?.Dispose(); + } + } + + private static void CloseAndCapture( + ImmediateCanvas? canvas, + ref ExceptionDispatchInfo? failure) + { + if (canvas is null) + return; + + try + { + canvas.CloseWithoutFlush(); + } + catch (Exception ex) + { + CaptureCleanupFailure(ex, ref failure); + } + } + + private static void DisposeAndCapture( + IDisposable? resource, + ref ExceptionDispatchInfo? failure) + { + if (resource is null) + return; + + try + { + resource.Dispose(); + } + catch (Exception ex) + { + CaptureCleanupFailure(ex, ref failure); + } + } + + private static void CaptureCleanupFailure( + Exception cleanupFailure, + ref ExceptionDispatchInfo? failure) + { + if (failure is null) + { + failure = ExceptionDispatchInfo.Capture(cleanupFailure); + return; + } + + const string key = "DrawableBrushMaterializationCleanupFailure"; + Exception primary = failure.SourceException; + primary.Data[key] = primary.Data[key] is Exception previous + ? new AggregateException(previous, cleanupFailure) + : cleanupFailure; + } + + private readonly struct DrawableBrushCycleScope( + List active, + Guid identity) : IDisposable + { + public void Dispose() + { + int index = active.Count - 1; + if (index < 0 || active[index] != identity) + throw new InvalidOperationException("The drawable-brush materialization stack is corrupted."); + + active.RemoveAt(index); + if (active.Count == 0 && ReferenceEquals(s_activeDrawableBrushes.Value, active)) + s_activeDrawableBrushes.Value = null; + } + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Effects.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Effects.cs new file mode 100644 index 0000000000..9616406447 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Effects.cs @@ -0,0 +1,626 @@ +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private void ReplayOpacityMask( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("An opacity mask requires exactly one input."); + + var payload = (OpacityMaskRenderFragmentPayload)fragment.Payload!; + _ = payload.Mask.Registry.Use( + payload.Mask, + mask => + { + using (destination.PushOpacityMask(mask, payload.BrushBounds, payload.Invert)) + Replay(fragment.Inputs[0], destination); + return true; + }); + } + + private IReadOnlyList MaterializeOpacity( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("An opacity fragment requires exactly one input."); + if (fragment.Bounds.Width == 0 || fragment.Bounds.Height == 0) + { + CompleteFragmentUse(fragment.Inputs[0]); + MarkExecutionSkipped(fragment); + return []; + } + + EffectiveScale scale = ClampToActiveDeviceGrid( + fragment.Bounds, + requestedScale ?? ResolveConcreteScale(fragment)); + RenderFragmentReference input = fragment.Inputs[0]; + IReadOnlyList values = Materialize( + input, + currentTarget, + input.EffectiveScale.IsUnbounded ? scale : null); + try + { + MaterializedRenderValue value = CreateOwnedValue( + fragment.Bounds, + scale, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + value.DeviceBounds, + value.DeviceGridOffset, + scale.Value); + using var canvas = CreateExecutorCanvas( + value.Target, + scale.Value, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + using (canvas.PushOpacity(((OpacityRenderFragmentPayload)fragment.Payload!).Opacity)) + DrawValues(values, canvas); + succeeded = true; + return [value]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(value); + } + } + finally + { + CompleteFragmentUse(input); + } + } + + private IReadOnlyList MaterializeOpacityMask( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("An opacity mask requires exactly one input."); + if (fragment.Bounds.Width == 0 || fragment.Bounds.Height == 0) + { + CompleteFragmentUse(fragment.Inputs[0]); + MarkExecutionSkipped(fragment); + return []; + } + + EffectiveScale scale = ClampToActiveDeviceGrid( + fragment.Bounds, + requestedScale ?? ResolveConcreteScale(fragment)); + RenderFragmentReference primary = fragment.Inputs[0]; + IReadOnlyList primaryValues = Materialize( + primary, + currentTarget, + primary.EffectiveScale.IsUnbounded ? scale : null); + try + { + MaterializedRenderValue value = CreateOwnedValue( + fragment.Bounds, + scale, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + value.DeviceBounds, + value.DeviceGridOffset, + scale.Value); + using var canvas = CreateExecutorCanvas( + value.Target, + scale.Value, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + var payload = (OpacityMaskRenderFragmentPayload)fragment.Payload!; + _ = payload.Mask.Registry.Use( + payload.Mask, + mask => + { + using (canvas.PushOpacityMask( + mask, + payload.BrushBounds, + payload.Invert)) + { + DrawValues(primaryValues, canvas); + } + return true; + }); + } + + succeeded = true; + return [value]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(value); + } + } + finally + { + CompleteFragmentUse(primary); + } + } + + private IReadOnlyList ExecuteOpaque( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + => ExecuteOnDeviceGrid( + currentTarget, + () => ExecuteOpaqueCore(fragment, currentTarget, requestedScale)); + + private IReadOnlyList ExecuteOpaqueCore( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + var payload = (OpaqueRenderFragmentPayload)fragment.Payload!; + OpaqueRenderDescription description = payload.Description; + var flattened = new List(); + var inputReadbacks = new List(); + var inputRanges = new List(fragment.Inputs.Length); + EffectiveScale outputSupply = requestedScale + ?? (!fragment.EffectiveScale.IsUnbounded + ? fragment.EffectiveScale + : EffectiveScale.At(currentTarget.Density)); + for (int inputIndex = 0; inputIndex < fragment.Inputs.Length; inputIndex++) + { + RenderFragmentReference input = fragment.Inputs[inputIndex]; + IReadOnlyList inputValues = Materialize( + input, + currentTarget, + input.EffectiveScale.IsUnbounded ? outputSupply : null); + RenderInputReadback readback = payload.InputReadbacks[inputIndex]; + readback.ValidateRuntimeCount(input.ValueCardinality, inputValues.Count); + inputRanges.Add(new RenderExecutionInputRange(flattened.Count, inputValues.Count)); + for (int valueIndex = 0; valueIndex < inputValues.Count; valueIndex++) + { + flattened.Add(inputValues[valueIndex]); + inputReadbacks.Add(readback.RequiresValue(valueIndex)); + } + } + + try + { + if (payload.Topology == OpaqueRenderTopology.Map) + { + var mapped = new List(); + bool mapCallbackInvoked = false; + for (int inputIndex = 0; inputIndex < flattened.Count; inputIndex++) + { + MaterializedRenderValue input = flattened[inputIndex]; + Rect outputBounds = description.Bounds.TransformBounds([input.CompleteBounds]); + EffectiveScale outputScale = requestedScale + ?? description.Scale.Resolve( + [input.EffectiveScale], + outputBounds, + _options.OutputScale, + _options.MaxWorkingScale); + mapped.AddRange(InvokeOpaque( + fragment, + description, + [input], + [inputReadbacks[inputIndex]], + [new RenderExecutionInputRange(0, 1)], + outputBounds, + outputScale, + description.ValueCardinality, + out bool currentCallbackInvoked)); + mapCallbackInvoked |= currentCallbackInvoked; + } + + if (!mapCallbackInvoked) + MarkExecutionSkipped(fragment); + return mapped; + } + + Rect declaredBounds = description.Bounds.TransformBounds( + flattened.Select(static value => value.CompleteBounds).ToArray()); + EffectiveScale declaredScale = requestedScale + ?? description.Scale.Resolve( + flattened.Select(static value => value.EffectiveScale).ToArray(), + declaredBounds, + _options.OutputScale, + _options.MaxWorkingScale); + IReadOnlyList result = InvokeOpaque( + fragment, + description, + flattened, + inputReadbacks, + inputRanges, + declaredBounds, + declaredScale, + description.ValueCardinality, + out bool singleCallbackInvoked); + if (!singleCallbackInvoked) + MarkExecutionSkipped(fragment); + return result; + } + finally + { + foreach (RenderFragmentReference input in fragment.Inputs) + CompleteFragmentUse(input); + } + } + + private IReadOnlyList ExecuteLegacyFilter( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget) + => ExecuteOnDeviceGrid( + currentTarget, + () => ExecuteLegacyFilterCore(fragment, currentTarget), + normalizeGridPhase: fragment.Payload is FilterEffectSegmentRenderFragmentPayload payload + && payload.HasImperativeItem); + + private IReadOnlyList ExecuteLegacyFilterCore( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget) + { + Rect requiredRegion = ResolveFragmentRequirement(fragment, fragment.Bounds); + var payload = (FilterEffectSegmentRenderFragmentPayload)fragment.Payload!; + if (FilterEffectSegmentDirectReplaySupport.CanMaterialize(fragment)) + { + return ExecuteDirectSkiaFilterMaterialization( + fragment, + currentTarget, + payload, + requiredRegion); + } + + var inputs = new List(); + EffectiveScale inputRequestScale = fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(currentTarget.Density) + : fragment.EffectiveScale; + for (int index = 0; index < fragment.Inputs.Length; index++) + { + RenderFragmentReference input = fragment.Inputs[index]; + inputs.AddRange(Materialize( + input, + currentTarget, + input.EffectiveScale.IsUnbounded ? inputRequestScale : null)); + } + + try + { + return payload.Context.Registry.Use( + payload.Context, + effectContext => + { + using var targets = new EffectTargets(); + foreach (MaterializedRenderValue input in inputs) + { + bool hasCompleteBacking = input.RasterBounds.Contains(input.CompleteBounds); + Rect inputBounds = hasCompleteBacking ? input.CompleteBounds : input.Bounds; + targets.Add(new EffectTarget( + input.Target, + inputBounds, + input.EffectiveScale, + input.DeviceBounds, + input.DeviceGridOffset, + input.PreserveLegacyRasterPlacement && hasCompleteBacking) + { + OriginalBounds = new Rect(default, inputBounds.Size), + Bounds = inputBounds, + }); + } + + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + _options.Intent, + _options.Purpose, + _options.OutputScale, + fragment.EffectiveScale.Value, + _options.MaxWorkingScale, + _activeDeviceGridOffset, + (target, source) => AcquireStandaloneProgram( + target, + source), + _drawableBrushMaterializer, + useExecutorManagedCanvas: true, + renderTargetLeaseSession: _targets); + activator.Apply(effectContext); + activator.CompletePolicyBoundary( + payload.WorkingScalePolicy.HasValue); + + var result = new List(activator.CurrentTargets.Count); + foreach (EffectTarget target in activator.CurrentTargets) + { + if (target.RenderTarget is not { } renderTarget) + continue; + + MaterializedRenderValue value = MaterializeLegacyTarget( + target, + renderTarget, + target.Bounds); + _ownedValues.Add(value); + + // Cropping the input to the backward region leaves the surrounding output + // undefined, so the published value must not claim it. + Rect selectedBounds = value.Bounds.Intersect(requiredRegion); + if (selectedBounds.Width == 0 || selectedBounds.Height == 0) + { + ReleaseUnpublished(value); + continue; + } + + if (selectedBounds != value.Bounds) + { + if (value.PreserveLegacyRasterPlacement + || value.RasterBounds.Contains(value.CompleteBounds)) + { + // Preserve a complete backing so later legacy effects can sample + // the physical footprint while Bounds remains the selected output. + value.Bounds = selectedBounds; + } + else + { + MaterializedRenderValue cropped = CropValue( + fragment, + value, + selectedBounds); + ReleaseUnpublished(value); + value = cropped; + } + } + + result.Add(value); + } + + return (IReadOnlyList)result; + }); + } + finally + { + foreach (RenderFragmentReference input in fragment.Inputs) + CompleteFragmentUse(input); + } + } + + private IReadOnlyList ExecuteDirectSkiaFilterMaterialization( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + FilterEffectSegmentRenderFragmentPayload payload, + Rect requiredRegion) + { + RenderFragmentReference input = fragment.Inputs[0]; + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + { + CompleteFragmentUse(input); + MarkExecutionSkipped(fragment); + return []; + } + + float requestedDensity = fragment.EffectiveScale.IsUnbounded + ? currentTarget.Density + : fragment.EffectiveScale.Value; + EffectiveScale scale = ClampToActiveDeviceGrid( + fragment.Bounds, + EffectiveScale.At(requestedDensity)); + MaterializedRenderValue? output = null; + bool succeeded = false; + bool replayStarted = false; + try + { + output = CreateOwnedValue( + requiredRegion, + scale, + fragment.Bounds, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + using var builder = new SKImageFilterBuilder(); + foreach (IFEItem item in payload.BoundsItems) + ((IFEItem_Skia)item).AcceptsDirect(builder); + + using var paint = builder.HasFilter() + ? new SKPaint { ImageFilter = builder.GetFilter() } + : null; + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + output.DeviceBounds, + output.DeviceGridOffset, + scale.Value); + using var canvas = CreateExecutorCanvas( + output.Target, + scale.Value, + _options.MaxWorkingScale, + output.RasterBounds.Size, + _options.Intent, + output.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + if (paint is not null) + { + Rect replayedInputBounds = ResolveFragmentRequirement(input, input.Bounds); + Rect layerContentBounds = GetDirectFilterLayerBounds( + input.Bounds, + replayedInputBounds); + using (canvas.PushBlendMode(BlendMode.SrcOver)) + using (canvas.PushTransform(Matrix.Identity)) + // The filter layer must match the region Replay writes, not the input's full + // semantic bounds. A wider layer exposes unwritten pixels to spatial filters. + using (canvas.PushFilterLayer(paint, layerContentBounds)) + { + replayStarted = true; + Replay(input, canvas); + } + } + else + { + replayStarted = true; + Replay(input, canvas); + } + } + + succeeded = true; + return [output]; + } + finally + { + if (!replayStarted) + CompleteFragmentUse(input); + if (!succeeded && output is not null) + ReleaseUnpublished(output); + } + } + + private MaterializedRenderValue MaterializeLegacyTarget( + EffectTarget target, + RenderTarget renderTarget, + Rect completeBounds) + { + if (target.PreserveLegacyRasterPlacement) + { + Vector deviceGridOffset = target.DeviceBounds + .ToRect(target.Scale.Value) + .Position - target.RasterBounds.Position; + return CreateOwnedLegacyValue( + target, + renderTarget, + target.Bounds, + target.Scale, + target.DeviceBounds, + deviceGridOffset, + completeBounds, + preserveLegacyRasterPlacement: true); + } + + Rect canonicalRasterBounds = target.DeviceBounds + .ToRect(target.Scale.Value) + .Translate(-target.DeviceGridOffset); + PixelRect semanticDeviceBounds = PixelRect.FromRect( + target.Bounds.Translate(target.DeviceGridOffset), + target.Scale.Value); + if (target.RasterBounds == canonicalRasterBounds + && Contains(target.DeviceBounds, semanticDeviceBounds)) + { + return CreateOwnedLegacyValue( + target, + renderTarget, + target.Bounds, + target.Scale, + target.DeviceBounds, + target.DeviceGridOffset, + completeBounds: completeBounds); + } + + Rect physicalBounds = target.RasterBounds.Union(target.Bounds); + float density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + physicalBounds.Translate(target.DeviceGridOffset), + target.Scale.Value); + EffectiveScale normalizedScale = EffectiveScale.At(density); + PixelRect normalizedDeviceBounds = PixelRect.FromRect(physicalBounds, density); + MaterializedRenderValue normalized = CreateOwnedValue( + target.Bounds, + normalizedScale, + completeBounds, + physicalDeviceBounds: normalizedDeviceBounds, + deviceGridOffset: target.DeviceGridOffset, + allowPreviewDrop: true); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + normalized.DeviceBounds, + normalized.DeviceGridOffset, + normalized.EffectiveScale.Value); + using var canvas = CreateExecutorCanvas( + normalized.Target, + normalized.EffectiveScale.Value, + _options.MaxWorkingScale, + normalized.RasterBounds.Size, + _options.Intent, + normalized.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.DrawRenderTargetScaledWithoutFlush(renderTarget, target.RasterBounds); + } + + succeeded = true; + return normalized; + } + finally + { + if (!succeeded) + ReleaseUnpublished(normalized); + } + } + + private static MaterializedRenderValue CreateOwnedLegacyValue( + EffectTarget effectTarget, + RenderTarget renderTarget, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset, + Rect? completeBounds = null, + bool preserveLegacyRasterPlacement = false) + { + EffectTargetRenderTargetLease? renderTargetLease = effectTarget.TakeRenderTargetLease(); + if (renderTargetLease is null) + { + return CreateOwnedShallowCopy( + renderTarget, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset, + completeBounds, + preserveLegacyRasterPlacement); + } + + try + { + return new MaterializedRenderValue( + renderTargetLease, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset, + completeBounds, + preserveLegacyRasterPlacement); + } + catch + { + renderTargetLease.Dispose(); + throw; + } + } + + private static bool Contains(PixelRect outer, PixelRect inner) + => outer.X <= inner.X + && outer.Y <= inner.Y + && outer.Right >= inner.Right + && outer.Bottom >= inner.Bottom; + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Family.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Family.cs new file mode 100644 index 0000000000..2c20b24202 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Family.cs @@ -0,0 +1,398 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private void ExecuteFamily( + CompiledRenderRequest request, + ImmediateCanvas destination, + Rect replayBounds, + Action? finalizeOutput, + ProgramCache programCache, + ProgramCache spirvProgramCache, + ICollection frames, + ICollection cleanupFailures, + ref int nestedRootAcquisitions, + ref bool nestedPreviewDropObserved) + { + foreach (CompiledRenderRequest nested in request.NestedRequests) + { + ExecuteNested( + nested, + destination, + programCache, + spirvProgramCache, + frames, + cleanupFailures, + ref nestedRootAcquisitions, + ref nestedPreviewDropObserved); + } + + ExecuteSingle( + request, + destination, + replayBounds, + finalizeOutput, + programCache, + spirvProgramCache, + frames, + cleanupFailures, + ref nestedPreviewDropObserved); + } + + private void ExecuteNested( + CompiledRenderRequest request, + ImmediateCanvas fallbackDestination, + ProgramCache programCache, + ProgramCache spirvProgramCache, + ICollection frames, + ICollection cleanupFailures, + ref int nestedRootAcquisitions, + ref bool nestedPreviewDropObserved) + { + NestedRenderTargetBinding binding = request.Request.Options.TargetBinding + ?? throw new InvalidOperationException("A nested request has no separate-target binding."); + bool needsTarget = request.Measurement.HasContributingValues + || request.Measurement.HasTargetEffects; + if (!needsTarget) + { + ExecuteFamily( + request, + fallbackDestination, + request.ExecutionTargetBounds, + finalizeOutput: null, + programCache, + spirvProgramCache, + frames, + cleanupFailures, + ref nestedRootAcquisitions, + ref nestedPreviewDropObserved); + return; + } + + Rect bounds = request.Request.Options.TargetDomain + ?? throw new InvalidOperationException( + "A separate-target nested request requires a finite target domain."); + + RenderTargetLease? lease = null; + ImmediateCanvas? canvas = null; + FamilyExecutionException? failure = null; + bool dropped = false; + RenderTargetCleanupFailureCheckpoint cleanupCheckpoint = + _targets.CaptureCleanupFailureCheckpoint(); + try + { + PixelRect deviceBounds = PixelRect.FromRect(bounds, request.Request.Options.OutputScale); + Rect rasterBounds = deviceBounds.ToRect(request.Request.Options.OutputScale); + try + { + // TryAcquire itself throws for a Delivery session, so a null result is a preview drop. + RenderTargetLease? acquired = _targets.TryAcquire(deviceBounds.Size); + if (acquired is null) + { + dropped = true; + nestedPreviewDropObserved = true; + SkipNestedFamily(request); + } + else + { + lease = acquired; + nestedRootAcquisitions++; + RenderTarget target = lease.Target; + binding.Stage(lease, bounds, request.Request.Options.OutputScale); + lease = null; + canvas = ImmediateCanvas.CreateExecutorManaged( + target, + request.Request.Options.OutputScale, + request.Request.Options.MaxWorkingScale, + rasterBounds.Size, + request.Request.Options.Intent, + deviceBounds.Position); + canvas.Clear(); + } + } + catch (Exception ex) + { + failure = new FamilyExecutionException( + ExceptionDispatchInfo.Capture(ex)); + } + + if (failure is null && !dropped) + { + using (canvas!.PushTransform(Matrix.CreateTranslation( + -rasterBounds.X, + -rasterBounds.Y))) + { + ExecuteFamily( + request, + canvas, + request.ExecutionTargetBounds, + finalizeOutput: null, + programCache, + spirvProgramCache, + frames, + cleanupFailures, + ref nestedRootAcquisitions, + ref nestedPreviewDropObserved); + } + + canvas.CloseWithoutFlush(); + canvas = null; + binding.PrepareForSampling(); + } + } + catch (FamilyExecutionException ex) + { + failure = ex; + } + finally + { + if (failure is not null) + binding.Reject(); + + try + { + canvas?.CloseWithoutFlush(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + failure ??= new FamilyExecutionException( + ExceptionDispatchInfo.Capture(ex)); + } + + lease?.Dispose(); + foreach (Exception cleanupFailure in _targets.GetCleanupFailuresSince(cleanupCheckpoint)) + { + AppendCleanupFailures(cleanupFailures, cleanupFailure); + failure ??= new FamilyExecutionException( + ExceptionDispatchInfo.Capture(cleanupFailure)); + } + } + + if (failure is not null) + throw failure; + } + + private void ExecuteSingle( + CompiledRenderRequest request, + ImmediateCanvas destination, + Rect replayBounds, + Action? finalizeOutput, + ProgramCache programCache, + ProgramCache spirvProgramCache, + ICollection frames, + ICollection cleanupFailures, + ref bool nestedPreviewDropObserved) + { + request.Request.TransitionTo(RenderRequestState.Executing); + var state = new RenderRequestExecutionState( + request.Request.Options, + request.Graph, + request.ExecutionPlan, + request.TargetDependencies, + request.Regions, + request.Roots, + request.MaterializationDemands, + request.PreviewDropEligibleMaterializations, + request.CacheResolution, + _targets, + programCache, + spirvProgramCache, + _shaderBackendPreference, + _afterCaptureAllocation); + if (nestedPreviewDropObserved) + state.MarkPreviewAllocationDropped(); + + var frame = new FamilyExecutionFrame(request, state); + frames.Add(frame); + using IDisposable materializerScope = destination.PushDrawableBrushMaterializer( + state.DrawableBrushMaterializer); + // Brush-owned intermediates allocate themselves, so they need the pass's session to reach the + // caller's factory; without it a tile brush would mix a global-allocator surface into the pass. + using IDisposable leaseSessionScope = destination.PushRenderTargetLeaseSession(_targets); + ExceptionDispatchInfo? bodyFailure = null; + try + { + if (replayBounds.Width != 0 && replayBounds.Height != 0) + { + Rect rasterClip = RenderScaleUtilities.AddRasterApron( + PixelRect.FromRect(replayBounds, destination.Density)) + .ToRect(destination.Density); + using (destination.PushClip(rasterClip)) + { + foreach (RenderFragmentReference root in request.Roots) + state.Replay(root, destination); + } + } + else + { + foreach (RenderFragmentReference root in request.Roots) + { + if (root.HasTargetEffects) + state.Replay(root, destination); + } + } + + state.ValidateExecutionCompleted( + allowSkippedIslands: replayBounds.Width == 0 || replayBounds.Height == 0); + state.PrepareBuiltInBackdropCaptures(); + finalizeOutput?.Invoke(); + } + catch (Exception ex) + { + bodyFailure = ExceptionDispatchInfo.Capture(ex); + } + + ExceptionDispatchInfo? cleanupFailure = null; + try + { + state.DisposeNonCacheValues(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + cleanupFailure = ExceptionDispatchInfo.Capture( + ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions[0] + : ex); + } + + // A drop the body observed - a materialization that could not allocate, a replay that abandoned - + // makes this request's output incomplete, and its parent composites that output. Reporting it only + // for a failed root acquisition would let the parent publish degraded pixels into a cache. + nestedPreviewDropObserved |= state.PreviewAllocationDropObserved; + + if (bodyFailure is not null) + throw new FamilyExecutionException(bodyFailure); + if (cleanupFailure is not null) + throw new FamilyExecutionException(cleanupFailure); + } + + private static void ValidateFamilyForExecution(CompiledRenderRequest request) + { + foreach (CompiledRenderRequest nested in request.NestedRequests) + ValidateFamilyForExecution(nested); + ObjectDisposedException.ThrowIf(request.IsDisposed, request); + if (request.Request.State != RenderRequestState.Planned) + throw new InvalidOperationException("Every render request in a family must be planned before execution."); + } + + private static void CompleteFamily(CompiledRenderRequest request) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(request)) + member.Request.TransitionTo(RenderRequestState.Completed); + } + + // A dropped subtree never runs, but CompleteFamily still transitions it, and RenderRequest only allows + // Planned -> Executing -> Completed. + private static void SkipNestedFamily(CompiledRenderRequest request) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(request)) + { + member.Request.Options.TargetBinding?.Reject(); + member.Request.TransitionTo(RenderRequestState.Executing); + } + } + + private static void RejectNestedBindings(CompiledRenderRequest request) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(request)) + member.Request.Options.TargetBinding?.Reject(); + } + + private static void FailFamily(CompiledRenderRequest request) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(request)) + member.Request.FailFamilyMember(); + } + + private static IEnumerable EnumerateFamilyDepthFirst( + CompiledRenderRequest request) + { + foreach (CompiledRenderRequest nested in request.NestedRequests) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(nested)) + yield return member; + } + + yield return request; + } + + private static void EnsureOwnerPrimary(RenderRequestOwner owner, Exception? failure) + { + if (failure is not null && owner.PrimaryFailure is null) + owner.RecordPrimaryFailure(failure); + } + + private sealed record FamilyExecutionFrame( + CompiledRenderRequest Request, + RenderRequestExecutionState State); + + private sealed class FamilyExecutionException( + ExceptionDispatchInfo failure) : Exception + { + public ExceptionDispatchInfo Failure { get; } = failure; + } + + private sealed class FamilyCachePublicationException( + ExceptionDispatchInfo failure, + IReadOnlyList cleanupFailures) : Exception + { + public ExceptionDispatchInfo Failure { get; } = failure; + + public IReadOnlyList CleanupFailures { get; } = cleanupFailures; + } + + private sealed class PreviewAllocationDropException : Exception + { + } + + private static void AppendCleanupFailures( + ICollection failures, + Exception exception) + { + if (exception is AggregateException aggregate) + { + foreach (Exception inner in aggregate.Flatten().InnerExceptions) + { + AddCleanupFailure(failures, inner); + } + } + else + { + AddCleanupFailure(failures, exception); + } + } + + private static void AddCleanupFailure( + ICollection failures, + Exception exception) + { + if (failures.Any(existing => ReferenceEquals(existing, exception))) + return; + failures.Add(exception); + } + + private static void RecordAdditionalFailures( + RenderRequestOwner owner, + IEnumerable failures) + { + Exception[] ownerCleanupFailures = [.. owner.CleanupFailures]; + foreach (Exception failure in failures) + { + if (!ReferenceEquals(owner.PrimaryFailure?.SourceException, failure) + && !ownerCleanupFailures.Any(existing => ReferenceEquals(existing, failure))) + { + owner.RecordPrimaryFailure(failure); + } + } + } + +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Geometry.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Geometry.cs new file mode 100644 index 0000000000..cd8f047309 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Geometry.cs @@ -0,0 +1,433 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private IReadOnlyList ExecuteGeometry( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget) + => ExecuteOnDeviceGrid( + currentTarget, + () => ExecuteGeometryCore(fragment, currentTarget)); + + private IReadOnlyList ExecuteGeometryCore( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("A Geometry fragment requires exactly one input stream."); + + GeometryDescription description = ((GeometryRenderFragmentPayload)fragment.Payload!).Description; + EffectiveScale requestScale = fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(currentTarget.Density) + : fragment.EffectiveScale; + IReadOnlyList inputs = Materialize( + fragment.Inputs[0], + currentTarget, + fragment.Inputs[0].EffectiveScale.IsUnbounded ? requestScale : null); + var results = new List(inputs.Count); + bool executed = false; + try + { + foreach (MaterializedRenderValue input in inputs) + { + Rect outputBounds = description.Bounds.TransformBounds(input.CompleteBounds); + if (outputBounds.Width == 0 || outputBounds.Height == 0) + continue; + + Rect requiredRegion = ResolveFragmentRequirement(fragment, outputBounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + continue; + + float density = requestScale.Value; + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Translate(_activeDeviceGridOffset), + density); + EffectiveScale outputScale = EffectiveScale.At(density); + MaterializedRenderValue output = CreateOwnedValue( + requiredRegion, + outputScale, + outputBounds, + allowPreviewDrop: true); + bool keepOutput = false; + try + { + Rect? finalBounds = ExecuteGeometryElement( + fragment, + description, + input, + output, + outputBounds, + requiredRegion); + executed = true; + if (finalBounds is not { Width: > 0, Height: > 0 } selectedBounds) + continue; + + if (selectedBounds != requiredRegion) + { + MaterializedRenderValue cropped = CropValue( + output, + selectedBounds, + allowPreviewDrop: true); + ReleaseUnpublished(output); + output = cropped; + } + + results.Add(output); + keepOutput = true; + } + finally + { + if (!keepOutput) + ReleaseUnpublished(output); + } + } + + if (!executed) + MarkExecutionSkipped(fragment); + return results; + } + catch + { + foreach (MaterializedRenderValue value in results) + ReleaseUnpublished(value); + throw; + } + finally + { + CompleteFragmentUse(fragment.Inputs[0]); + } + } + + private Rect? ExecuteGeometryElement( + RenderFragmentReference fragment, + GeometryDescription description, + MaterializedRenderValue input, + MaterializedRenderValue output, + Rect outputBounds, + Rect requiredRegion) + { + using SKImage inputImage = input.Target.Value.Snapshot(); + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + return token.RunAndComplete( + () => + { + Func? createSnapshot = description.RequiresReadback + ? () => SnapshotInputForReadback(input) + : null; + var executionInput = new RenderExecutionInput( + token, + input.Bounds, + input.EffectiveScale, + input.DeviceBounds, + input.RasterBounds, + inputImage, + createSnapshot, + description.RequiresReadback); + var callbackCanvas = new RenderCallbackCanvas( + token, + output.EffectiveScale.Value, + requiredRegion, + output.DeviceBounds, + () => CreateExecutorCanvas( + output.Target, + output.EffectiveScale.Value, + _options.MaxWorkingScale, + output.RasterBounds.Size, + _options.Intent, + output.DeviceBounds.Position), + CallbackCanvasCapability.Draw, + rasterBounds: output.RasterBounds); + var session = new GeometrySession( + token, + executionInput, + outputBounds, + requiredRegion, + output.DeviceBounds, + _options.OutputScale, + output.EffectiveScale.Value, + _options.MaxWorkingScale, + _options.Intent, + _options.Purpose, + callbackCanvas, + description.Resources); + description.Render(session); + if (session.IsOutputDiscarded) + return null; + + return session.OutputBounds.Intersect(requiredRegion); + }); + } + + private MaterializedRenderValue CropValue( + RenderFragmentReference fragment, + MaterializedRenderValue source, + Rect selectedBounds) + => CropValue( + source, + selectedBounds, + _previewDropEligibleMaterializations.Contains(fragment)); + + private MaterializedRenderValue CropValue( + MaterializedRenderValue source, + Rect selectedBounds, + bool allowPreviewDrop) + { + MaterializedRenderValue cropped = CreateOwnedValue( + selectedBounds, + source.EffectiveScale, + source.CompleteBounds, + deviceGridOffset: source.DeviceGridOffset, + allowPreviewDrop: allowPreviewDrop); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + cropped.DeviceBounds, + cropped.DeviceGridOffset, + cropped.EffectiveScale.Value); + using var canvas = CreateExecutorCanvas( + cropped.Target, + cropped.EffectiveScale.Value, + _options.MaxWorkingScale, + cropped.RasterBounds.Size, + _options.Intent, + cropped.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.ClipRect(selectedBounds); + canvas.DrawRenderTargetScaledWithoutFlush(source.Target, source.RasterBounds); + } + succeeded = true; + return cropped; + } + finally + { + if (!succeeded) + ReleaseUnpublished(cropped); + } + } + + private IReadOnlyList InvokeOpaque( + RenderFragmentReference fragment, + OpaqueRenderDescription description, + IReadOnlyList inputs, + IReadOnlyList inputReadbacks, + IReadOnlyList inputRanges, + Rect outputBounds, + EffectiveScale declaredScale, + RenderValueCardinality cardinality, + out bool callbackInvoked) + { + callbackInvoked = false; + Rect requiredRegion = ResolveFragmentRequirement(fragment, outputBounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + return []; + + var inputImages = new List(); + var executionInputs = new List(inputs.Count); + var outputLeases = new Dictionary( + ReferenceEqualityComparer.Instance); + var published = new List(); + bool succeeded = false; + bool callbackWasInvoked = false; + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + try + { + IReadOnlyList result = token.RunAndComplete( + () => + { + for (int inputIndex = 0; inputIndex < inputs.Count; inputIndex++) + { + MaterializedRenderValue input = inputs[inputIndex]; + bool requiresReadback = inputReadbacks[inputIndex]; + SKImage image = input.Target.Value.Snapshot(); + inputImages.Add(image); + Func? createSnapshot = requiresReadback + ? () => SnapshotInputForReadback(input) + : null; + executionInputs.Add(new RenderExecutionInput( + token, + input.Bounds, + input.EffectiveScale, + input.DeviceBounds, + input.RasterBounds, + image, + createSnapshot, + requiresReadback)); + } + + float density = declaredScale.IsUnbounded + ? RenderScaleUtilities.ResolveWorkingScale( + inputs.Select(static value => value.EffectiveScale).ToArray(), + _options.OutputScale, + _options.MaxWorkingScale) + : declaredScale.Value; + // A source whose rasterization reaches outside the bounds it publishes declares the + // extra room here rather than publishing the wider rectangle, which would move it. + Thickness rasterOutset = fragment.Kind == RenderFragmentKind.OpaqueSource + ? description.Bounds.RasterOutset + : default; + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Inflate(rasterOutset).Translate(_activeDeviceGridOffset), + density); + bool preserveRasterApron = description.HasDirectReplayMaterializationContract + && fragment.Kind == RenderFragmentKind.OpaqueSource; + density = RenderMaterializationDensityPolicy.Clamp( + fragment, + density); + if (preserveRasterApron) + { + density = RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget( + outputBounds.Inflate(rasterOutset).Translate(_activeDeviceGridOffset), + density); + } + + OpaqueRenderSession? session = null; + session = new OpaqueRenderSession( + token, + executionInputs, + inputRanges, + outputBounds, + requiredRegion, + PixelRect.FromRect( + requiredRegion.Translate(_activeDeviceGridOffset), + density), + _options.OutputScale, + density, + _options.MaxWorkingScale, + _options.Intent, + _options.Purpose, + description.Resources, + (_, logicalBounds, requestedOutputDensity) => + { + float outputDensity = requestedOutputDensity is { } requested + ? Math.Min(requested, _options.MaxWorkingScale) + : density; + if (requestedOutputDensity.HasValue) + { + Rect densityBounds = logicalBounds + .Inflate(rasterOutset) + .Translate(_activeDeviceGridOffset); + outputDensity = preserveRasterApron + ? RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget( + densityBounds, + outputDensity) + : RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + densityBounds, + outputDensity); + } + + EffectiveScale outputScale = EffectiveScale.At(outputDensity); + Rect rasterFootprint = logicalBounds.Inflate(rasterOutset); + PixelRect? physicalDeviceBounds = preserveRasterApron + ? RenderScaleUtilities.AddRasterApron( + PixelRect.FromRect(rasterFootprint, outputDensity)) + : rasterOutset == default + ? null + : PixelRect.FromRect(rasterFootprint, outputDensity); + MaterializedRenderValue value = CreateOwnedValue( + logicalBounds, + outputScale, + outputBounds, + physicalDeviceBounds, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + var canvas = new RenderCallbackCanvas( + token, + outputDensity, + logicalBounds, + value.DeviceBounds, + () => CreateExecutorCanvas( + value.Target, + outputDensity, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position), + CallbackCanvasCapability.Draw, + rasterBounds: value.RasterBounds); + var output = new OpaqueRenderOutput( + token, + session!, + logicalBounds, + outputScale, + canvas, + _ => ReleaseUnpublished(value)); + outputLeases.Add(output, value); + return output; + }, + output => + { + MaterializedRenderValue value = outputLeases[output]; + if (value.Bounds != output.Bounds) + { + MaterializedRenderValue cropped = CropValue( + value, + output.Bounds, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + ReleaseUnpublished(value); + outputLeases[output] = cropped; + value = cropped; + } + published.Add(value); + }); + + callbackWasInvoked = true; + description.Execute(session); + ValidateOutputCount(cardinality, published.Count); + if (description.BackendBoundary != RenderBackendBoundary.None && published.Count != 0) + { + RecordSynchronization(); + } + return published.ToArray(); + }); + succeeded = true; + return result; + } + finally + { + callbackInvoked = callbackWasInvoked; + foreach (SKImage image in inputImages) + image.Dispose(); + + foreach (MaterializedRenderValue value in outputLeases.Values) + { + if (!succeeded || !published.Contains(value, ReferenceEqualityComparer.Instance)) + ReleaseUnpublished(value); + } + } + } + + private IReadOnlyList MaterializeExternal( + RenderFragmentReference fragment) + { + var payload = (MaterializedInputRenderFragmentPayload)fragment.Payload!; + MaterializedInputDescription description = payload.Description; + MaterializedRenderValue value = description.Target.Registry.Use( + description.Target, + target => + { + description.ValidateTargetDeviceSize(target); + return CreateOwnedShallowCopy( + target, + description.Bounds, + description.EffectiveScale, + description.DeviceBounds, + description.DeviceGridOffset); + }); + _ownedValues.Add(value); + return [value]; + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Materialize.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Materialize.cs new file mode 100644 index 0000000000..e09773e3a1 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Materialize.cs @@ -0,0 +1,321 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private IReadOnlyList Materialize( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale = null) + { + if (fragment.EffectiveScale.IsUnbounded) + { + if (!_materializationDemands.TryGetValue(fragment, out EffectiveScale demand)) + { + throw new InvalidOperationException( + "An executable fragment is not reachable from the request publication roots."); + } + + if (requestedScale is { } callerRequest) + { + float callerDensity = MathF.Min( + callerRequest.Value, + RenderScaleUtilities.SanitizeMaxWorkingScale(_options.MaxWorkingScale)); + callerDensity = RenderMaterializationDensityPolicy.Clamp( + fragment, + callerDensity); + if (callerDensity > demand.Value) + { + throw new InvalidOperationException( + "The compiled materialization demand does not cover its contextual caller."); + } + } + + requestedScale = demand; + } + + return MaterializeCore( + fragment, + currentTarget, + requestedScale); + } + + private IReadOnlyList MaterializeCore( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale = null) + { + if (_values.TryGetValue(fragment, out IReadOnlyList? cached)) + return cached; + + IReadOnlyList result; + bool cacheHit = TryMaterializeCacheHit( + fragment, + out IReadOnlyList? hitValues); + if (cacheHit) + { + result = hitValues!; + } + else + { + result = ExecuteFragment(fragment, currentTarget, requestedScale); + } + StageCacheCaptures(fragment, result); + _values.Add(fragment, result); + AddValueReferences(result); + if (fragment.Kind == RenderFragmentKind.ContributeValues && !cacheHit) + CompleteFragmentUse(fragment.Inputs.Single()); + return result; + } + + private IReadOnlyList ExecuteFragment( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (_executionPlan.TryGetMembership(fragment, out ExecutionIslandMembership membership)) + { + ExecutionIsland island = _executionLedger.Begin(fragment); + IReadOnlyList values = membership.ShaderRun is { } run + ? ExecuteCompiledShaderRun(run, currentTarget, requestedScale) + : MaterializePlannedFragment(fragment, currentTarget, requestedScale); + _executionLedger.Complete(island); + return values; + } + + return fragment.Kind switch + { + RenderFragmentKind.MaterializedInput => MaterializeExternal(fragment), + RenderFragmentKind.ContributeValues => MaterializeSingleInput(fragment, currentTarget), + _ => throw new InvalidOperationException( + $"Executable fragment '{fragment.Kind}' is not assigned to an execution island."), + }; + } + + private IReadOnlyList MaterializePlannedFragment( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + => fragment.Kind switch + { + RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand => ExecuteOpaque(fragment, currentTarget, requestedScale), + RenderFragmentKind.FilterEffectSegment => ExecuteLegacyFilter(fragment, currentTarget), + RenderFragmentKind.Shader => ExecuteShader(fragment, currentTarget, requestedScale), + RenderFragmentKind.Geometry => ExecuteGeometry(fragment, currentTarget), + RenderFragmentKind.Opacity => MaterializeOpacity(fragment, currentTarget, requestedScale), + RenderFragmentKind.OpacityMask => MaterializeOpacityMask(fragment, currentTarget, requestedScale), + RenderFragmentKind.Layer => MaterializeLayer(fragment, currentTarget, requestedScale), + RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture => CaptureTarget(fragment, currentTarget), + RenderFragmentKind.TargetScope + when ((TargetScopeRenderFragmentPayload)fragment.Payload!).Description.IsValueReplayMap + => MaterializeValueReplayMap(fragment, currentTarget, requestedScale), + _ => throw new NotSupportedException( + $"The planned fragment '{fragment.Kind}' cannot be materialized as a value."), + }; + + private IReadOnlyList MaterializeSingleInput( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale = null) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("A unary recorded fragment requires exactly one input."); + return Materialize(fragment.Inputs[0], currentTarget, requestedScale); + } + + private bool TryMaterializeCacheHit( + RenderFragmentReference fragment, + out IReadOnlyList? values) + { + if (fragment.Id is not { } id + || !_cacheHits.TryGetValue(id, out RenderCacheHitSubstitution? hit)) + { + values = null; + return false; + } + + if (hit.Entry.Payload is not RenderNodeCachedOutput cachedOutput) + { + throw new InvalidOperationException( + "A selected render-cache hit does not contain a node-cache output payload."); + } + + var acquired = new List(cachedOutput.Values.Count); + bool supportsIndependentOutputDensities = fragment.SupportsIndependentOutputDensities; + try + { + foreach (RenderNodeCachedValue cached in cachedOutput.Values) + { + if (cached.EffectiveScale.IsUnbounded + || (!supportsIndependentOutputDensities + && BitConverter.SingleToInt32Bits(cached.EffectiveScale.Value) + != BitConverter.SingleToInt32Bits(hit.Identity.Density))) + { + throw new InvalidOperationException( + "A render-cache hit payload does not match its planned materialization density."); + } + + MaterializedRenderValue value = CreateOwnedShallowCopy( + cached.Target, + cached.Bounds, + cached.EffectiveScale, + cached.DeviceBounds, + cached.DeviceGridOffset, + completeBounds: cached.CompleteBounds); + _ownedValues.Add(value); + acquired.Add(value); + } + } + catch + { + foreach (MaterializedRenderValue value in acquired) + ReleaseUnpublished(value); + throw; + } + + values = acquired; + return true; + } + + private void StageCacheCaptures( + RenderFragmentReference fragment, + IReadOnlyList values) + { + if (PreviewAllocationDropObserved) + return; + if (fragment.Id is not { } id || !_cacheMisses.TryGetValue(id, out var misses)) + return; + + bool supportsIndependentOutputDensities = fragment.SupportsIndependentOutputDensities; + long actualPixels = 0; + foreach (MaterializedRenderValue value in values) + { + long valuePixels = (long)value.DeviceBounds.Width * value.DeviceBounds.Height; + actualPixels = actualPixels > long.MaxValue - valuePixels + ? long.MaxValue + : actualPixels + valuePixels; + } + + foreach (RenderCacheMissCapture miss in misses) + { + if (!_options.CachePolicy.Rules.Match(actualPixels)) + { + _suppressedCacheCaptures.Add(miss.CandidateId); + continue; + } + + var captures = new List(values.Count); + bool dropped = false; + try + { + foreach (MaterializedRenderValue value in values) + { + if (!supportsIndependentOutputDensities + && BitConverter.SingleToInt32Bits(value.EffectiveScale.Value) + != BitConverter.SingleToInt32Bits(miss.Identity.Density)) + { + throw new InvalidOperationException( + "A render-cache capture does not match its planned materialization density."); + } + + MaterializedRenderValue? capture = CopyForCacheCapture(value); + if (capture is null) + { + dropped = true; + break; + } + + _cacheCaptureValues.Add(capture); + captures.Add(capture); + } + + if (dropped) + { + // The frame keeps its pixels; only this candidate goes uncached. + foreach (MaterializedRenderValue partial in captures) + { + _cacheCaptureValues.Remove(partial); + ReleaseUnpublished(partial); + } + + _suppressedCacheCaptures.Add(miss.CandidateId); + continue; + } + + _pendingCacheCaptures.Add(new PendingRenderCacheCapture(miss, captures)); + } + catch + { + foreach (MaterializedRenderValue capture in captures) + { + _cacheCaptureValues.Remove(capture); + ReleaseUnpublished(capture); + } + throw; + } + } + } + + /// + /// Copies a value so it can be handed to the render cache, or when a preview + /// cannot spare the buffer. + /// + /// + /// This copy exists only to warm a cache: the frame is already correct without it. Allocating it the + /// one way that cannot degrade made it the only thing in a preview that could fail a frame whose + /// pixels were fine. A delivery session still fails here, because TryAcquire never degrades for one. + /// + private MaterializedRenderValue? CopyForCacheCapture(MaterializedRenderValue source) + { + MaterializedRenderValue capture; + try + { + capture = CreateOwnedValue( + source.Bounds, + source.EffectiveScale, + source.CompleteBounds, + source.DeviceBounds, + source.DeviceGridOffset, + physicalDeviceBoundsAreAligned: true, + allowPreviewDrop: true); + } + catch (PreviewAllocationDropException) + { + return null; + } + + bool succeeded = false; + try + { + using var canvas = CreateExecutorCanvas( + capture.Target, + capture.EffectiveScale.Value, + _options.MaxWorkingScale, + capture.RasterBounds.Size, + _options.Intent, + capture.DeviceBounds.Position); + canvas.DrawRenderTargetPixelsWithoutFlush(source.Target, 0, 0); + succeeded = true; + return capture; + } + finally + { + if (!succeeded) + ReleaseUnpublished(capture); + } + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Publish.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Publish.cs new file mode 100644 index 0000000000..b17b91daf6 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Publish.cs @@ -0,0 +1,144 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + public void CompleteEmptySelection(CompiledRenderRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ObjectDisposedException.ThrowIf(request.IsDisposed, request); + if (request.SelectedOutputBounds.Width != 0 && request.SelectedOutputBounds.Height != 0) + { + throw new InvalidOperationException( + "Only a request with an empty selected output can complete without execution."); + } + + CompleteNoOp(request); + } + + public void CompleteNoOp(CompiledRenderRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ObjectDisposedException.ThrowIf(request.IsDisposed, request); + + ValidateFamilyForExecution(request); + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(request)) + member.Request.TransitionTo(RenderRequestState.Executing); + + var cleanupFailures = new List(); + RejectNestedBindings(request); + + RenderRequestOwner owner = request.Request.Options.Owner; + int ownerCleanupStart = owner.CleanupFailures.Length; + owner.Cleanup(); + foreach (Exception failure in owner.CleanupFailures.Skip(ownerCleanupStart)) + AppendCleanupFailures(cleanupFailures, failure); + + try + { + // Close the session early so cleanup failures are finalized before the family completes; + // the enclosing owner may dispose it again because session disposal is idempotent. + _targets.Dispose(); + _targets.ThrowIfCleanupFailed(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + } + + if (cleanupFailures.Count != 0) + { + Exception primaryFailure = cleanupFailures[0]; + EnsureOwnerPrimary(owner, primaryFailure); + RecordAdditionalFailures(owner, cleanupFailures); + FailFamily(request); + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + + Statistics = default; + CompleteFamily(request); + } + + private static IReadOnlyList PublishCacheCapturesAtomically( + IReadOnlyList frames) + { + var seenCaches = new HashSet(ReferenceEqualityComparer.Instance); + foreach (FamilyExecutionFrame frame in frames) + frame.State.ValidateCacheCaptures(seenCaches); + + var transferredTargets = new List(); + var publications = new List(); + IReadOnlyList replacedStorageCleanupFailures; + try + { + foreach (FamilyExecutionFrame frame in frames) + frame.State.AppendCachePublications(publications, transferredTargets); + // Transfer only detaches targets from the renderer pool. No cache is observable until this + // batch reaches PublishAtomically's validated reference-assignment commit point. If preparation + // fails, the catch below disposes every detached target and leaves every node cache unchanged. + replacedStorageCleanupFailures = RenderNodeCache.PublishAtomically(publications); + } + catch (Exception ex) + { + ExceptionDispatchInfo primary = ExceptionDispatchInfo.Capture(ex); + var cleanupFailures = new List(); + for (int index = transferredTargets.Count - 1; index >= 0; index--) + { + try + { + transferredTargets[index].Dispose(); + } + catch (Exception cleanupFailure) + { + cleanupFailures.Add(cleanupFailure); + } + } + throw new FamilyCachePublicationException(primary, cleanupFailures); + } + + transferredTargets.Clear(); + foreach (FamilyExecutionFrame frame in frames) + frame.State.AcceptCacheCaptures(); + return replacedStorageCleanupFailures; + } + + private static RenderExecutionStatistics AggregateStatistics( + IEnumerable frames, + int nestedRootAcquisitions) + { + int shaderRuns = 0; + int shaderStages = 0; + int fusedRuns = 0; + int spirvRuns = 0; + int intermediateTargets = nestedRootAcquisitions; + int programCacheHits = 0; + int synchronizations = 0; + foreach (FamilyExecutionFrame frame in frames) + { + RenderExecutionStatistics statistics = frame.State.CreateStatistics(); + shaderRuns += statistics.ShaderRunExecutions; + shaderStages += statistics.ShaderStageExecutions; + fusedRuns += statistics.FusedShaderRunExecutions; + spirvRuns += statistics.SpirvShaderRunExecutions; + intermediateTargets += statistics.IntermediateTargetAcquisitions; + programCacheHits += statistics.ProgramCacheHits; + synchronizations += statistics.Synchronizations; + } + + return new RenderExecutionStatistics( + shaderRuns, + shaderStages, + fusedRuns, + spirvRuns, + intermediateTargets, + programCacheHits, + synchronizations); + } + +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Replay.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Replay.cs new file mode 100644 index 0000000000..e99453f9ca --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Replay.cs @@ -0,0 +1,659 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private void RecordSynchronization() + { + _synchronizations = checked(_synchronizations + 1); + } + + private bool TryReplayEngineSourceDirect( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + OpaqueRenderDescription description = + ((OpaqueRenderFragmentPayload)fragment.Payload!).Description; + if (description.DirectReplay is not { } replay + || !fragment.ContributesValuesToTarget + || _values.ContainsKey(fragment) + || fragment.Id is { } id + && (_cacheHits.ContainsKey(id) || _cacheMisses.ContainsKey(id)) + || _resourceUses.GetRemainingUseCount(fragment) != 1) + { + return false; + } + + bool replayAtExactReduction = description.DirectReplayAtExactIntegerReduction + && RenderScaleUtilities.IsExactIntegerReduction(destination.Density); + float replayScale = replayAtExactReduction + ? destination.Density + : fragment.EffectiveScale.Value; + if (!fragment.EffectiveScale.IsUnbounded + && (!replayAtExactReduction && fragment.EffectiveScale.Value != destination.Density + || !DirectRenderTargetGeometry.FromCanvas(destination).CanDrawPixelAligned( + fragment.Bounds, + replayScale, + PixelRect.FromRect(fragment.Bounds, replayScale).Size))) + { + return false; + } + + var inputs = new List(); + EffectiveScale outputSupply = fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(destination.Density) + : fragment.EffectiveScale; + try + { + foreach (RenderFragmentReference input in fragment.Inputs) + { + inputs.AddRange(Materialize( + input, + destination, + input.EffectiveScale.IsUnbounded ? outputSupply : null)); + } + + ExecuteReplayIsland( + fragment, + () => + { + var images = new List(); + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + try + { + token.RunAndComplete( + () => + { + IReadOnlyList executionInputs = CreateExecutionInputs( + token, + inputs, + requiresReadback: false, + images); + using (destination.BeginDirectExecution(token)) + { + replay(new EngineDirectRenderSession( + token, + destination, + executionInputs)); + } + }); + } + finally + { + foreach (SKImage image in images.AsEnumerable().Reverse()) + { + image.Dispose(); + } + } + }); + return true; + } + finally + { + foreach (RenderFragmentReference input in fragment.Inputs) + CompleteFragmentUse(input); + } + } + + private bool TryExecuteCompiledShaderRunDirect( + RenderFragmentReference fragment, + CompiledShaderRun run, + ImmediateCanvas destination) + { + // The Vulkan-native path consumes and produces pooled RGBA16F textures. Keep it behind the ordinary + // materialization boundary instead of recording GPU work directly into a Skia replay destination. + if (ShouldDeferDirectReplayToSpirv(run)) + return false; + + if (!ReferenceEquals(run.Output, fragment) + || !_roots.Contains(fragment) + || !fragment.ContributesValuesToTarget + || _values.ContainsKey(fragment) + || fragment.Id is { } id + && (_cacheHits.ContainsKey(id) || _cacheMisses.ContainsKey(id)) + || _resourceUses.GetRemainingUseCount(fragment) != 1) + { + return false; + } + + if (!DirectShaderRunPlanner.TryResolve( + fragment, + run, + _regions, + DirectRenderTargetGeometry.FromCanvas(destination), + out DirectShaderRunPlan directPlan)) + { + return false; + } + + EffectiveScale inputRequestScale = !run.Output.EffectiveScale.IsUnbounded + ? run.Output.EffectiveScale + : EffectiveScale.At(destination.Density); + IReadOnlyList inputs = Materialize( + run.Input, + destination, + run.Input.EffectiveScale.IsUnbounded ? inputRequestScale : null); + try + { + if (inputs.Count != 1) + { + if (inputs.Count == 0) + { + ExecutionIsland island = _executionLedger.Begin(fragment); + _executionLedger.Complete(island); + MarkExecutionSkipped(fragment); + return true; + } + + throw new InvalidOperationException( + "A directly executed compiled Shader run requires exactly one materialized input."); + } + + MaterializedRenderValue input = inputs[0]; + ExecuteReplayIsland( + fragment, + () => ExecuteCompiledShaderRunProgram( + run, + input, + directPlan.OutputBounds, + directPlan.RequiredRegion, + directPlan.OutputDeviceBounds, + directPlan.RasterBounds, + directPlan.Density, + shader => + { + using SKShader mapped = shader.WithLocalMatrix( + SKMatrix.CreateScaleTranslation( + 1f / directPlan.Density, + 1f / directPlan.Density, + directPlan.OutputDeviceBounds.X / directPlan.Density, + directPlan.OutputDeviceBounds.Y / directPlan.Density)); + using var paint = new SKPaint + { + Shader = mapped, + IsAntialias = false, + }; + destination.VerifyAccess(); + destination.Canvas.DrawRect(directPlan.RasterBounds.ToSKRect(), paint); + })); + return true; + } + finally + { + CompleteFragmentUse(run.Input); + } + } + + private bool TryReplayBuiltInSkiaFilterChainDirect( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + var chain = new List<( + RenderFragmentReference Fragment, + FilterEffectSegmentRenderFragmentPayload Payload)>(); + RenderFragmentReference input = fragment; + while (TryGetDirectSkiaFilterSegment(input, destination, out var payload)) + { + chain.Add((input, payload)); + input = input.Inputs[0]; + } + + if (chain.Count == 0) + return false; + + // Every link fuses into the one save layer, so only the fragment the walk stopped at can + // reach it as a buffer, and a buffer survives the copy only where the destination transform + // lands it on whole device pixels. An unbounded input is re-rasterized inside the layer. + if (!input.EffectiveScale.IsUnbounded + && (input.EffectiveScale.Value != destination.Density + || !CanCopyPixelsToDestination(chain[^1].Fragment.Bounds, destination))) + { + return false; + } + + IReadOnlyList? materializedInput = null; + if (input.ValueCardinality.Maximum is > 1 or null) + { + if (!input.ContributesValuesToTarget + || !CanCopyPixelsToDestination(fragment.Bounds, destination)) + { + return false; + } + + materializedInput = Materialize( + input, + destination, + input.EffectiveScale.IsUnbounded + ? EffectiveScale.At(destination.Density) + : null); + if (materializedInput.Count > 1) + return false; + } + + using var builder = new SKImageFilterBuilder(); + for (int segmentIndex = chain.Count - 1; segmentIndex >= 0; segmentIndex--) + { + foreach (IFEItem item in chain[segmentIndex].Payload.BoundsItems) + ((IFEItem_Skia)item).AcceptsDirect(builder); + } + + using var paint = builder.HasFilter() + ? new SKPaint { ImageFilter = builder.GetFilter() } + : null; + Rect replayedInputBounds = ResolveFragmentRequirement(input, input.Bounds); + Rect layerContentBounds = GetDirectFilterLayerBounds( + input.Bounds, + replayedInputBounds, + materializedInput is { Count: 1 } ? materializedInput[0].RasterBounds : null); + ExecuteSegment(chainIndex: 0); + return true; + + void ExecuteSegment(int chainIndex) + { + (RenderFragmentReference current, _) = chain[chainIndex]; + ExecuteReplayIsland( + current, + () => + { + int nextIndex = chainIndex + 1; + if (nextIndex < chain.Count) + { + ExecuteSegment(nextIndex); + CompleteFragmentUse(chain[nextIndex].Fragment); + } + else if (paint is not null) + { + using (destination.PushBlendMode(BlendMode.SrcOver)) + using (destination.PushTransform(Matrix.Identity)) + // Bound the layer to exactly what ReplayInput draws; filters must not sample + // unwritten portions of the input's semantic bounds as source pixels. + using (destination.PushFilterLayer(paint, layerContentBounds)) + { + ReplayInput(); + } + } + else + { + ReplayInput(); + } + }); + } + + void ReplayInput() + { + if (materializedInput is null) + { + Replay(input, destination); + return; + } + + if (materializedInput.Count == 1) + DrawValues(materializedInput, destination); + else + MarkExecutionSkipped(chain[^1].Fragment); + CompleteFragmentUse(input); + } + } + + private bool TryGetDirectSkiaFilterSegment( + RenderFragmentReference fragment, + ImmediateCanvas destination, + out FilterEffectSegmentRenderFragmentPayload payload) + { + payload = null!; + if (!fragment.ContributesValuesToTarget + || fragment.Inputs.Length != 1 + || _values.ContainsKey(fragment) + || fragment.Id is { } id + && (_cacheHits.ContainsKey(id) || _cacheMisses.ContainsKey(id)) + || _resourceUses.GetRemainingUseCount(fragment) != 1 + || !fragment.EffectiveScale.IsUnbounded + && fragment.EffectiveScale.Value != destination.Density + || fragment.Payload is not FilterEffectSegmentRenderFragmentPayload directPayload + || !directPayload.SupportsDirectReplay) + { + return false; + } + + payload = directPayload; + return true; + } + + /// + /// Reports whether a buffer covering lands on whole device pixels of + /// , so copying it costs nothing. + /// + private static bool CanCopyPixelsToDestination(Rect bounds, ImmediateCanvas destination) + => DirectRenderTargetGeometry.FromCanvas(destination).CanDrawPixelAligned( + bounds, + destination.Density, + PixelRect.FromRect(bounds, destination.Density).Size); + + private void DrawMaterializedFragment( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + IReadOnlyList values = Materialize( + fragment, + destination, + fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(destination.Density) + : null); + if (fragment.ContributesValuesToTarget) + DrawValues(values, destination); + } + + private void ExecuteReplayIsland(RenderFragmentReference fragment, Action execute) + { + ExecutionIsland island = _executionLedger.Begin(fragment); + execute(); + _executionLedger.Complete(island); + } + + public void DisposeNonCacheValues() + { + try + { + DisposeValues(static (_, isCapture) => !isCapture); + } + finally + { + _values.Clear(); + _valueReferences.Clear(); + _backdropCaptures.Clear(); + } + } + + public void RejectCacheCaptures() + { + try + { + DisposeValues(static (_, isCapture) => isCapture); + } + finally + { + _pendingCacheCaptures.Clear(); + _suppressedCacheCaptures.Clear(); + _cacheCaptureValues.Clear(); + } + } + + public void ValidateCacheCaptures(ISet seenCaches) + { + ArgumentNullException.ThrowIfNull(seenCaches); + if (PreviewAllocationDropObserved) + return; + if (_pendingCacheCaptures.Count + _suppressedCacheCaptures.Count + != _cacheResolution.MissCaptures.Length) + { + throw new InvalidOperationException( + "Every selected render-cache miss must materialize exactly one staged capture."); + } + + var byCandidate = _pendingCacheCaptures.ToDictionary(static item => item.Descriptor.CandidateId); + foreach (RenderCacheMissCapture descriptor in _cacheResolution.MissCaptures) + { + if (_suppressedCacheCaptures.Contains(descriptor.CandidateId)) + continue; + if (!byCandidate.ContainsKey(descriptor.CandidateId)) + throw new InvalidOperationException("A selected render-cache miss was not staged."); + RenderNodeCache cache = _cacheResolution.GetDecision(descriptor.CandidateId).Candidate.Cache + ?? throw new InvalidOperationException("A production cache capture has no node-cache owner."); + ObjectDisposedException.ThrowIf(cache.IsDisposed, cache); + if (!seenCaches.Add(cache)) + { + throw new InvalidOperationException( + "One request family cannot atomically publish two independent outputs to the same node cache."); + } + } + } + + public void AppendCachePublications( + ICollection publications, + ICollection transferredTargets) + { + ArgumentNullException.ThrowIfNull(publications); + ArgumentNullException.ThrowIfNull(transferredTargets); + if (PreviewAllocationDropObserved) + return; + var byCandidate = _pendingCacheCaptures.ToDictionary(static item => item.Descriptor.CandidateId); + foreach (RenderCacheMissCapture descriptor in _cacheResolution.MissCaptures) + { + if (_suppressedCacheCaptures.Contains(descriptor.CandidateId)) + continue; + PendingRenderCacheCapture pending = byCandidate[descriptor.CandidateId]; + RenderNodeCache cache = _cacheResolution.GetDecision(descriptor.CandidateId).Candidate.Cache!; + var cachedValues = new List(pending.Values.Count); + foreach (MaterializedRenderValue value in pending.Values) + { + RenderTarget target = value.TransferToAcceptedCache(); + transferredTargets.Add(target); + cachedValues.Add(new RenderNodeCachedValue( + target, + value.Bounds, + value.EffectiveScale, + value.DeviceBounds, + value.DeviceGridOffset) + { + CompleteBounds = value.CompleteBounds, + }); + _ownedValues.Remove(value); + _cacheCaptureValues.Remove(value); + } + + publications.Add(new RenderNodeCachePublication( + cache, + descriptor.Identity, + cachedValues)); + } + } + + public void AcceptCacheCaptures() + { + if (PreviewAllocationDropObserved) + { + RejectCacheCaptures(); + return; + } + + _pendingCacheCaptures.Clear(); + _suppressedCacheCaptures.Clear(); + } + + public void Dispose() + { + var failures = new List(); + try + { + DisposeValues(static (_, _) => true); + } + catch (AggregateException aggregate) + { + failures.AddRange(aggregate.Flatten().InnerExceptions); + } + catch (Exception ex) + { + failures.Add(ex); + } + finally + { + _pendingCacheCaptures.Clear(); + _suppressedCacheCaptures.Clear(); + _cacheCaptureValues.Clear(); + } + + try + { + RejectBuiltInBackdropCaptures(); + } + catch (AggregateException aggregate) + { + failures.AddRange(aggregate.Flatten().InnerExceptions); + } + catch (Exception ex) + { + failures.Add(ex); + } + + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + if (failures.Count > 1) + throw new AggregateException("One or more execution-state resources failed to dispose.", failures); + } + + private void DisposeValues(Func predicate) + { + List? failures = null; + foreach (MaterializedRenderValue value in _ownedValues.Reverse().ToArray()) + { + bool isCapture = _cacheCaptureValues.Contains(value); + if (!predicate(value, isCapture)) + continue; + + _ownedValues.Remove(value); + _cacheCaptureValues.Remove(value); + try + { + DisposeOwnedValue(value); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is null) + return; + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + throw new AggregateException("One or more render values failed to dispose.", failures); + } + + public RenderExecutionStatistics CreateStatistics() + => new( + _shaderRunExecutions, + _shaderStageExecutions, + _fusedShaderRunExecutions, + _spirvShaderRunExecutions, + _intermediateTargetAcquisitions, + _programCacheHits, + _synchronizations); + + public void ValidateExecutionCompleted(bool allowSkippedIslands) + => _executionLedger.ValidateCompleted( + allowSkippedIslands || PreviewAllocationDropObserved, + _regionEmptyIslands); + + private static bool IsRegionEmpty(ExecutionIsland island, RegionAnalysis regions) + { + foreach (RenderFragmentId fragmentId in island.Fragments) + { + if (!regions.FragmentRequirements.TryGetValue(fragmentId, out RequiredRegion requirement) + || !requirement.IsEmpty) + { + return false; + } + + if (regions.TargetAccessRequirements.TryGetValue( + fragmentId, + out RequiredRegion targetRequirement) + && !targetRequirement.IsEmpty) + { + return false; + } + } + + return true; + } + + } +} + +internal readonly record struct DirectRenderTargetGeometry(float Density, Matrix Transform) +{ + public static DirectRenderTargetGeometry FromCanvas(ImmediateCanvas canvas) + { + ArgumentNullException.ThrowIfNull(canvas); + return new DirectRenderTargetGeometry(canvas.Density, canvas.Transform); + } + + public static DirectRenderTargetGeometry FromRasterBounds(Rect rasterBounds, float density) + { + Matrix transform = Matrix.CreateScale(density, density) + .Prepend(Matrix.CreateTranslation(-rasterBounds.X, -rasterBounds.Y)); + return new DirectRenderTargetGeometry(density, transform); + } + + public bool CanDrawPixelAligned(Rect destination, float sourceDensity, PixelSize sourceSize) + => ImmediateCanvas.CanDrawPixelAligned( + destination, + sourceDensity, + sourceSize, + Density, + Transform); +} + +internal readonly record struct DirectShaderRunPlan( + Rect OutputBounds, + Rect RequiredRegion, + PixelRect OutputDeviceBounds, + Rect RasterBounds, + float Density); + +internal static class DirectShaderRunPlanner +{ + public static bool TryResolve( + RenderFragmentReference fragment, + CompiledShaderRun run, + RegionAnalysis regions, + DirectRenderTargetGeometry destination, + out DirectShaderRunPlan plan) + { + plan = default; + if (!ReferenceEquals(run.Output, fragment)) + return false; + + Rect outputBounds = run.Output.Bounds; + RenderFragmentReference requirementFragment = run.WholeSourceHead is null + ? run.Output + : run.Stages[0].Fragment; + Rect requiredRegion = regions.GetFragmentRequirement(requirementFragment).Resolve(outputBounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + return false; + + float requestedDensity = fragment.EffectiveScale.IsUnbounded + ? destination.Density + : fragment.EffectiveScale.Value; + float density = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + outputBounds, + requestedDensity); + if (density != destination.Density) + return false; + + PixelRect outputDeviceBounds = PixelRect.FromRect(requiredRegion, density); + Rect rasterBounds = outputDeviceBounds.ToRect(density); + if (!destination.CanDrawPixelAligned( + rasterBounds, + density, + outputDeviceBounds.Size)) + { + return false; + } + + plan = new DirectShaderRunPlan( + outputBounds, + requiredRegion, + outputDeviceBounds, + rasterBounds, + density); + return true; + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Shader.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Shader.cs new file mode 100644 index 0000000000..2fd564b2ed --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Shader.cs @@ -0,0 +1,890 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private IReadOnlyList ExecuteShader( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + => ExecuteOnDeviceGrid( + currentTarget, + () => ExecuteShaderCore(fragment, currentTarget, requestedScale)); + + private IReadOnlyList ExecuteShaderCore( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (fragment.Inputs.Length != 1) + throw new InvalidOperationException("A Shader fragment requires exactly one input stream."); + + var payload = (ShaderRenderFragmentPayload)fragment.Payload!; + ShaderDescription description = payload.Description; + EffectiveScale inputRequestScale = requestedScale + ?? (!fragment.EffectiveScale.IsUnbounded + ? fragment.EffectiveScale + : EffectiveScale.At(currentTarget.Density)); + IReadOnlyList inputs = Materialize( + fragment.Inputs[0], + currentTarget, + fragment.Inputs[0].EffectiveScale.IsUnbounded ? inputRequestScale : null); + var results = new List(inputs.Count); + bool executed = false; + try + { + foreach (MaterializedRenderValue input in inputs) + { + Rect outputBounds = description.Bounds.TransformBounds(input.CompleteBounds); + if (outputBounds.Width == 0 || outputBounds.Height == 0) + continue; + + Rect requiredRegion = ResolveFragmentRequirement(fragment, outputBounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + continue; + + float density = !fragment.EffectiveScale.IsUnbounded + ? fragment.EffectiveScale.Value + : inputRequestScale.Value; + density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Translate(_activeDeviceGridOffset), + density); + EffectiveScale outputScale = EffectiveScale.At(density); + MaterializedRenderValue output = CreateOwnedValue( + requiredRegion, + outputScale, + outputBounds, + allowPreviewDrop: true); + bool succeeded = false; + try + { + MaterializedRenderValue shaderInput = NormalizeSemanticShaderInput(input); + try + { + ExecuteShaderElement( + description, + shaderInput, + output, + outputBounds, + requiredRegion); + } + finally + { + if (!ReferenceEquals(shaderInput, input)) + ReleaseUnpublished(shaderInput); + } + + executed = true; + results.Add(output); + succeeded = true; + } + finally + { + if (!succeeded) + ReleaseUnpublished(output); + } + } + + if (!executed) + MarkExecutionSkipped(fragment); + return results; + } + catch + { + foreach (MaterializedRenderValue value in results) + ReleaseUnpublished(value); + throw; + } + finally + { + CompleteFragmentUse(fragment.Inputs[0]); + } + } + + private IReadOnlyList ExecuteCompiledShaderRun( + CompiledShaderRun run, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + => ExecuteOnDeviceGrid( + currentTarget, + () => ExecuteCompiledShaderRunCore(run, currentTarget, requestedScale)); + + private IReadOnlyList ExecuteCompiledShaderRunCore( + CompiledShaderRun run, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + Rect outputBounds = run.Output.Bounds; + if (outputBounds.Width == 0 || outputBounds.Height == 0) + { + CompleteFragmentUse(run.Input); + MarkExecutionSkipped(run.Output); + return []; + } + + RenderFragmentReference requirementFragment = run.WholeSourceHead is null + ? run.Output + : run.Stages[0].Fragment; + Rect requiredRegion = ResolveFragmentRequirement(requirementFragment, outputBounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + { + CompleteFragmentUse(run.Input); + MarkExecutionSkipped(run.Output); + return []; + } + + EffectiveScale outputRequestScale = !run.Output.EffectiveScale.IsUnbounded + ? run.Output.EffectiveScale + : requestedScale ?? EffectiveScale.At(currentTarget.Density); + EffectiveScale inputRequestScale = requestedScale ?? outputRequestScale; + IReadOnlyList inputs = Materialize( + run.Input, + currentTarget, + run.Input.EffectiveScale.IsUnbounded ? inputRequestScale : null); + if (inputs.Count == 0) + { + CompleteFragmentUse(run.Input); + MarkExecutionSkipped(run.Output); + return []; + } + if (inputs.Count != 1) + { + throw new InvalidOperationException( + "A compiled Shader run requires its declared single input to materialize exactly one value."); + } + + MaterializedRenderValue input = inputs[0]; + float density = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + outputBounds.Translate(_activeDeviceGridOffset), + outputRequestScale.Value); + MaterializedRenderValue output = CreateOwnedValue( + requiredRegion, + EffectiveScale.At(density), + outputBounds, + allowPreviewDrop: true, + initializeTarget: !ShouldMaterializeForSpirv(run)); + bool succeeded = false; + try + { + MaterializedRenderValue shaderInput = run.WholeSourceHead is null + ? input + : NormalizeSemanticShaderInput(input); + try + { + ExecuteCompiledShaderRunElement( + run, + shaderInput, + output, + outputBounds, + requiredRegion); + } + finally + { + if (!ReferenceEquals(shaderInput, input)) + ReleaseUnpublished(shaderInput); + } + + succeeded = true; + return [output]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(output); + CompleteFragmentUse(run.Input); + } + } + + private MaterializedRenderValue NormalizeSemanticShaderInput(MaterializedRenderValue input) + { + if (!input.PreserveLegacyRasterPlacement || CanUseAsSemanticShaderInput(input)) + return input; + + MaterializedRenderValue normalized = CreateNormalizedSemanticShaderInput( + input, + addRasterApron: false); + if (CanUseAsSemanticShaderInput(normalized)) + return normalized; + + // Global device-grid alignment can move a locally exact edge by a sub-pixel epsilon. + // Keep the legacy exact placement whenever it is sufficient, and add an apron only for + // the residual case where raster-local pixel rounding still falls outside the image. + ReleaseUnpublished(normalized); + return CreateNormalizedSemanticShaderInput(input, addRasterApron: true); + } + + private MaterializedRenderValue CreateNormalizedSemanticShaderInput( + MaterializedRenderValue input, + bool addRasterApron) + { + Rect physicalBounds = input.RasterBounds.Union(input.Bounds); + Rect alignedPhysicalBounds = physicalBounds.Translate(input.DeviceGridOffset); + float density = addRasterApron + ? RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget( + alignedPhysicalBounds, + input.EffectiveScale.Value) + : RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + alignedPhysicalBounds, + input.EffectiveScale.Value); + EffectiveScale normalizedScale = EffectiveScale.At(density); + PixelRect normalizedDeviceBounds = PixelRect.FromRect(physicalBounds, density); + if (addRasterApron) + normalizedDeviceBounds = RenderScaleUtilities.AddRasterApron(normalizedDeviceBounds); + MaterializedRenderValue normalized = CreateOwnedValue( + input.Bounds, + normalizedScale, + input.CompleteBounds, + physicalDeviceBounds: normalizedDeviceBounds, + deviceGridOffset: input.DeviceGridOffset, + allowPreviewDrop: true); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + normalized.DeviceBounds, + normalized.DeviceGridOffset, + normalized.EffectiveScale.Value); + using var canvas = CreateExecutorCanvas( + normalized.Target, + normalized.EffectiveScale.Value, + _options.MaxWorkingScale, + normalized.RasterBounds.Size, + _options.Intent, + normalized.DeviceBounds.Position); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.DrawRenderTargetScaledWithoutFlush(input.Target, input.RasterBounds); + } + + succeeded = true; + return normalized; + } + finally + { + if (!succeeded) + ReleaseUnpublished(normalized); + } + } + + private static bool CanUseAsSemanticShaderInput(MaterializedRenderValue input) + { + // Mirror RasterShaderMapping's semantic subset calculation. Continuous Rect + // containment is insufficient because PixelRect.FromRect rounds both edges outward. + Rect sourceRasterBounds = input.RasterBounds; + float sourceScale = input.EffectiveScale.Value; + Rect canonicalRasterBounds = input.DeviceBounds.ToRect(sourceScale); + PixelRect semanticSubset; + if (sourceRasterBounds == canonicalRasterBounds) + { + PixelRect semanticDeviceBounds = PixelRect.FromRect(input.Bounds, sourceScale); + semanticSubset = new PixelRect( + semanticDeviceBounds.X - input.DeviceBounds.X, + semanticDeviceBounds.Y - input.DeviceBounds.Y, + semanticDeviceBounds.Width, + semanticDeviceBounds.Height); + } + else + { + Vector deviceGridOffset = canonicalRasterBounds.Position - sourceRasterBounds.Position; + PixelRect semanticDeviceBounds = PixelRect.FromRect( + input.Bounds.Translate(deviceGridOffset), + sourceScale); + semanticSubset = new PixelRect( + semanticDeviceBounds.X - input.DeviceBounds.X, + semanticDeviceBounds.Y - input.DeviceBounds.Y, + semanticDeviceBounds.Width, + semanticDeviceBounds.Height); + } + + var imageBounds = new PixelRect(input.DeviceBounds.Size); + return imageBounds.Contains(semanticSubset); + } + + private void ExecuteCompiledShaderRunElement( + CompiledShaderRun run, + MaterializedRenderValue input, + MaterializedRenderValue output, + Rect outputBounds, + Rect requiredRegion) + { + if (TryExecuteSpirvShaderRun( + run, + input, + output, + outputBounds, + requiredRegion)) + { + return; + } + + ExecuteCompiledShaderRunProgram( + run, + input, + outputBounds, + requiredRegion, + output.DeviceBounds, + output.RasterBounds, + output.EffectiveScale.Value, + shader => + { + using var paint = new SKPaint { Shader = shader }; + using var canvas = CreateExecutorCanvas( + output.Target, + output.EffectiveScale.Value, + _options.MaxWorkingScale, + output.RasterBounds.Size, + _options.Intent, + output.DeviceBounds.Position); + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.Canvas.DrawRect( + SKRect.Create(output.Target.Width, output.Target.Height), + paint); + } + }); + } + + private bool TryExecuteSpirvShaderRun( + CompiledShaderRun run, + MaterializedRenderValue input, + MaterializedRenderValue output, + Rect outputBounds, + Rect requiredRegion) + { + if (_shaderBackendPreference == ShaderBackendPreference.Sksl) + return false; + + SpirvShaderLowering? lowering = run.Stages.Length == 1 + ? run.Stages[0].Description.SpirvLowering + : null; + if (lowering is null) + { + if (_shaderBackendPreference == ShaderBackendPreference.Spirv) + { + throw new InvalidOperationException( + "The compiled shader run cannot be lowered to the requested SPIR-V backend."); + } + return false; + } + if (!lowering.SupportsBitExactSkiaHandoff) + { + if (_shaderBackendPreference == ShaderBackendPreference.Spirv) + { + throw new InvalidOperationException( + "The requested SPIR-V lowering is bit-exact in its native RGBA16F target, but its output " + + "cannot be handed to the Skia compositor bit-exactly without a fence wait. " + + "Auto uses the SkSL lowering for this description."); + } + return false; + } + + IGraphicsContext? graphicsContext = GraphicsContextFactory.SharedContext; + ITexture2D? sourceTexture = input.Target.Texture; + ITexture2D? destinationTexture = output.Target.Texture; + bool compatible = graphicsContext is { Supports3DRendering: true } + && sourceTexture is not null + && destinationTexture is not null + && (_shaderBackendPreference == ShaderBackendPreference.Spirv + || !sourceTexture.RequiresSkiaFlushForBackendInterop) + && sourceTexture.Format == TextureFormat.RGBA16Float + && destinationTexture.Format == TextureFormat.RGBA16Float + && input.EffectiveScale == output.EffectiveScale + && input.DeviceBounds.Intersect(output.DeviceBounds) == output.DeviceBounds + && input.Bounds == outputBounds + && output.Bounds == requiredRegion; + if (!compatible) + { + if (_shaderBackendPreference == ShaderBackendPreference.Spirv) + { + throw new InvalidOperationException( + "The requested SPIR-V backend requires matching RGBA16F input and output footprints. " + + $"Source format/footprint: {sourceTexture?.Format} {input.DeviceBounds} {input.RasterBounds} {input.Bounds}; " + + $"destination format/footprint: {destinationTexture?.Format} {output.DeviceBounds} {output.RasterBounds} {output.Bounds}; " + + $"complete output/requirement: {outputBounds} {requiredRegion}."); + } + return false; + } + + ProgramCacheContextKey contextKey = + SpirvShaderProgramCache.CreateContextKey(_programCacheContext); + ProgramCacheLease lease; + try + { + lease = SpirvShaderProgramCache.Acquire( + _spirvProgramCache, + run.Stages[0].Description, + graphicsContext!, + contextKey); + } + catch (InvalidOperationException) when (_shaderBackendPreference == ShaderBackendPreference.Auto) + { + // The SkSL lowering is the compatibility contract. A native compile/resource failure must not + // change existing output, and the absent cache entry lets a later execution retry SPIR-V. + return false; + } + using (lease) + { + RenderExecutionSessionToken bindingToken = CreateExecutionSessionToken(); + SpirvPushConstants pushConstants = default; + PixelPoint sourceTexelOffset = output.DeviceBounds.Position - input.DeviceBounds.Position; + bindingToken.RunAndComplete( + () => + { + ShaderExecutionContext context = CreateCompiledShaderStageContext( + run, + run.Stages[0], + stageIndex: 0, + bindingToken, + input, + outputBounds, + requiredRegion, + output.DeviceBounds, + output.RasterBounds, + output.EffectiveScale.Value); + pushConstants = lowering.Bind( + run.Stages[0].Description, + context, + sourceTexelOffset); + }); + + input.Target.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + lease.Program.Execute(sourceTexture!, destinationTexture!, pushConstants); + + _shaderRunExecutions++; + _shaderStageExecutions++; + _spirvShaderRunExecutions++; + if (lease.IsCacheHit) + _programCacheHits++; + } + return true; + } + + private bool ShouldMaterializeForSpirv(CompiledShaderRun run) + { + if (_shaderBackendPreference == ShaderBackendPreference.Sksl + || run.Stages.Length != 1 + || run.Stages[0].Description.SpirvLowering is not { } lowering) + { + return false; + } + + return _shaderBackendPreference == ShaderBackendPreference.Spirv + || (lowering.SupportsBitExactSkiaHandoff + && GraphicsContextFactory.SharedContext is { Supports3DRendering: true }); + } + + private bool ShouldDeferDirectReplayToSpirv(CompiledShaderRun run) + => ShouldMaterializeForSpirv(run) + && (_shaderBackendPreference == ShaderBackendPreference.Spirv + || run.Input.HasOpaqueExternalWork); + + private void ExecuteCompiledShaderRunProgram( + CompiledShaderRun run, + MaterializedRenderValue input, + Rect outputBounds, + Rect requiredRegion, + PixelRect outputDeviceBounds, + Rect outputRasterBounds, + float outputScale, + Action draw) + { + ShaderEvaluationFrame frame = run.WholeSourceHead is null + ? ShaderEvaluationFrame.Destination(outputDeviceBounds, outputRasterBounds) + : RasterShaderMapping.CreateWholeSourceFrame( + outputBounds, + outputDeviceBounds, + outputRasterBounds, + outputScale); + using SKImage inputImage = input.Target.Value.Snapshot(); + ProgramCacheContextKey contextKey = CreateProgramContextKey(run.Program.Budget); + using ProgramCacheLease lease = AcquireProgram(run, contextKey); + using var uniforms = new SKRuntimeEffectUniforms(lease.Program.Effect); + using var runtimeChildren = new SKRuntimeEffectChildren(lease.Program.Effect); + var children = new List(); + RenderExecutionSessionToken bindingToken = CreateExecutionSessionToken(); + try + { + bindingToken.RunAndComplete( + () => + { + SKShader inputShader; + if (run.WholeSourceHead is { } head) + { + inputShader = RasterShaderMapping.CreateSemanticImageShader( + inputImage, + input.Target.RawValue.Context, + input.Bounds, + input.EffectiveScale.Value, + input.DeviceBounds, + input.RasterBounds, + outputScale, + frame.RasterBounds, + head.SourceTileMode); + } + else + { + bool interpolatedBitmap = run.Input.Kind == RenderFragmentKind.OpaqueSource + && ((OpaqueRenderFragmentPayload)run.Input.Payload!).Description + .DirectReplayAtExactIntegerReduction; + SKSamplingOptions sampling = interpolatedBitmap + ? RasterShaderMapping.SamplingFor( + input.EffectiveScale.Value, + outputScale) + : SKSamplingOptions.Default; + SKShaderTileMode tileMode = interpolatedBitmap + ? SKShaderTileMode.Clamp + : SKShaderTileMode.Decal; + inputShader = inputImage.ToShader( + tileMode, + tileMode, + sampling, + RasterShaderMapping.CreateLocalMatrix( + outputScale, + input.EffectiveScale.Value, + outputRasterBounds, + input.RasterBounds)); + } + children.Add(inputShader); + runtimeChildren[SkslSnippetMerger.SourceChildName] = inputShader; + + var stagesByMergedIndex = new Dictionary(); + var contextsByMergedIndex = new Dictionary(); + for (int index = 0; index < run.Program.Stages.Count; index++) + { + int mergedIndex = run.Program.Stages[index].StageIndex; + CompiledShaderStage stage = run.Stages[index]; + stagesByMergedIndex.Add(mergedIndex, stage); + contextsByMergedIndex.Add( + mergedIndex, + CreateCompiledShaderStageContext( + run, + stage, + index, + bindingToken, + input, + outputBounds, + requiredRegion, + outputDeviceBounds, + outputRasterBounds, + outputScale)); + } + + foreach (SkslMergedBindingLayout layout in run.Program.Bindings) + { + CompiledShaderStage stage = stagesByMergedIndex[layout.StageIndex]; + ShaderExecutionContext context = contextsByMergedIndex[layout.StageIndex]; + ShaderDescription description = stage.Description; + if (layout.Kind == SkslBindingKind.Uniform) + { + ShaderUniformBinding binding = description.Uniforms[layout.BindingIndex]; + SkslUniformDeclaration declaration = description.Source.Uniforms[binding.Name]; + ShaderUniformValue value = binding.Bind(declaration, context); + SetUniform(uniforms, layout.MergedName, declaration, value); + } + else + { + ShaderResourceBinding binding = description.Resources[layout.BindingIndex]; + SKShader child = binding.Bind(context); + children.Add(child); + runtimeChildren[layout.MergedName] = child; + } + } + }); + + using SKShader shader = lease.Program.Effect.ToShader(uniforms, runtimeChildren); + DrawInEvaluationFrame(shader, frame, draw); + + _shaderRunExecutions++; + _shaderStageExecutions = checked(_shaderStageExecutions + run.Stages.Length); + if (run.IsFused) + _fusedShaderRunExecutions++; + if (lease.IsCacheHit) + _programCacheHits++; + } + finally + { + foreach (SKShader child in children.AsEnumerable().Reverse()) + child.Dispose(); + } + } + + // Only valid while the run's source child is mapped against the same frame's raster bounds; the two + // shifts cancel, so the program keeps sampling the texel the destination pixel already resolved to. + private static void DrawInEvaluationFrame( + SKShader shader, + ShaderEvaluationFrame frame, + Action draw) + { + if (frame.FragmentOrigin == default) + { + draw(shader); + return; + } + + using SKShader rebased = shader.WithLocalMatrix( + SKMatrix.CreateTranslation(-frame.FragmentOrigin.X, -frame.FragmentOrigin.Y)); + draw(rebased); + } + + private ShaderExecutionContext CreateCompiledShaderStageContext( + CompiledShaderRun run, + CompiledShaderStage stage, + int stageIndex, + RenderExecutionSessionToken bindingToken, + MaterializedRenderValue runInput, + Rect runOutputBounds, + Rect runRequiredRegion, + PixelRect runOutputDeviceBounds, + Rect runOutputRasterBounds, + float runWorkingScale) + { + bool isFirst = stageIndex == 0; + bool isLast = stageIndex == run.Stages.Length - 1; + RenderFragmentReference fragment = stage.Fragment; + RenderFragmentReference fragmentInput = fragment.Inputs.Single(); + Rect inputBounds = isFirst + ? runInput.Bounds + : ResolveFragmentRequirement(fragmentInput, fragmentInput.Bounds); + Rect outputBounds = isLast ? runOutputBounds : fragment.Bounds; + Rect requiredRegion = isLast + ? runRequiredRegion + : ResolveFragmentRequirement(fragment, fragment.Bounds); + EffectiveScale inputEffectiveScale = isFirst + ? runInput.EffectiveScale + : EffectiveScale.At(runWorkingScale); + float workingScale = runWorkingScale; + Vector deviceGridOffset = new( + (runOutputDeviceBounds.X / workingScale) - runOutputRasterBounds.X, + (runOutputDeviceBounds.Y / workingScale) - runOutputRasterBounds.Y); + PixelRect deviceBounds; + if (stage.Description.Kind == ShaderDescriptionKind.WholeSource) + { + deviceBounds = RasterShaderMapping.CreateWholeSourceFrame( + outputBounds, + runOutputDeviceBounds, + runOutputRasterBounds, + workingScale) + .DeviceBounds; + } + else + { + deviceBounds = isLast + ? runOutputDeviceBounds + : PixelRect.FromRect( + requiredRegion.Translate(deviceGridOffset), + workingScale); + } + + Rect rasterBounds = deviceBounds + .ToRect(workingScale) + .Translate(-deviceGridOffset); + return new ShaderExecutionContext( + bindingToken, + inputBounds, + outputBounds, + requiredRegion, + deviceBounds, + rasterBounds, + inputEffectiveScale, + _options.OutputScale, + workingScale, + _options.MaxWorkingScale, + _options.Intent, + _options.Purpose); + } + + private ProgramCacheLease AcquireProgram( + CompiledShaderRun run, + ProgramCacheContextKey contextKey) + { + return _programCache.GetOrCreate( + run.Program, + contextKey, + CachedSkRuntimeEffect.Create); + } + + private ProgramCacheContextKey CreateProgramContextKey(SkslBackendBudget budget) + => SkRuntimeEffectProgramCache.CreateContextKey(_programCacheContext, budget); + + private ProgramCacheLease AcquireStandaloneProgram( + EffectTarget target, + string source) + { + ArgumentNullException.ThrowIfNull(target); + RenderTarget renderTarget = target.RenderTarget + ?? throw new InvalidOperationException( + "A legacy shader program requires a materialized execution destination."); + return AcquireStandaloneProgram(renderTarget, source); + } + + private ProgramCacheLease AcquireStandaloneProgram( + RenderTarget target, + string source) + { + ProgramCacheLease lease = + SkRuntimeEffectProgramCache.AcquireForDestination( + _programCache, + target, + source); + if (lease.IsCacheHit) + _programCacheHits++; + return lease; + } + + private static void SetUniform( + SKRuntimeEffectUniforms uniforms, + string name, + SkslUniformDeclaration declaration, + ShaderUniformValue value) + { + if (value.IsInteger) + { + uniforms[name] = declaration.ArrayExtent is null + && declaration.Type is "int" or "bool" + ? value.Integers![0] + : value.Integers!; + } + else + { + uniforms[name] = declaration.ArrayExtent is null + && declaration.Type is "float" or "half" + ? value.Floats![0] + : value.Floats!; + } + } + + private void ExecuteShaderElement( + ShaderDescription description, + MaterializedRenderValue input, + MaterializedRenderValue output, + Rect outputBounds, + Rect requiredRegion) + { + using SKImage inputImage = input.Target.Value.Snapshot(); + ShaderEvaluationFrame frame = description.Kind == ShaderDescriptionKind.WholeSource + ? RasterShaderMapping.CreateWholeSourceFrame( + outputBounds, + output.DeviceBounds, + output.RasterBounds, + output.EffectiveScale.Value) + : ShaderEvaluationFrame.Destination(output.DeviceBounds, output.RasterBounds); + string childName; + string programSource; + SKShaderTileMode tileMode; + if (description.Kind == ShaderDescriptionKind.CurrentPixel) + { + childName = "__beutl_src"; + tileMode = SKShaderTileMode.Decal; + programSource = $"uniform shader {childName};\n{description.Source.Text}\n" + + $"half4 main(float2 __beutl_coord) {{ return apply({childName}.eval(__beutl_coord)); }}\n"; + } + else + { + childName = "src"; + tileMode = description.SourceTileMode; + programSource = description.Source.Text; + } + + using ProgramCacheLease lease = + AcquireStandaloneProgram(output.Target, programSource); + using var uniforms = new SKRuntimeEffectUniforms(lease.Program.Effect); + using var runtimeChildren = new SKRuntimeEffectChildren(lease.Program.Effect); + var children = new List(); + RenderExecutionSessionToken bindingToken = CreateExecutionSessionToken(); + try + { + bindingToken.RunAndComplete( + () => + { + var context = new ShaderExecutionContext( + bindingToken, + input.Bounds, + outputBounds, + requiredRegion, + frame.DeviceBounds, + frame.RasterBounds, + input.EffectiveScale, + _options.OutputScale, + output.EffectiveScale.Value, + _options.MaxWorkingScale, + _options.Intent, + _options.Purpose); + foreach (ShaderUniformBinding binding in description.Uniforms) + { + if (!description.Source.Uniforms.TryGetValue( + binding.Name, + out SkslUniformDeclaration declaration)) + { + throw new InvalidOperationException( + $"Shader uniform '{binding.Name}' was not declared."); + } + + ShaderUniformValue value = binding.Bind(declaration, context); + SetUniform(uniforms, binding.Name, declaration, value); + } + + SKShader inputShader = RasterShaderMapping.CreateSemanticImageShader( + inputImage, + input.Target.RawValue.Context, + input.Bounds, + input.EffectiveScale.Value, + input.DeviceBounds, + input.RasterBounds, + output.EffectiveScale.Value, + frame.RasterBounds, + tileMode); + children.Add(inputShader); + runtimeChildren[childName] = inputShader; + + foreach (ShaderResourceBinding binding in description.Resources) + { + SKShader child = binding.Bind(context); + children.Add(child); + runtimeChildren[binding.Name] = child; + } + }); + + using SKShader shader = lease.Program.Effect.ToShader(uniforms, runtimeChildren); + DrawInEvaluationFrame( + shader, + frame, + rebased => + { + using var paint = new SKPaint { Shader = rebased }; + using var canvas = CreateExecutorCanvas( + output.Target, + output.EffectiveScale.Value, + _options.MaxWorkingScale, + output.RasterBounds.Size, + _options.Intent, + output.DeviceBounds.Position); + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.Canvas.DrawRect( + SKRect.Create(output.Target.Width, output.Target.Height), + paint); + } + }); + } + finally + { + foreach (SKShader child in children.AsEnumerable().Reverse()) + child.Dispose(); + } + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Target.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Target.cs new file mode 100644 index 0000000000..21eec992cf --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Target.cs @@ -0,0 +1,717 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private void ExecuteTargetCommand( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + var payload = (TargetCommandRenderFragmentPayload)fragment.Payload!; + TargetCommandDescription description = payload.Description; + var values = new List(); + var inputReadbacks = new List(); + var inputRanges = new List(fragment.Inputs.Length); + for (int inputIndex = 0; inputIndex < fragment.Inputs.Length; inputIndex++) + { + IReadOnlyList inputValues = Materialize( + fragment.Inputs[inputIndex], + destination); + RenderInputReadback readback = payload.InputReadbacks[inputIndex]; + readback.ValidateRuntimeCount( + fragment.Inputs[inputIndex].ValueCardinality, + inputValues.Count); + inputRanges.Add(new RenderExecutionInputRange(values.Count, inputValues.Count)); + for (int valueIndex = 0; valueIndex < inputValues.Count; valueIndex++) + { + values.Add(inputValues[valueIndex]); + inputReadbacks.Add(readback.RequiresValue(valueIndex)); + } + } + + var images = new List(values.Count); + Bitmap? targetSnapshot = null; + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + try + { + token.RunAndComplete( + () => + { + IReadOnlyList inputs = CreateTargetCommandExecutionInputs( + token, + values, + inputReadbacks, + images); + Rect affectedBounds = ResolveTargetRegion( + description.AffectedRegion, + fragment, + destination); + Rect requiredRegion = ResolveTargetAccessRequirement(fragment, affectedBounds); + if (description.Access == TargetAccess.Readback + && (requiredRegion.Width == 0 || requiredRegion.Height == 0)) + { + // A readback-only root has no pixel-writing output requirement, but its + // authored callback still consumes the immutable preceding target token. + requiredRegion = affectedBounds; + } + CallbackCanvasCapability capability = description.AffectedRegion.Kind switch + { + TargetRegionKind.Empty => CallbackCanvasCapability.TargetCommandEmpty, + TargetRegionKind.Region => CallbackCanvasCapability.TargetCommandRegion, + TargetRegionKind.Full => CallbackCanvasCapability.TargetCommandFull, + _ => throw new InvalidOperationException("The target-command region is uninitialized."), + }; + RenderCallbackCanvas callbackCanvas = RenderCallbackCanvas.CreateTargetAttached( + token, + requiredRegion, + destination, + capability); + var session = new TargetCommandSession( + token, + inputs, + inputRanges, + affectedBounds, + requiredRegion, + _options.Intent, + _options.Purpose, + callbackCanvas, + description.Resources, + description.Access == TargetAccess.Readback, + description.Access == TargetAccess.Readback + ? () => TakeTargetSnapshot(ref targetSnapshot) + : null); + if (description.Access == TargetAccess.Readback) + { + RecordSynchronization(); + targetSnapshot = SnapshotTarget(destination, requiredRegion); + } + description.Execute(session); + session.ValidateCompletion(); + }); + } + finally + { + targetSnapshot?.Dispose(); + foreach (SKImage image in images) + image.Dispose(); + foreach (RenderFragmentReference input in fragment.Inputs) + CompleteFragmentUse(input); + } + } + + private void ExecuteRawTargetCommand( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + RawTargetCommandDescription description = + ((RawTargetCommandRenderFragmentPayload)fragment.Payload!).Description; + using ImmediateCanvas view = destination.CreateExecutionView(); + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + token.RunAndComplete( + () => + { + token.UseRawCanvas( + view, + canvas => + { + description.Execute(new RawTargetCommandSession( + token, + canvas, + _options.Intent, + _options.Purpose, + description.Resources)); + }); + }); + } + + private void ExecuteTargetScope( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + TargetScopeDescription description = + ((TargetScopeRenderFragmentPayload)fragment.Payload!).Description; + RenderFragmentReference input = fragment.Inputs.Single(); + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + token.RunAndComplete( + () => + { + Rect? parentDomain = fragment.Id is { } id + && _resolvedParentScopeDomains.TryGetValue(id, out Rect resolvedParent) + ? resolvedParent + : _options.TargetDomain; + Rect callbackBounds = TargetWriteMetadataResolver.Resolve(fragment, parentDomain) + ?? fragment.Bounds; + Rect requiredRegion = ResolveFragmentRequirement(fragment, callbackBounds); + RenderCallbackCanvas callbackCanvas = RenderCallbackCanvas.CreateTargetAttached( + token, + requiredRegion, + destination, + CallbackCanvasCapability.TargetScope); + var session = new TargetScopeSession( + token, + fragment.Bounds, + requiredRegion, + _options.Intent, + _options.Purpose, + callbackCanvas, + description.Resources, + canvas => Replay(input, canvas)); + description.Execute(session); + session.ValidateCompletion(); + }); + } + + private IReadOnlyList MaterializeValueReplayMap( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (fragment.Inputs.Length != 1 + || !fragment.ValueCardinality.Equals(RenderValueCardinality.Single)) + { + throw new InvalidOperationException( + "A value replay map requires exactly one single-value input stream."); + } + + Rect requiredRegion = ResolveFragmentRequirement(fragment, fragment.Bounds); + if (requiredRegion.Width == 0 || requiredRegion.Height == 0) + { + CompleteFragmentUse(fragment.Inputs[0]); + MarkExecutionSkipped(fragment); + return []; + } + + float requestedDensity = requestedScale?.Value + ?? (fragment.EffectiveScale.IsUnbounded + ? currentTarget.Density + : fragment.EffectiveScale.Value); + float density = RenderMaterializationDensityPolicy.Clamp( + fragment, + requestedDensity); + density = ClampToActiveDeviceGrid( + fragment.Bounds, + EffectiveScale.At(density), + requiresRasterApron: true) + .Value; + EffectiveScale scale = EffectiveScale.At(density); + PixelRect deviceBounds = RenderScaleUtilities.AddRasterApron( + PixelRect.FromRect(requiredRegion, density)); + MaterializedRenderValue output = CreateOwnedValue( + requiredRegion, + scale, + fragment.Bounds, + deviceBounds, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + output.DeviceBounds, + output.DeviceGridOffset, + density); + using var canvas = CreateExecutorCanvas( + output.Target, + density, + _options.MaxWorkingScale, + output.RasterBounds.Size, + _options.Intent, + output.DeviceBounds.Position); + canvas.Clear(); + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + ExecuteTargetScope(fragment, canvas); + } + + succeeded = true; + return [output]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(output); + } + } + + private void ExecuteRawTargetScope( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + RawTargetScopeDescription description = + ((RawTargetScopeRenderFragmentPayload)fragment.Payload!).Description; + RenderFragmentReference input = fragment.Inputs.Single(); + using ImmediateCanvas view = destination.CreateExecutionView(); + RenderExecutionSessionToken token = CreateExecutionSessionToken(); + token.RunAndComplete( + () => + { + token.UseRawCanvas( + view, + canvas => + { + var session = new RawTargetScopeSession( + token, + canvas, + fragment.Bounds, + _options.Intent, + _options.Purpose, + description.Resources, + replayCanvas => replayCanvas.ReplayTargetScopeInput( + nested => Replay(input, nested))); + description.Execute(session); + session.ValidateCompletion(); + }); + }); + } + + private IReadOnlyList CreateExecutionInputs( + RenderExecutionSessionToken token, + IReadOnlyList values, + bool requiresReadback, + List images) + { + var inputs = new List(values.Count); + foreach (MaterializedRenderValue value in values) + { + SKImage image = value.Target.Value.Snapshot(); + images.Add(image); + Func? createSnapshot = requiresReadback + ? () => SnapshotInputForReadback(value) + : null; + inputs.Add(new RenderExecutionInput( + token, + value.Bounds, + value.EffectiveScale, + value.DeviceBounds, + value.RasterBounds, + image, + createSnapshot, + requiresReadback)); + } + + return inputs; + } + + private IReadOnlyList CreateTargetCommandExecutionInputs( + RenderExecutionSessionToken token, + IReadOnlyList values, + IReadOnlyList inputReadbacks, + List images) + { + if (inputReadbacks.Count != values.Count) + throw new InvalidOperationException("Target-command input readback planning did not reconcile."); + + var inputs = new List(values.Count); + for (int index = 0; index < values.Count; index++) + { + MaterializedRenderValue value = values[index]; + SKImage image = value.Target.Value.Snapshot(); + images.Add(image); + bool requiresReadback = inputReadbacks[index]; + Func? createSnapshot = requiresReadback + ? () => SnapshotInputForReadback(value) + : null; + inputs.Add(new RenderExecutionInput( + token, + value.Bounds, + value.EffectiveScale, + value.DeviceBounds, + value.RasterBounds, + image, + createSnapshot, + requiresReadback)); + } + + return inputs; + } + + private Bitmap SnapshotInputForReadback(MaterializedRenderValue value) + { + RecordSynchronization(); + return value.Target.Snapshot(); + } + + private Rect ResolveFragmentRequirement( + RenderFragmentReference fragment, + Rect completeBounds) + => _regions.GetFragmentRequirement(fragment) + .Resolve(completeBounds) + .Intersect(completeBounds); + + private Rect ResolveTargetAccessRequirement( + RenderFragmentReference fragment, + Rect completeBounds) + => _regions.GetTargetAccessRequirement(fragment) + .Resolve(completeBounds) + .Intersect(completeBounds); + + private Rect ResolveTargetRegion( + TargetRegion region, + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + return region.Kind switch + { + TargetRegionKind.Empty => Rect.Empty, + TargetRegionKind.Region => region.Value, + TargetRegionKind.Full + when fragment.Id is { } id + && _resolvedAccessDomains.TryGetValue(id, out Rect domain) => domain, + TargetRegionKind.Full when _options.TargetDomain is { } domain => domain, + TargetRegionKind.Full => new Rect(default, destination.LogicalSize), + _ => throw new InvalidOperationException("The target region is uninitialized."), + }; + } + + private static Bitmap TakeTargetSnapshot(ref Bitmap? snapshot) + { + Bitmap result = snapshot + ?? throw new InvalidOperationException("The target snapshot was already consumed."); + snapshot = null; + return result; + } + + private static Bitmap SnapshotTarget( + ImmediateCanvas destination, + Rect requiredRegion) + { + using RenderTarget target = RenderTarget.GetRenderTarget(destination); + using Bitmap snapshot = target.Snapshot(); + PixelRect targetBounds = new(0, 0, snapshot.Width, snapshot.Height); + PixelRect sourceRegion = PixelRect.FromRect( + requiredRegion.TransformToAABB(destination.Transform), + 1) + .Intersect(targetBounds); + if (sourceRegion.Width == 0 || sourceRegion.Height == 0) + { + throw new InvalidOperationException( + "A target readback requirement must resolve to a non-empty region on the current target."); + } + + return snapshot.ExtractSubset(sourceRegion); + } + + private IReadOnlyList MaterializeLayer( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget, + EffectiveScale? requestedScale) + { + if (_values.TryGetValue(fragment, out IReadOnlyList? existing)) + return existing; + + Rect domain = ((LayerRenderFragmentPayload)fragment.Payload!).Domain + ?? fragment.Bounds; + EffectiveScale scale = ClampToActiveDeviceGrid( + fragment.Bounds, + requestedScale ?? ResolveConcreteScale(fragment)); + Vector? deviceGridOffset = RequiresLocalDestructiveDeviceGrid(fragment) + ? default(Vector) + : null; + MaterializedRenderValue value = CreateOwnedValue( + domain, + scale, + deviceGridOffset: deviceGridOffset, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + bool succeeded = false; + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + value.DeviceBounds, + value.DeviceGridOffset, + scale.Value); + using (var canvas = CreateExecutorCanvas( + value.Target, + scale.Value, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position)) + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + if (fragment.Inputs.Length == 1 + && IsMatchingTargetLayerScope(fragment.Inputs[0], canvas, domain)) + { + // The finite Layer already provides the target-layer isolation surface. + ReplayTargetLayerScopeIntoExistingLayer( + fragment.Inputs[0], + canvas, + currentTarget); + } + else + { + int backdropSourceCount = _backdropSources.Count; + if (backdropSourceCount != 0) + _backdropSources.Add(currentTarget); + try + { + foreach (RenderFragmentReference input in fragment.Inputs) + Replay(input, canvas); + } + finally + { + RemoveBackdropSources(backdropSourceCount); + } + } + } + + succeeded = true; + return [value]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(value); + } + } + + private IReadOnlyList CaptureTarget( + RenderFragmentReference fragment, + ImmediateCanvas currentTarget) + { + TargetCaptureDescription description = fragment.Payload switch + { + TargetCaptureRenderFragmentPayload payload => payload.Description, + BuiltInBackdropCaptureRenderFragmentPayload payload => payload.Description, + _ => throw new InvalidOperationException("The target-capture payload is invalid."), + }; + Rect bounds = fragment.Kind == RenderFragmentKind.BuiltInBackdropCapture + ? fragment.Bounds + : description.Bounds; + EffectiveScale scale = description.Scale.PreservesTargetSupply + ? EffectiveScale.At(DeviceGridAlignment.ResolveLocalDensity(currentTarget)) + : ResolveConcreteScale(fragment); + scale = ClampToActiveDeviceGrid(bounds, scale); + MaterializedRenderValue value = CreateOwnedValue( + bounds, + scale, + allowPreviewDrop: _previewDropEligibleMaterializations.Contains(fragment)); + bool succeeded = false; + try + { + _afterCaptureAllocation?.Invoke(fragment.Kind); + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + value.DeviceBounds, + value.DeviceGridOffset, + scale.Value); + using (var canvas = CreateExecutorCanvas( + value.Target, + scale.Value, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position)) + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + canvas.ClipRect(bounds); + bool capturesBackingTarget = fragment.Id is { } fragmentId + && _regions.BackingTargetBackdropCaptures.Contains(fragmentId); + if (fragment.Kind == RenderFragmentKind.BuiltInBackdropCapture) + { + foreach (ImmediateCanvas backdropSource in _backdropSources) + DrawTargetIntoCapture(backdropSource, canvas, capturesBackingTarget); + } + + DrawTargetIntoCapture(currentTarget, canvas, capturesBackingTarget); + } + + succeeded = true; + return [value]; + } + finally + { + if (!succeeded) + ReleaseUnpublished(value); + } + } + + private void ReplayTargetLayerScope( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + Rect domain = ResolveTargetLayerScopeDomain(fragment, destination); + if (domain.Width == 0 || domain.Height == 0) + { + MarkExecutionSkipped(fragment); + return; + } + + EffectiveScale scale = EffectiveScale.At(destination.Density); + Vector deviceGridOffset = RequiresLocalDestructiveDeviceGrid(fragment) + ? default + : DeviceGridAlignment.ResolveLogicalOffset(destination); + scale = ClampToDeviceGrid(domain, scale, deviceGridOffset); + MaterializedRenderValue value = CreateOwnedValue( + domain, + scale, + deviceGridOffset: deviceGridOffset); + try + { + Vector rasterTranslation = DeviceGridAlignment.ResolveRasterTranslation( + value.DeviceBounds, + value.DeviceGridOffset, + scale.Value); + using (var canvas = CreateExecutorCanvas( + value.Target, + scale.Value, + _options.MaxWorkingScale, + value.RasterBounds.Size, + _options.Intent, + value.DeviceBounds.Position)) + using (canvas.PushTransform(Matrix.CreateTranslation( + rasterTranslation.X, + rasterTranslation.Y))) + { + int backdropSourceCount = _backdropSources.Count; + _backdropSources.Add(destination); + try + { + foreach (RenderFragmentReference input in fragment.Inputs) + Replay(input, canvas); + } + finally + { + RemoveBackdropSources(backdropSourceCount); + } + } + + DrawValue(value, destination); + } + finally + { + ReleaseUnpublished(value); + } + } + + private Rect ResolveTargetLayerScopeDomain( + RenderFragmentReference fragment, + ImmediateCanvas destination) + { + TargetRegion region = ((TargetLayerScopeRenderFragmentPayload)fragment.Payload!).Region; + return region.Kind switch + { + TargetRegionKind.Empty => Rect.Empty, + TargetRegionKind.Region => region.Value, + TargetRegionKind.Full + when fragment.Id is { } id + && _resolvedScopeDomains.TryGetValue(id, out Rect resolved) => resolved, + TargetRegionKind.Full when _options.TargetDomain is { } targetDomain => targetDomain, + TargetRegionKind.Full => new Rect(default, destination.LogicalSize), + _ => throw new InvalidOperationException("The target-layer region is uninitialized."), + }; + } + + private bool IsMatchingTargetLayerScope( + RenderFragmentReference fragment, + ImmediateCanvas destination, + Rect domain) + => fragment.Kind == RenderFragmentKind.TargetLayerScope + && ResolveTargetLayerScopeDomain(fragment, destination) == domain; + + private void ReplayTargetLayerScopeIntoExistingLayer( + RenderFragmentReference scope, + ImmediateCanvas destination, + ImmediateCanvas backdropSource) + { + ExecuteReplayIsland( + scope, + () => + { + int backdropSourceCount = _backdropSources.Count; + _backdropSources.Add(backdropSource); + try + { + foreach (RenderFragmentReference input in scope.Inputs) + Replay(input, destination); + } + finally + { + RemoveBackdropSources(backdropSourceCount); + } + }); + } + + private static void DrawTargetIntoCapture( + ImmediateCanvas sourceCanvas, + ImmediateCanvas captureCanvas, + bool capturesBackingTarget) + { + using RenderTarget source = RenderTarget.GetRenderTarget(sourceCanvas); + if (capturesBackingTarget) + { + float sourceDensity = sourceCanvas.SurfaceDensity; + captureCanvas.DrawRenderTargetScaledWithoutFlush( + source, + new Rect( + sourceCanvas.DeviceOrigin.X / sourceDensity, + sourceCanvas.DeviceOrigin.Y / sourceDensity, + source.Width / sourceDensity, + source.Height / sourceDensity)); + return; + } + + // Target-local captures are authored in the target's logical space, so place the surface + // through the inverse of its transform. A singular transform has no visible local pixels. + if (!DeviceGridAlignment.TryResolveSurfaceToLogical(sourceCanvas, out Matrix toLocal)) + return; + + using (captureCanvas.PushTransform(toLocal)) + { + captureCanvas.DrawRenderTargetScaledWithoutFlush( + source, + new Rect(0, 0, source.Width, source.Height)); + } + } + + private void RemoveBackdropSources(int count) + { + if (_backdropSources.Count > count) + _backdropSources.RemoveRange(count, _backdropSources.Count - count); + } + + private static bool RequiresLocalDestructiveDeviceGrid(RenderFragmentReference fragment) + { + if (fragment.Kind == RenderFragmentKind.Blend + && fragment.Payload is BlendRenderFragmentPayload payload + && BlendModeRenderNode.RequiresFullTargetRegion(payload.BlendMode)) + { + return payload.BlendMode switch + { + BlendMode.DstIn => fragment.Inputs.Any(RequiresLocalDestructiveDeviceGrid), + BlendMode.DstOut => !CanReplayWithDirectDstOut(fragment.Inputs.Single()), + _ => true, + }; + } + + return fragment.Inputs.Any(RequiresLocalDestructiveDeviceGrid); + } + + private static bool CanReplayWithDirectDstOut(RenderFragmentReference fragment) + { + return fragment.Kind switch + { + RenderFragmentKind.OpaqueSource + => ((OpaqueRenderFragmentPayload)fragment.Payload!).Description.SupportsDirectDstOut, + RenderFragmentKind.TargetScope + => fragment.Inputs.All(CanReplayWithDirectDstOut), + RenderFragmentKind.Opacity + => ((OpacityRenderFragmentPayload)fragment.Payload!).Opacity == 1f + && fragment.Inputs.All(CanReplayWithDirectDstOut), + _ => false, + }; + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Values.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Values.cs new file mode 100644 index 0000000000..1b2b2750b1 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.Values.cs @@ -0,0 +1,299 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +internal sealed partial class RenderRequestExecutor +{ + private sealed partial class RenderRequestExecutionState + { + private void AddValueReferences(IEnumerable values) + { + foreach (MaterializedRenderValue value in values) + { + _valueReferences.TryGetValue(value, out int references); + _valueReferences[value] = checked(references + 1); + } + } + + private void ReleaseValueReference(MaterializedRenderValue value) + { + if (!_valueReferences.TryGetValue(value, out int references) || references <= 0) + throw new InvalidOperationException("A render value reference was released more than once."); + + if (references > 1) + { + _valueReferences[value] = references - 1; + return; + } + + _valueReferences.Remove(value); + if (!_cacheCaptureValues.Contains(value)) + ReleaseUnpublished(value); + } + + private void CompleteFragmentUse(RenderFragmentReference fragment) + { + if (!_resourceUses.CompleteUse(fragment)) + return; + if (!_values.Remove(fragment, out IReadOnlyList? values)) + return; + + foreach (MaterializedRenderValue value in values) + ReleaseValueReference(value); + } + + private void MarkExecutionSkipped(RenderFragmentReference fragment) + { + if (fragment.Id is { } id) + _skippedExecutionSubjects.Add(id); + } + + private static void AddResolvedDomain( + Dictionary domains, + RenderFragmentId fragmentId, + Rect domain) + { + if (domains.TryGetValue(fragmentId, out Rect existing) && existing != domain) + { + throw new InvalidOperationException( + "One target-effect fragment cannot execute in two different target domains."); + } + + domains[fragmentId] = domain; + } + + private T ExecuteOnDeviceGrid( + ImmediateCanvas currentTarget, + Func execute, + bool normalizeGridPhase = false) + { + Vector previousOffset = _activeDeviceGridOffset; + bool previousNormalized = _deviceGridPhaseNormalized; + // A custom effect's buffers must start on the pixel its logical origin names, because the + // effect does its own device-pixel arithmetic against them. A grid whose phase is + // fractional cannot deliver that, so the flush would resample the input onto the phase + // instead, and half a pixel of edge coverage is lost before the effect ever sees it. + // The requirement is inherited: the input is rasterized in whatever frame materializes it, + // which for a chained segment is a nested frame that re-derives the grid from this canvas. + bool normalized = previousNormalized || normalizeGridPhase; + Vector offset = DeviceGridAlignment.ResolveLogicalOffset(currentTarget); + if (normalized) + offset -= DeviceGridAlignment.NormalizePhase(offset, currentTarget.Density); + _activeDeviceGridOffset = offset; + _deviceGridPhaseNormalized = normalized; + try + { + return execute(); + } + finally + { + _activeDeviceGridOffset = previousOffset; + _deviceGridPhaseNormalized = previousNormalized; + } + } + + private MaterializedRenderValue CreateOwnedValue( + Rect bounds, + EffectiveScale scale, + Rect? completeBounds = null, + PixelRect? physicalDeviceBounds = null, + Vector? deviceGridOffset = null, + bool physicalDeviceBoundsAreAligned = false, + bool allowPreviewDrop = false, + bool initializeTarget = true) + { + if (scale.IsUnbounded) + throw new InvalidOperationException("An allocated render value requires a concrete density."); + Vector gridOffset = deviceGridOffset ?? _activeDeviceGridOffset; + PixelRect semanticDeviceBounds = PixelRect.FromRect( + bounds.Translate(gridOffset), + scale.Value); + PixelRect deviceBounds; + if (physicalDeviceBounds is not { } requestedPhysicalBounds) + { + deviceBounds = semanticDeviceBounds; + } + else if (physicalDeviceBoundsAreAligned || gridOffset == default) + { + deviceBounds = requestedPhysicalBounds; + } + else + { + PixelRect localSemanticBounds = PixelRect.FromRect(bounds, scale.Value); + int leftApron = localSemanticBounds.X - requestedPhysicalBounds.X; + int topApron = localSemanticBounds.Y - requestedPhysicalBounds.Y; + int rightApron = requestedPhysicalBounds.Right - localSemanticBounds.Right; + int bottomApron = requestedPhysicalBounds.Bottom - localSemanticBounds.Bottom; + deviceBounds = new PixelRect( + semanticDeviceBounds.X - leftApron, + semanticDeviceBounds.Y - topApron, + semanticDeviceBounds.Width + leftApron + rightApron, + semanticDeviceBounds.Height + topApron + bottomApron); + } + if (deviceBounds.Width <= 0 + || deviceBounds.Height <= 0 + || deviceBounds.X > semanticDeviceBounds.X + || deviceBounds.Y > semanticDeviceBounds.Y + || deviceBounds.Right < semanticDeviceBounds.Right + || deviceBounds.Bottom < semanticDeviceBounds.Bottom) + { + throw new ArgumentException( + "An allocated render value's physical device bounds must contain its semantic bounds.", + nameof(physicalDeviceBounds)); + } + RenderTargetLease? lease = allowPreviewDrop + ? _targets.TryAcquire(deviceBounds.Size) + : _targets.Acquire(deviceBounds.Size); + if (lease is null) + throw new PreviewAllocationDropException(); + _intermediateTargetAcquisitions++; + bool succeeded = false; + try + { + if (initializeTarget) + { + if (!lease.Target.HasTransparentContents) + lease.Target.ClearToTransparent(); + } + var value = new MaterializedRenderValue( + lease, + bounds, + scale, + deviceBounds, + gridOffset, + completeBounds); + _ownedValues.Add(value); + succeeded = true; + return value; + } + finally + { + if (!succeeded) + lease.Dispose(); + } + } + + private void ReleaseUnpublished(MaterializedRenderValue value) + { + if (_ownedValues.Remove(value)) + DisposeOwnedValue(value); + } + + private void DisposeOwnedValue(MaterializedRenderValue value) + { + value.Dispose(); + } + + private EffectiveScale ResolveConcreteScale(RenderFragmentReference fragment) + { + float scale = fragment.EffectiveScale.IsUnbounded + ? RenderScaleUtilities.ResolveWorkingScale( + fragment.Inputs.Select(static input => input.EffectiveScale).ToArray(), + _options.OutputScale, + _options.MaxWorkingScale) + : fragment.EffectiveScale.Value; + scale = RenderMaterializationDensityPolicy.Clamp(fragment, scale); + return EffectiveScale.At(scale); + } + + private EffectiveScale ClampToActiveDeviceGrid( + Rect completeBounds, + EffectiveScale scale, + bool requiresRasterApron = false) + { + return ClampToDeviceGrid( + completeBounds, + scale, + _activeDeviceGridOffset, + requiresRasterApron); + } + + private EffectiveScale ClampToDeviceGrid( + Rect completeBounds, + EffectiveScale scale, + Vector deviceGridOffset, + bool requiresRasterApron = false) + { + Rect alignedBounds = completeBounds.Translate(deviceGridOffset); + float density = requiresRasterApron + ? RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget( + alignedBounds, + scale.Value) + : RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget( + alignedBounds, + scale.Value); + return EffectiveScale.At(density); + } + + private static void DrawValues( + IReadOnlyList values, + ImmediateCanvas destination) + { + foreach (MaterializedRenderValue value in values) + DrawValue(value, destination); + } + + private static void DrawValue( + MaterializedRenderValue value, + ImmediateCanvas destination) + { + if (value.PreserveLegacyRasterPlacement + && value.EffectiveScale.Value == 1f + && destination.Density == 1f) + { + destination.DrawRenderTarget(value.Target, value.RasterBounds.Position); + } + else + { + destination.DrawRenderTargetScaledWithoutFlush(value.Target, value.RasterBounds); + } + } + + private static void ValidateOutputCount( + RenderValueCardinality cardinality, + int count) + { + if (count < cardinality.Minimum + || (cardinality.Maximum is { } maximum && count > maximum)) + { + throw new InvalidOperationException( + $"The deferred callback published {count} values outside its declared cardinality " + + $"[{cardinality.Minimum}, {cardinality.Maximum?.ToString() ?? "unbounded"}]."); + } + } + + private static MaterializedRenderValue CreateOwnedShallowCopy( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset = default, + Rect? completeBounds = null, + bool preserveLegacyRasterPlacement = false) + { + RenderTarget copy = target.ShallowCopy(); + try + { + return new MaterializedRenderValue( + copy, + bounds, + effectiveScale, + deviceBounds, + ownsTarget: true, + deviceGridOffset: deviceGridOffset, + completeBounds: completeBounds, + preserveLegacyRasterPlacement: preserveLegacyRasterPlacement); + } + catch + { + copy.Dispose(); + throw; + } + } + + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs new file mode 100644 index 0000000000..6a9af41819 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs @@ -0,0 +1,804 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal readonly record struct RenderExecutionStatistics( + int ShaderRunExecutions, + int ShaderStageExecutions, + int FusedShaderRunExecutions, + int SpirvShaderRunExecutions, + int IntermediateTargetAcquisitions, + int ProgramCacheHits, + int Synchronizations); + +internal sealed partial class RenderRequestExecutor +{ + private readonly RenderTargetLeaseSession _targets; + private readonly ProgramCache? _programCache; + private readonly ProgramCache? _spirvProgramCache; + private readonly ShaderBackendPreference _shaderBackendPreference; + private readonly Action? _afterCaptureAllocation; + + public RenderExecutionStatistics Statistics { get; private set; } + + internal static Rect GetDirectFilterLayerBounds( + Rect semanticInputBounds, + Rect replayedInputBounds, + Rect? materializedRasterBounds = null) + => materializedRasterBounds ?? replayedInputBounds; + + public RenderRequestExecutor( + RenderTargetLeaseSession targets, + ProgramCache? programCache = null, + Action? afterCaptureAllocation = null, + ProgramCache? spirvProgramCache = null, + ShaderBackendPreference shaderBackendPreference = ShaderBackendPreference.Auto) + { + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _programCache = programCache; + _spirvProgramCache = spirvProgramCache; + _afterCaptureAllocation = afterCaptureAllocation; + if (!Enum.IsDefined(shaderBackendPreference)) + throw new ArgumentOutOfRangeException(nameof(shaderBackendPreference)); + _shaderBackendPreference = shaderBackendPreference; + } + + public void Execute( + CompiledRenderRequest request, + ImmediateCanvas destination, + Action? finalizeOutput = null, + Rect? replayBounds = null, + Action? finalizeExternalResources = null) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(destination); + ObjectDisposedException.ThrowIf(request.IsDisposed, request); + ObjectDisposedException.ThrowIf(destination.IsDisposed, destination); + ValidateFamilyForExecution(request); + + ProgramCache? localProgramCache = _programCache is null + ? SkRuntimeEffectProgramCache.Create() + : null; + ProgramCache familyProgramCache = _programCache ?? localProgramCache!; + ProgramCache? localSpirvProgramCache = _spirvProgramCache is null + ? SpirvShaderProgramCache.Create() + : null; + ProgramCache familySpirvProgramCache = + _spirvProgramCache ?? localSpirvProgramCache!; + var frames = new List(); + var cleanupFailures = new List(); + ExceptionDispatchInfo? primaryFailure = null; + int nestedRootAcquisitions = 0; + bool nestedPreviewDropObserved = false; + RenderRequestOwner owner = request.Request.Options.Owner; + try + { + try + { + ExecuteFamily( + request, + destination, + replayBounds ?? request.SelectedOutputBounds, + finalizeOutput, + familyProgramCache, + familySpirvProgramCache, + frames, + cleanupFailures, + ref nestedRootAcquisitions, + ref nestedPreviewDropObserved); + } + catch (FamilyExecutionException ex) + { + primaryFailure = ex.Failure; + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + + if (finalizeExternalResources is not null) + { + try + { + finalizeExternalResources(); + } + catch (Exception ex) + { + EnsureOwnerPrimary(owner, primaryFailure?.SourceException); + IEnumerable externalCleanupFailures = ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions + : [ex]; + Exception? firstExternalCleanupFailure = null; + foreach (Exception failure in externalCleanupFailures) + { + firstExternalCleanupFailure ??= failure; + bool alreadyRecorded = cleanupFailures.Any( + existing => ReferenceEquals(existing, failure)); + AddCleanupFailure(cleanupFailures, failure); + if (!alreadyRecorded) + owner.RecordCleanupFailure(failure); + } + + if (primaryFailure is null && firstExternalCleanupFailure is not null) + { + primaryFailure = ExceptionDispatchInfo.Capture(firstExternalCleanupFailure); + } + } + } + + if (localProgramCache is not null) + { + try + { + localProgramCache.Dispose(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + } + } + + if (localSpirvProgramCache is not null) + { + try + { + localSpirvProgramCache.Dispose(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + } + } + + if (primaryFailure is null && cleanupFailures.Count != 0) + { + primaryFailure = ExceptionDispatchInfo.Capture(cleanupFailures[0]); + } + + if (primaryFailure is not null) + RejectNestedBindings(request); + + EnsureOwnerPrimary(owner, primaryFailure?.SourceException); + int ownerCleanupStart = owner.CleanupFailures.Length; + owner.Cleanup(); + foreach (Exception failure in owner.CleanupFailures.Skip(ownerCleanupStart)) + { + cleanupFailures.Add(failure); + if (primaryFailure is null) + { + primaryFailure = ExceptionDispatchInfo.Capture(failure); + } + } + + try + { + _targets.ThrowIfCleanupFailed(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + if (primaryFailure is null) + { + primaryFailure = ExceptionDispatchInfo.Capture( + ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions[0] + : ex); + } + } + + if (primaryFailure is null) + { + try + { + foreach (FamilyExecutionFrame frame in frames) + frame.State.PublishBuiltInBackdropCaptures(); + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + } + + if (primaryFailure is null) + { + try + { + IReadOnlyList publicationCleanupFailures = + PublishCacheCapturesAtomically(frames); + foreach (Exception failure in publicationCleanupFailures) + { + cleanupFailures.Add(failure); + } + if (publicationCleanupFailures.Count != 0) + { + primaryFailure = ExceptionDispatchInfo.Capture(publicationCleanupFailures[0]); + } + } + catch (FamilyCachePublicationException ex) + { + foreach (Exception cleanupFailure in ex.CleanupFailures) + AppendCleanupFailures(cleanupFailures, cleanupFailure); + primaryFailure = ex.Failure; + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + } + + foreach (FamilyExecutionFrame frame in frames) + { + try + { + frame.State.RejectCacheCaptures(); + frame.State.RejectBuiltInBackdropCaptures(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + if (primaryFailure is null) + { + primaryFailure = ExceptionDispatchInfo.Capture( + ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions[0] + : ex); + } + } + } + + Statistics = AggregateStatistics(frames, nestedRootAcquisitions); + } + finally + { + // Every state is explicitly drained above. This fallback only protects + // future edits that introduce an unexpected coordinator exception. + foreach (FamilyExecutionFrame frame in frames) + { + try + { + frame.State.Dispose(); + } + catch (Exception ex) + { + AppendCleanupFailures(cleanupFailures, ex); + if (primaryFailure is null) + { + primaryFailure = ExceptionDispatchInfo.Capture( + ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions[0] + : ex); + } + } + } + } + + if (primaryFailure is not null) + { + EnsureOwnerPrimary(request.Request.Options.Owner, primaryFailure.SourceException); + RecordAdditionalFailures(request.Request.Options.Owner, cleanupFailures); + FailFamily(request); + primaryFailure.Throw(); + } + + CompleteFamily(request); + } + + private sealed partial class RenderRequestExecutionState : IDisposable + { + private readonly RenderRequestOptions _options; + private readonly ExecutionIslandPlan _executionPlan; + private readonly ExecutionIslandExecutionLedger _executionLedger; + private readonly RegionAnalysis _regions; + private readonly ResourcePlanUseTracker _resourceUses; + private readonly RenderCacheResolution _cacheResolution; + private readonly IReadOnlyDictionary _materializationDemands; + private readonly IReadOnlySet _previewDropEligibleMaterializations; + private readonly HashSet _roots; + private readonly RenderTargetLeaseSession _targets; + private readonly RenderCacheDeviceContextIdentity _programCacheContext; + private readonly ProgramCache _programCache; + private readonly ProgramCache _spirvProgramCache; + private readonly ShaderBackendPreference _shaderBackendPreference; + private readonly DrawableBrushMaterializer _drawableBrushMaterializer; + private readonly Action? _afterCaptureAllocation; + private readonly HashSet _regionEmptyIslands; + private readonly Dictionary _resolvedScopeDomains = []; + private readonly Dictionary _resolvedParentScopeDomains = []; + private readonly Dictionary _resolvedAccessDomains = []; + private readonly Dictionary> _values = + new(ReferenceEqualityComparer.Instance); + private readonly HashSet _ownedValues = + new(ReferenceEqualityComparer.Instance); + private readonly HashSet _cacheCaptureValues = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _valueReferences = + new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _cacheHits; + private readonly Dictionary> _cacheMisses; + private readonly HashSet _skippedExecutionSubjects = []; + private readonly List _pendingCacheCaptures = []; + private readonly HashSet _suppressedCacheCaptures = []; + private readonly List<(IBuiltInBackdropCaptureSink Sink, MaterializedRenderValue Value)> _backdropCaptures = []; + private readonly List _pendingBackdropPublications = []; + private readonly List _backdropSources = []; + private int _shaderRunExecutions; + private int _shaderStageExecutions; + private int _fusedShaderRunExecutions; + private int _spirvShaderRunExecutions; + private int _intermediateTargetAcquisitions; + private int _programCacheHits; + private int _synchronizations; + private int _replayDepth; + private bool _previewAllocationDropObserved; + private Vector _activeDeviceGridOffset; + private bool _deviceGridPhaseNormalized; + + public RenderRequestExecutionState( + RenderRequestOptions options, + RecordedRenderGraph graph, + ExecutionIslandPlan executionPlan, + TargetDependencyPlan targetDependencies, + RegionAnalysis regions, + ImmutableArray roots, + IReadOnlyDictionary materializationDemands, + IReadOnlySet previewDropEligibleMaterializations, + RenderCacheResolution cacheResolution, + RenderTargetLeaseSession targets, + ProgramCache programCache, + ProgramCache spirvProgramCache, + ShaderBackendPreference shaderBackendPreference, + Action? afterCaptureAllocation) + { + _options = options; + _executionPlan = executionPlan; + _executionLedger = executionPlan.CreateExecutionLedger(graph, roots, cacheResolution); + _regions = regions; + _regionEmptyIslands = executionPlan.Islands + .Where(island => IsRegionEmpty(island, regions)) + .Select(static island => island.Id) + .ToHashSet(); + HashSet cacheHitFragmentIds = cacheResolution.CollectPrunedHitProducers(); + _resourceUses = ResourcePlanUseSchedule.Create(roots, cacheHitFragmentIds).BeginExecution(); + _cacheResolution = cacheResolution; + _materializationDemands = materializationDemands + ?? throw new ArgumentNullException(nameof(materializationDemands)); + _previewDropEligibleMaterializations = previewDropEligibleMaterializations + ?? throw new ArgumentNullException(nameof(previewDropEligibleMaterializations)); + _roots = new HashSet( + roots, + ReferenceEqualityComparer.Instance); + _targets = targets; + _programCacheContext = targets.CacheDeviceContextIdentity; + _programCache = programCache; + _spirvProgramCache = spirvProgramCache; + _shaderBackendPreference = shaderBackendPreference; + _drawableBrushMaterializer = MaterializeDrawableBrush; + _afterCaptureAllocation = afterCaptureAllocation; + _cacheHits = cacheResolution.Hits.ToDictionary(static item => item.OriginalProducerId); + _cacheMisses = cacheResolution.MissCaptures + .GroupBy(static item => item.ProducerId) + .ToDictionary( + static group => group.Key, + static group => group.ToImmutableArray()); + + var scopes = targetDependencies.Scopes.ToDictionary(static scope => scope.Id); + foreach (TargetScopePlan scope in targetDependencies.Scopes) + { + if (scope.OwnerFragmentId is { } owner && scope.ResolvedDomain is { } domain) + AddResolvedDomain(_resolvedScopeDomains, owner, domain); + if (scope.OwnerFragmentId is { } parentOwner + && scope.ParentId is { } parentId + && scopes[parentId].ResolvedDomain is { } parentDomain) + { + AddResolvedDomain(_resolvedParentScopeDomains, parentOwner, parentDomain); + } + } + + foreach (TargetDependencyStep step in targetDependencies.Steps) + { + if (scopes[step.ScopeId].ResolvedDomain is { } domain) + AddResolvedDomain(_resolvedAccessDomains, step.FragmentId, domain); + } + } + + /// + /// Records that a nested subtree this request depends on was dropped for want of a target, so the + /// request's degraded output is never published into the render cache. + /// + public void MarkPreviewAllocationDropped() => _previewAllocationDropObserved = true; + + /// + /// Whether anything this request rendered was dropped for want of a target, which makes its output + /// incomplete and therefore unfit for anything that outlives the frame. + /// + /// + /// The lease session is consulted alongside this request's own observation because the paths that + /// allocate their own surfaces - tile-brush intermediates, custom-effect targets, effect flush + /// buffers - degrade without ever taking a lease, and a frame missing their pixels is exactly as + /// unfit for a cache as one whose materialization failed. + /// + public bool PreviewAllocationDropObserved + => _previewAllocationDropObserved || _targets.ContentDropObserved; + + public void Replay(RenderFragmentReference fragment, ImmediateCanvas destination) + { + _replayDepth++; + try + { + ReplayCore(fragment, destination); + CompleteFragmentUse(fragment); + } + catch (PreviewAllocationDropException) when (_replayDepth == 1) + { + _previewAllocationDropObserved = true; + _executionLedger.AbandonActive(); + MarkExecutionSkipped(fragment); + CompleteFragmentUse(fragment); + } + catch (PreviewAllocationDropException) + { + throw; + } + finally + { + _replayDepth--; + } + } + + private void ReplayCore(RenderFragmentReference fragment, ImmediateCanvas destination) + { + if (fragment.Id is { } boundaryId + && (_cacheHits.ContainsKey(boundaryId) || _cacheMisses.ContainsKey(boundaryId))) + { + IReadOnlyList boundaryValues = Materialize( + fragment, + destination, + fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(destination.Density) + : null); + if (fragment.ContributesValuesToTarget) + DrawValues(boundaryValues, destination); + return; + } + + bool canReplayMaterializedValue = fragment.Kind != RenderFragmentKind.BuiltInBackdropCapture; + if (canReplayMaterializedValue + && _values.TryGetValue(fragment, out IReadOnlyList? cachedValues)) + { + if (fragment.ContributesValuesToTarget) + DrawValues(cachedValues, destination); + return; + } + + if (fragment.CanBeUsedAsValueInput + && canReplayMaterializedValue + && _resourceUses.GetRemainingUseCount(fragment) > 1) + { + DrawMaterializedFragment(fragment, destination); + return; + } + + if (_executionPlan.TryGetMembership(fragment, out ExecutionIslandMembership membership) + && membership.ShaderRun is not null) + { + if (TryExecuteCompiledShaderRunDirect( + fragment, + membership.ShaderRun, + destination)) + { + return; + } + DrawMaterializedFragment(fragment, destination); + return; + } + + switch (fragment.Kind) + { + case RenderFragmentKind.ContributeValues: + // The values belong to the input, and this is the one replay branch that materializes + // without delegating to a method whose own finally completes the use. Leaving it open + // holds the input's pooled target for the rest of the request, so a chain of these keeps + // one live intermediate per link instead of handing each back as it is drawn. + try + { + DrawValues( + MaterializeSingleInput( + fragment, + destination, + fragment.EffectiveScale.IsUnbounded + ? EffectiveScale.At(destination.Density) + : null), + destination); + } + finally + { + CompleteFragmentUse(fragment.Inputs[0]); + } + + return; + case RenderFragmentKind.Opacity: + ExecuteReplayIsland( + fragment, + () => + { + using (destination.PushOpacity(((OpacityRenderFragmentPayload)fragment.Payload!).Opacity)) + Replay(fragment.Inputs.Single(), destination); + }); + return; + case RenderFragmentKind.Blend: + ExecuteReplayIsland( + fragment, + () => + { + BlendMode blendMode = + ((BlendRenderFragmentPayload)fragment.Payload!).BlendMode; + // DstOut leaves the destination unchanged for a transparent source, + // so a replay-safe source can erase directly without a coverage-changing + // intermediate layer. Other destructive modes still require the scope layer. + using (blendMode == BlendMode.DstOut + && CanReplayWithDirectDstOut(fragment.Inputs.Single()) + ? destination.PushDirectBlendMode(blendMode) + : destination.PushBlendMode(blendMode)) + { + Replay(fragment.Inputs.Single(), destination); + } + }); + return; + case RenderFragmentKind.OpacityMask: + ExecuteReplayIsland(fragment, () => ReplayOpacityMask(fragment, destination)); + return; + case RenderFragmentKind.Layer: + if (fragment.ContributesValuesToTarget) + DrawValues( + Materialize(fragment, destination), + destination); + else + _ = Materialize(fragment, destination); + return; + case RenderFragmentKind.TargetLayerScope: + ExecuteReplayIsland(fragment, () => ReplayTargetLayerScope(fragment, destination)); + return; + case RenderFragmentKind.OpaqueSource: + if (TryReplayEngineSourceDirect(fragment, destination)) + return; + DrawMaterializedFragment(fragment, destination); + return; + case RenderFragmentKind.OpaqueMap: + case RenderFragmentKind.OpaqueExpand: + case RenderFragmentKind.MaterializedInput: + case RenderFragmentKind.Shader: + case RenderFragmentKind.Geometry: + DrawMaterializedFragment(fragment, destination); + return; + case RenderFragmentKind.FilterEffectSegment: + if (TryReplayBuiltInSkiaFilterChainDirect(fragment, destination)) + return; + DrawMaterializedFragment(fragment, destination); + return; + case RenderFragmentKind.OpaqueCombine: + if (TryReplayEngineSourceDirect(fragment, destination)) + return; + DrawMaterializedFragment(fragment, destination); + return; + case RenderFragmentKind.TargetCapture: + _ = Materialize(fragment, destination); + return; + case RenderFragmentKind.BuiltInBackdropCapture: + { + IReadOnlyList values = Materialize(fragment, destination); + if (values.Count != 1 + || ((BuiltInBackdropCaptureRenderFragmentPayload)fragment.Payload!).Identity + is not IBuiltInBackdropCaptureSink sink) + { + throw new InvalidOperationException( + "A built-in backdrop capture must produce one value for its publication sink."); + } + + AddValueReferences(values); + _backdropCaptures.Add((sink, values[0])); + return; + } + case RenderFragmentKind.TargetCommand: + ExecuteReplayIsland(fragment, () => ExecuteTargetCommand(fragment, destination)); + return; + case RenderFragmentKind.RawTargetCommand: + ExecuteReplayIsland(fragment, () => ExecuteRawTargetCommand(fragment, destination)); + return; + case RenderFragmentKind.TargetScope: + ExecuteReplayIsland(fragment, () => ExecuteTargetScope(fragment, destination)); + return; + case RenderFragmentKind.RawTargetScope: + ExecuteReplayIsland(fragment, () => ExecuteRawTargetScope(fragment, destination)); + return; + default: + throw new InvalidOperationException("The recorded render-fragment kind is invalid."); + } + } + + private sealed record PendingRenderCacheCapture( + RenderCacheMissCapture Descriptor, + IReadOnlyList Values); + + private sealed class PendingBackdropPublication( + IBuiltInBackdropCaptureSink sink, + Bitmap bitmap, + float density) + { + public IBuiltInBackdropCaptureSink Sink { get; } = sink; + + public Bitmap? Bitmap { get; set; } = bitmap; + + public float Density { get; } = density; + } + } + + private sealed class MaterializedRenderValue : IDisposable + { + private readonly RenderTargetLease? _lease; + private readonly EffectTargetRenderTargetLease? _effectTargetLease; + + public MaterializedRenderValue( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + bool ownsTarget, + Vector deviceGridOffset = default, + Rect? completeBounds = null, + bool preserveLegacyRasterPlacement = false) + { + ArgumentNullException.ThrowIfNull(target); + ValidatePhysicalFootprint( + target, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement); + Target = target; + Bounds = bounds; + CompleteBounds = completeBounds ?? bounds; + EffectiveScale = effectiveScale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + OwnsTarget = ownsTarget; + PreserveLegacyRasterPlacement = preserveLegacyRasterPlacement; + } + + public MaterializedRenderValue( + RenderTargetLease lease, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset = default, + Rect? completeBounds = null) + { + ArgumentNullException.ThrowIfNull(lease); + ValidatePhysicalFootprint( + lease.Target, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset); + _lease = lease; + Target = lease.Target; + Bounds = bounds; + CompleteBounds = completeBounds ?? bounds; + EffectiveScale = effectiveScale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + OwnsTarget = true; + } + + public MaterializedRenderValue( + EffectTargetRenderTargetLease lease, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset = default, + Rect? completeBounds = null, + bool preserveLegacyRasterPlacement = false) + { + ArgumentNullException.ThrowIfNull(lease); + ValidatePhysicalFootprint( + lease.Target, + bounds, + effectiveScale, + deviceBounds, + deviceGridOffset, + preserveLegacyRasterPlacement); + _effectTargetLease = lease; + Target = lease.Target; + Bounds = bounds; + CompleteBounds = completeBounds ?? bounds; + EffectiveScale = effectiveScale; + DeviceBounds = deviceBounds; + DeviceGridOffset = deviceGridOffset; + OwnsTarget = true; + PreserveLegacyRasterPlacement = preserveLegacyRasterPlacement; + } + + public RenderTarget Target { get; } + + public Rect Bounds { get; set; } + + public Rect CompleteBounds { get; } + + public EffectiveScale EffectiveScale { get; } + + public PixelRect DeviceBounds { get; } + + public Vector DeviceGridOffset { get; } + + public Rect RasterBounds + => DeviceBounds + .ToRect(EffectiveScale.Value) + .Translate(-DeviceGridOffset); + + public bool OwnsTarget { get; } + + public bool PreserveLegacyRasterPlacement { get; } + + public RenderTarget TransferToAcceptedCache() + { + if (_effectTargetLease is not null) + return _effectTargetLease.TransferToAcceptedCache(); + if (_lease is null) + { + throw new InvalidOperationException( + "Only a renderer-owned pooled capture can transfer into a persistent node cache."); + } + + return _lease.TransferToAcceptedCache(); + } + + public void Dispose() + { + if (_effectTargetLease is not null) + _effectTargetLease.Dispose(); + else if (_lease is not null) + _lease.Dispose(); + else if (OwnsTarget) + Target.Dispose(); + } + + private static void ValidatePhysicalFootprint( + RenderTarget target, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Vector deviceGridOffset, + bool preserveLegacyRasterPlacement = false) + { + if (effectiveScale.IsUnbounded) + throw new ArgumentException("A materialized value requires a concrete density.", nameof(effectiveScale)); + if (deviceBounds.Size != new PixelSize(target.Width, target.Height)) + { + throw new ArgumentException( + "A materialized value's device bounds must match its backing target size.", + nameof(deviceBounds)); + } + + if (preserveLegacyRasterPlacement) + return; + + PixelRect semanticDeviceBounds = PixelRect.FromRect( + bounds.Translate(deviceGridOffset), + effectiveScale.Value); + if (deviceBounds.X > semanticDeviceBounds.X + || deviceBounds.Y > semanticDeviceBounds.Y + || deviceBounds.Right < semanticDeviceBounds.Right + || deviceBounds.Bottom < semanticDeviceBounds.Bottom) + { + throw new ArgumentException( + "A materialized value's device bounds must contain its semantic bounds.", + nameof(deviceBounds)); + } + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOptions.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOptions.cs new file mode 100644 index 0000000000..c6aed6d657 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOptions.cs @@ -0,0 +1,179 @@ +using Beutl.Graphics.Rendering.Cache; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderRequestOptions +{ + public RenderRequestOptions( + RenderIntent intent, + RenderRequestPurpose purpose, + Rect? targetDomain = null, + Rect? requestedRegion = null, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity, + RenderCacheOptions? cachePolicy = null, + FusionMode fusionMode = FusionMode.Enabled, + RenderRequestOwner? owner = null, + NestedRenderTargetBinding? targetBinding = null) + { + if (!Enum.IsDefined(intent)) + { + throw new ArgumentOutOfRangeException(nameof(intent), intent, "The render intent is not defined."); + } + + if (!Enum.IsDefined(purpose)) + { + throw new ArgumentOutOfRangeException(nameof(purpose), purpose, "The render request purpose is not defined."); + } + + if (!Enum.IsDefined(fusionMode)) + { + throw new ArgumentOutOfRangeException(nameof(fusionMode), fusionMode, "The fusion mode is not defined."); + } + + ValidateTargetDomain(targetDomain); + ValidateRequestedRegion(requestedRegion); + + Intent = intent; + Purpose = purpose; + TargetDomain = targetDomain; + RequestedRegion = requestedRegion; + OutputScale = SanitizeOutputScale(outputScale); + MaxWorkingScale = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); + RenderCacheOptions sourceCachePolicy = cachePolicy ?? RenderCacheOptions.Default; + CachePolicy = new RenderCacheOptions(sourceCachePolicy.IsEnabled, sourceCachePolicy.Rules); + FusionMode = fusionMode; + Owner = owner ?? new RenderRequestOwner(); + OwnsOwner = owner is null; + TargetBinding = targetBinding; + PlanIdentity = new RenderRequestPlanIdentity( + Purpose, + FusionMode, + CachePolicy.IsEnabled, + CachePolicy.Rules); + } + + public RenderIntent Intent { get; } + + public RenderRequestPurpose Purpose { get; } + + public Rect? TargetDomain { get; } + + public Rect? RequestedRegion { get; } + + public float OutputScale { get; } + + public float MaxWorkingScale { get; } + + public RenderCacheOptions CachePolicy { get; } + + public FusionMode FusionMode { get; } + + public RenderRequestOwner Owner { get; } + + public NestedRenderTargetBinding? TargetBinding { get; } + + public RenderRequestPlanIdentity PlanIdentity { get; } + + internal bool OwnsOwner { get; } + + internal RenderRequestOptions? NestedPolicyParent { get; private set; } + + public RenderRequestOptions CreateNested( + NestedRenderTargetBinding targetBinding, + Rect? targetDomain = null, + Rect? requestedRegion = null) + => CreateNestedCore( + targetBinding, + targetDomain, + requestedRegion, + OutputScale, + MaxWorkingScale); + + public RenderRequestOptions CreateNestedAtScale( + NestedRenderTargetBinding targetBinding, + float workingScale, + Rect? targetDomain = null, + Rect? requestedRegion = null) + { + if (!float.IsFinite(workingScale) || workingScale <= 0f) + { + throw new ArgumentOutOfRangeException( + nameof(workingScale), + workingScale, + "A nested target working scale must be positive and finite."); + } + + return CreateNestedCore( + targetBinding, + targetDomain, + requestedRegion, + workingScale, + workingScale); + } + + private RenderRequestOptions CreateNestedCore( + NestedRenderTargetBinding targetBinding, + Rect? targetDomain, + Rect? requestedRegion, + float outputScale, + float maxWorkingScale) + { + ArgumentNullException.ThrowIfNull(targetBinding); + var nested = new RenderRequestOptions( + Intent, + Purpose, + targetDomain ?? TargetDomain, + requestedRegion, + outputScale, + maxWorkingScale, + CachePolicy, + FusionMode, + Owner, + targetBinding); + nested.NestedPolicyParent = this; + return nested; + } + + private static float SanitizeOutputScale(float outputScale) + => float.IsFinite(outputScale) && outputScale > 0 ? outputScale : 1; + + private static void ValidateTargetDomain(Rect? targetDomain) + { + if (targetDomain is not { } value) + { + return; + } + + if (!RenderRectValidation.IsFiniteNonNegative(value) + || value.Width <= 0 + || value.Height <= 0) + { + throw new ArgumentException( + "A target domain must be a finite rectangle with positive width and height.", + nameof(targetDomain)); + } + } + + private static void ValidateRequestedRegion(Rect? requestedRegion) + { + if (requestedRegion is { } value && !RenderRectValidation.IsFiniteNonNegative(value)) + { + throw new ArgumentException( + "A requested region must be finite and have non-negative dimensions.", + nameof(requestedRegion)); + } + } +} + +internal readonly record struct RenderRequestPlanIdentity( + RenderRequestPurpose Purpose, + FusionMode FusionMode, + bool CacheEnabled, + RenderCacheRules CacheRules); + +internal enum FusionMode : byte +{ + Enabled, + Disabled, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOwner.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOwner.cs new file mode 100644 index 0000000000..47db58acc9 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestOwner.cs @@ -0,0 +1,189 @@ +using System.Collections.Immutable; +using System.Runtime.ExceptionServices; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderRequestOwner : IDisposable +{ + private readonly List _ownership = []; + private readonly List _secondaryFailures = []; + private readonly List _cleanupFailures = []; + private readonly Dictionary _builtInBackdropBindings = + new(ReferenceEqualityComparer.Instance); + private ExceptionDispatchInfo? _primaryFailure; + + public RenderRequestOwner() + { + ResourceRegistry = new RenderRequestResourceRegistry(); + RecordingFamily = new RenderRecordingFamily(); + _ownership.Add(new RenderOwnershipToken(this, ResourceRegistry.Dispose)); + } + + public ExceptionDispatchInfo? PrimaryFailure => _primaryFailure; + + public ImmutableArray SecondaryFailures => [.. _secondaryFailures]; + + public ImmutableArray CleanupFailures => [.. _cleanupFailures]; + + public bool IsCleanedUp { get; private set; } + + public RenderRequestResourceRegistry ResourceRegistry { get; } + + public RenderRecordingFamily RecordingFamily { get; } + + public void CommitBuiltInBackdropBindings( + IEnumerable bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + if (IsCleanedUp) + throw new InvalidOperationException("The render request owner has already begun cleanup."); + + foreach (BuiltInBackdropBinding binding in bindings) + _builtInBackdropBindings[binding.Identity] = binding.Reference; + } + + public bool TryGetBuiltInBackdrop( + object identity, + out RenderFragmentReference? reference) + { + ArgumentNullException.ThrowIfNull(identity); + return _builtInBackdropBindings.TryGetValue(identity, out reference); + } + + public RenderOwnershipToken Register(Action cleanup) + { + ArgumentNullException.ThrowIfNull(cleanup); + if (IsCleanedUp) + { + throw new InvalidOperationException("The render request owner has already begun cleanup."); + } + + var token = new RenderOwnershipToken(this, cleanup); + _ownership.Add(token); + return token; + } + + public void Discharge(RenderOwnershipToken token) + { + EnsurePendingToken(token); + token.State = RenderOwnershipState.Discharged; + } + + public void DischargeAfterAcceptedCacheTransfer(RenderOwnershipToken token) + { + EnsurePendingToken(token); + token.State = RenderOwnershipState.CacheTransferred; + } + + public void RecordPrimaryFailure(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + if (_primaryFailure is null) + { + _primaryFailure = ExceptionDispatchInfo.Capture(exception); + } + else if (ReferenceEquals(_primaryFailure.SourceException, exception)) + { + // Nested recording/request boundaries may observe the same exception while it + // propagates. That is not an independent secondary failure. + return; + } + else + { + _secondaryFailures.Add(exception); + } + } + + public void Cleanup() + { + if (IsCleanedUp) + { + return; + } + + IsCleanedUp = true; + _builtInBackdropBindings.Clear(); + for (int index = _ownership.Count - 1; index >= 0; index--) + { + RenderOwnershipToken token = _ownership[index]; + if (token.State != RenderOwnershipState.Pending) + { + continue; + } + + token.State = RenderOwnershipState.Discharged; + try + { + token.Cleanup(); + } + catch (Exception ex) + { + RecordCleanupFailure(ex); + } + } + } + + public void ThrowIfFailed() + { + _primaryFailure?.Throw(); + } + + public void Dispose() + { + Cleanup(); + } + + internal void RecordCleanupFailure(Exception exception) + { + _cleanupFailures.Add(exception); + if (_primaryFailure is null) + { + _primaryFailure = ExceptionDispatchInfo.Capture(exception); + } + else + { + _secondaryFailures.Add(exception); + } + } + + private void EnsurePendingToken(RenderOwnershipToken token) + { + ArgumentNullException.ThrowIfNull(token); + if (!ReferenceEquals(token.Owner, this)) + { + throw new InvalidOperationException("The ownership token belongs to a different render request owner."); + } + + if (token.State != RenderOwnershipState.Pending) + { + throw new InvalidOperationException("The ownership token has already been discharged or transferred."); + } + + if (IsCleanedUp) + { + throw new InvalidOperationException("The render request owner has already begun cleanup."); + } + } +} + +internal sealed class RenderOwnershipToken +{ + public RenderOwnershipToken(RenderRequestOwner owner, Action cleanup) + { + Owner = owner; + Cleanup = cleanup; + } + + public RenderRequestOwner Owner { get; } + + public Action Cleanup { get; } + + public RenderOwnershipState State { get; set; } +} + +internal enum RenderOwnershipState : byte +{ + Pending, + Discharged, + CacheTransferred, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs new file mode 100644 index 0000000000..e493a74825 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestRecorder.cs @@ -0,0 +1,288 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using Beutl.Graphics.Rendering.Cache; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderRequestRecorder : IRenderRequestRecordingHost +{ + private static readonly ConditionalWeakTable s_cacheIdentities = new(); + private readonly RecordedRenderGraphBuilder _builder; + private readonly List _pendingCacheCandidates = []; + private readonly HashSet _cacheCandidateNodes = new(ReferenceEqualityComparer.Instance); + private bool _recorded; + + public RenderRequestRecorder(RenderRequest request) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + _builder = new RecordedRenderGraphBuilder(request.Id); + IsRenderCacheEnabled = request.Options.CachePolicy.IsEnabled; + } + + public RenderRequest Request { get; } + + // The request-wide cache policy, not a running tally. RecordSubtreeCore hands every node of a container + // hierarchy the same parent, so a node that latched this on commit would decide for every node recorded + // after it in the request - including unrelated siblings. + public bool IsRenderCacheEnabled { get; } + + public RecordedRenderGraph Record(RenderNode root) + { + ArgumentNullException.ThrowIfNull(root); + if (_recorded) + throw new InvalidOperationException("A render request recorder can record its root only once."); + if (Request.State != RenderRequestState.Created) + throw new InvalidOperationException("A render request must be newly created before recording."); + + _recorded = true; + Request.TransitionTo(RenderRequestState.Recording); + try + { + IReadOnlyList outputs = RecordSubtreeCore(root, parent: null); + CommitCacheCandidates(); + foreach (RenderFragmentReference output in outputs) + { + RenderFragmentId id = output.Id + ?? throw new InvalidOperationException("A root publication was not committed to the request graph."); + _builder.PublishRoot(id); + } + + Request.TransitionTo(RenderRequestState.Recorded); + return _builder.Build(); + } + catch (Exception ex) + { + if (Request.State is not (RenderRequestState.Failed or RenderRequestState.Disposed)) + Request.Fail(ex); + Request.Options.Owner.ThrowIfFailed(); + throw; + } + } + + public IReadOnlyList RecordNode( + NodeRecordingTransaction parent, + RenderNode node, + IReadOnlyList inputs, + bool subtree) + { + ArgumentNullException.ThrowIfNull(parent); + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(inputs); + return subtree + ? RecordSubtreeCore(node, parent) + : InvokeNode(node, inputs, parent, guardAlreadyHeld: false); + } + + public void Commit(NodeRecordingCommit commit) + { + ArgumentNullException.ThrowIfNull(commit); + _builder.Append(commit); + foreach (RenderResource resource in commit.Resources) + { + Request.Options.Owner.ResourceRegistry.Commit(resource); + } + + Request.Options.Owner.CommitBuiltInBackdropBindings(commit.BuiltInBackdropBindings); + } + + public RecordedNestedRenderRequest RecordNestedRequest( + RenderNode root, + RenderRequestOptions options) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentNullException.ThrowIfNull(options); + var nestedRequest = new RenderRequest(options, Request); + try + { + var recorder = new RenderRequestRecorder(nestedRequest); + RecordedRenderGraph graph = recorder.Record(root); + return new RecordedNestedRenderRequest(nestedRequest, graph); + } + catch + { + nestedRequest.Dispose(); + throw; + } + } + + private IReadOnlyList RecordSubtreeCore( + RenderNode node, + NodeRecordingTransaction? parent) + { + using ActiveNodeScope scope = EnterNode(node); + node.PrepareForRequest(new RenderNodePreparation(Request.Options)); + var inputs = new List(); + if (node is ContainerRenderNode container) + { + foreach (RenderNode child in container.Children) + { + inputs.AddRange(RecordSubtreeCore(child, parent)); + } + } + + return InvokeNode(node, inputs, parent, guardAlreadyHeld: true); + } + + private IReadOnlyList InvokeNode( + RenderNode node, + IReadOnlyList inputs, + NodeRecordingTransaction? parent, + bool guardAlreadyHeld) + { + ActiveNodeScope scope = default; + if (!guardAlreadyHeld) + { + scope = EnterNode(node); + + // The subtree walk already prepared this node on its way down. A node reached with explicit + // inputs is not walked, so this is where it gets its one call for the request - the contract is + // that PrepareForRequest runs before Process, on every request, however the node is reached. + node.PrepareForRequest(new RenderNodePreparation(Request.Options)); + } + + try + { + var transaction = new NodeRecordingTransaction(this, node, inputs, parent); + var context = new RenderNodeContext(transaction); + try + { + node.Process(context); + bool canCache = transaction.IsRenderCacheEnabled + && node.Cache.CanCapture + && !node.HasChanges + && !node.Cache.IsDisposed; + ImmutableArray outputs = transaction.Commit(); + if (canCache) + QueueCacheCandidates(node, outputs); + return outputs; + } + catch (Exception ex) + { + if (transaction.State == NodeRecordingTransactionState.Active) + transaction.Rollback(ex); + throw; + } + } + finally + { + scope.Dispose(); + } + } + + private ActiveNodeScope EnterNode(RenderNode node) + { + return new ActiveNodeScope(Request.Options.Owner.RecordingFamily.Enter(node)); + } + + private void QueueCacheCandidates( + RenderNode node, + IReadOnlyList outputs) + { + // RenderNodeCache owns one atomic output set. Multiple independently published fragments would + // require a compound candidate identity and are conservatively left uncached for now. + if (outputs.Count != 1) + return; + + // A node reachable from more than one parent is recorded once per parent, and each recording would + // offer the same RenderNodeCache a candidate of its own. Those outputs are only interchangeable when + // both parents demanded the same thing, so the family would sooner or later try to publish two + // independent outputs to one cache and fail the frame. The first recording keeps the cache; a later + // one renders uncached, which costs work rather than correctness. + if (!_cacheCandidateNodes.Add(node)) + return; + + RenderNodeCacheIdentity identity = s_cacheIdentities.GetValue( + node, + static _ => new RenderNodeCacheIdentity()); + foreach (RenderFragmentReference output in outputs) + { + if (output.CanBeUsedAsValueInput && output.ValueCardinality.Maximum != 0) + { + _pendingCacheCandidates.Add(new PendingRenderCacheCandidate( + output, + identity, + node.Cache)); + } + } + } + + private void CommitCacheCandidates() + { + foreach (PendingRenderCacheCandidate candidate in _pendingCacheCandidates) + { + RenderFragmentId fragmentId = candidate.Reference.Id + ?? throw new InvalidOperationException( + "A cache candidate producer was not committed to the recorded graph."); + _builder.AddCacheCandidate(fragmentId, candidate.Identity, candidate.Cache); + } + _pendingCacheCandidates.Clear(); + } + + private readonly struct ActiveNodeScope : IDisposable + { + private readonly IDisposable? _scope; + + public ActiveNodeScope(IDisposable scope) + { + _scope = scope; + } + + public void Dispose() => _scope?.Dispose(); + } + + private sealed class RenderNodeCacheIdentity + { + } + + private sealed record PendingRenderCacheCandidate( + RenderFragmentReference Reference, + RenderNodeCacheIdentity Identity, + RenderNodeCache Cache); +} + +internal sealed class RenderRecordingFamily +{ + private readonly List _activeNodes = []; + + public IDisposable Enter(RenderNode node) + { + ArgumentNullException.ThrowIfNull(node); + int cycleStart = _activeNodes.FindIndex(item => ReferenceEquals(item, node)); + if (cycleStart >= 0) + { + IEnumerable cycle = _activeNodes + .Skip(cycleStart) + .Append(node) + .Select(static item => item.GetType().FullName ?? item.GetType().Name); + throw new InvalidOperationException( + $"A render-node recording cycle was detected: {string.Join(" -> ", cycle)}."); + } + + _activeNodes.Add(node); + return new Scope(this, node); + } + + private void Exit(RenderNode node) + { + int index = _activeNodes.Count - 1; + if (index < 0 || !ReferenceEquals(_activeNodes[index], node)) + throw new InvalidOperationException("The active render-node recording stack is corrupted."); + + _activeNodes.RemoveAt(index); + } + + private sealed class Scope(RenderRecordingFamily owner, RenderNode node) : IDisposable + { + private RenderRecordingFamily? _owner = owner; + + public void Dispose() + { + RenderRecordingFamily? current = Interlocked.Exchange(ref _owner, null); + current?.Exit(node); + } + } +} + +internal sealed record RecordedNestedRenderRequest( + RenderRequest Request, + RecordedRenderGraph Graph); diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetLeaseRegistry.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetLeaseRegistry.cs new file mode 100644 index 0000000000..2e6ed5cd25 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetLeaseRegistry.cs @@ -0,0 +1,438 @@ +using System.Runtime.ExceptionServices; + +using Beutl.Graphics.Backend; +using Beutl.Logging; +using Beutl.Media; + +using Microsoft.Extensions.Logging; + +namespace Beutl.Graphics.Rendering; + +/// +/// Compatibility-facing adapter over the renderer-lifetime target pool. Request code keeps the +/// original lease vocabulary while released targets remain available for exact-size reuse until +/// the owning renderer is disposed. +/// +internal sealed class RenderTargetLeaseRegistry : IDisposable +{ + private static readonly ILogger s_logger = Log.CreateLogger(); + + private readonly RenderTargetPool _pool; + private RenderTargetLeaseSession? _activeSession; + private bool _disposed; + + public RenderTargetLeaseRegistry(IRenderTargetFactory? factory) + { + HasTargetFactory = factory is not null; + _pool = new RenderTargetPool(factory); + } + + public RenderTargetPoolStatistics Statistics => _pool.Statistics; + + /// + /// Whether the caller supplied an . Paths that allocate their own + /// surfaces consult this: with a factory they must route through the session so its allocation policy and + /// graphics context are honoured, and without one they keep their own allocation and failure reporting. + /// + public bool HasTargetFactory { get; } + + public RenderTargetLeaseSession BeginSession( + RenderIntent intent, + RenderTarget? externalTarget = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_activeSession is not null) + { + throw new InvalidOperationException( + "Concurrent render-target allocation sessions on one renderer are unsupported."); + } + + RenderTargetPoolRequest request = _pool.BeginRequest(externalTarget); + var session = new RenderTargetLeaseSession( + this, + request, + intent, + externalTarget); + _activeSession = session; + return session; + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + List failures = []; + try + { + _activeSession?.Dispose(); + } + catch (Exception ex) + { + AppendFailures(failures, ex); + } + + _activeSession = null; + try + { + _pool.Dispose(); + } + catch (Exception ex) + { + AppendFailures(failures, ex); + } + + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + if (failures.Count > 1) + { + throw new AggregateException( + "One or more render-target registry resources failed to dispose.", + failures); + } + } + + /// Evicts every unleased retained target and reports the released byte count. + /// Disposes backend resources, so it must run on the renderer's thread. + public long ReleaseRetainedTargets() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _pool.ReleaseRetainedTargets(); + } + + internal RenderTargetLease Acquire(RenderTargetLeaseSession session, PixelSize deviceSize) + => TryAcquire(session, deviceSize) + ?? throw RenderTargetPool.CreateAllocationFailure(deviceSize); + + /// + /// Leases an intermediate target, returning when a + /// session may drop the caller's contribution instead. + /// + /// A session never degrades: it throws. + internal RenderTargetLease? TryAcquire( + RenderTargetLeaseSession session, + PixelSize deviceSize, + bool clearContents = true) + { + VerifyActive(session); + if (!session.Request.TryAcquire(deviceSize, out PooledRenderTargetLease? pooled, clearContents)) + { + s_logger.LogWarning( + "Intermediate render-target allocation failed ({Width}x{Height} px); preview drops this target, delivery render fails fast.", + deviceSize.Width, + deviceSize.Height); + if (session.Intent == RenderIntent.Delivery) + throw RenderTargetPool.CreateAllocationFailure(deviceSize); + return null; + } + + var lease = new RenderTargetLease(session, pooled); + session.Register(lease); + return lease; + } + + internal void Release(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (lease.IsReleased) + return; + + lease.IsReleased = true; + try + { + lease.PooledLease.Dispose(); + } + catch (Exception ex) + { + lease.Session.RecordCleanupFailure(ex); + } + } + + internal void ReleaseForBackendReuse(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (lease.IsReleased) + return; + + ITexture2D? texture = lease.Target.Texture; + if (texture is not { RequiresSkiaFlushForBackendInterop: true }) + { + Release(lease); + return; + } + + long approximateBytes = checked((long)lease.Target.Width * lease.Target.Height * 8); + lease.PooledLease.DeferRelease(); + lease.IsReleased = true; + var deferredRelease = new DeferredRenderTargetLeaseRelease(lease); + if (!GpuResourceReclaimQueue.TryDefer(deferredRelease, approximateBytes)) + deferredRelease.Dispose(); + } + + internal RenderTarget TransferToAcceptedCache(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + VerifyActive(lease.Session); + if (lease.IsReleased) + throw new InvalidOperationException("The render-target lease has already been discharged."); + + RenderTarget target = lease.PooledLease.TransferToAcceptedCache(); + lease.IsReleased = true; + return target; + } + + internal void EndSession(RenderTargetLeaseSession session) + { + if (ReferenceEquals(_activeSession, session)) + _activeSession = null; + } + + private void VerifyActive(RenderTargetLeaseSession session) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(session); + if (!ReferenceEquals(_activeSession, session) || session.IsDisposed) + throw new InvalidOperationException("The render-target allocation session is no longer active."); + } + + private static void AppendFailures(List failures, Exception failure) + { + if (failure is AggregateException aggregate) + failures.AddRange(aggregate.Flatten().InnerExceptions); + else + failures.Add(failure); + } +} + +internal sealed class RenderTargetLeaseSession : IDisposable +{ + private readonly RenderTargetLeaseRegistry _registry; + private readonly List _leases = []; + private readonly List _cleanupFailures = []; + + internal RenderTargetLeaseSession( + RenderTargetLeaseRegistry registry, + RenderTargetPoolRequest request, + RenderIntent intent, + RenderTarget? externalTarget) + { + _registry = registry; + Request = request; + Intent = intent; + ExternalTarget = externalTarget; + } + + public RenderIntent Intent { get; } + + /// + public bool HasTargetFactory => _registry.HasTargetFactory; + + /// + /// Whether a path that allocates its own surfaces dropped content it was asked to draw rather than + /// failing the render. + /// + /// + /// Tile-brush intermediates, custom-effect targets, and effect flush buffers degrade to transparent + /// under instead of throwing, and the executor's own drop + /// observation cannot see them: they never take a lease. Folding this in keeps a frame that is missing + /// pixels out of anything that outlives it — a render cache or a captured backdrop. + /// + internal bool ContentDropObserved { get; private set; } + + /// Records that content this session backs was dropped for want of a target. + internal void MarkContentDropped() => ContentDropObserved = true; + + public bool IsDisposed { get; private set; } + + internal RenderTargetPoolRequest Request { get; } + + internal RenderTarget? ExternalTarget { get; } + + internal IReadOnlyList CleanupFailures + => _cleanupFailures.Concat(Request.CleanupFailures).ToArray(); + + internal RenderTargetPoolStatistics PoolStatistics => _registry.Statistics; + + internal RenderCacheDeviceContextIdentity CacheDeviceContextIdentity + => new( + _registry, + new RenderTargetCacheContextIdentity( + Request.ContextIdentity, + Request.ContextGeneration)); + + internal RenderTargetCleanupFailureCheckpoint CaptureCleanupFailureCheckpoint() + => new(this, _cleanupFailures.Count, Request.CleanupFailures.Count); + + internal IReadOnlyList GetCleanupFailuresSince( + RenderTargetCleanupFailureCheckpoint checkpoint) + { + if (!ReferenceEquals(checkpoint.Session, this) + || checkpoint.SessionFailureCount < 0 + || checkpoint.SessionFailureCount > _cleanupFailures.Count + || checkpoint.RequestFailureCount < 0 + || checkpoint.RequestFailureCount > Request.CleanupFailures.Count) + { + throw new ArgumentException( + "The cleanup-failure checkpoint does not belong to this session.", + nameof(checkpoint)); + } + + return + [ + .. _cleanupFailures.Skip(checkpoint.SessionFailureCount), + .. Request.CleanupFailures.Skip(checkpoint.RequestFailureCount), + ]; + } + + public RenderTargetLease Acquire(PixelSize deviceSize) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return _registry.Acquire(this, deviceSize); + } + + /// + /// Whether the lease must arrive transparent. Pass only when every pixel is + /// defined before any is read. + /// + public RenderTargetLease? TryAcquire(PixelSize deviceSize, bool clearContents = true) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return _registry.TryAcquire(this, deviceSize, clearContents); + } + + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + try + { + for (int index = _leases.Count - 1; index >= 0; index--) + _registry.Release(_leases[index]); + Request.Dispose(); + } + finally + { + _registry.EndSession(this); + } + } + + public void ThrowIfCleanupFailed() + { + Exception[] failures = [.. CleanupFailures]; + if (failures.Length == 0) + return; + if (failures.Length == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + + throw new AggregateException( + "One or more render targets failed to discharge.", + failures); + } + + internal void Register(RenderTargetLease lease) + { + _leases.Add(lease); + } + + internal void RecordCleanupFailure(Exception exception) + { + _cleanupFailures.Add(exception); + } + + internal void Release(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!ReferenceEquals(lease.Session, this)) + throw new InvalidOperationException("The render-target lease belongs to a different allocation session."); + _registry.Release(lease); + } + + internal void ReleaseForBackendReuse(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!ReferenceEquals(lease.Session, this)) + throw new InvalidOperationException("The render-target lease belongs to a different allocation session."); + _registry.ReleaseForBackendReuse(lease); + } + + internal RenderTarget TransferToAcceptedCache(RenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!ReferenceEquals(lease.Session, this)) + throw new InvalidOperationException("The render-target lease belongs to a different allocation session."); + return _registry.TransferToAcceptedCache(lease); + } +} + +internal readonly record struct RenderTargetCleanupFailureCheckpoint( + RenderTargetLeaseSession Session, + int SessionFailureCount, + int RequestFailureCount); + +internal sealed class RenderTargetLease : IDisposable +{ + internal RenderTargetLease( + RenderTargetLeaseSession session, + PooledRenderTargetLease pooledLease) + { + Session = session; + PooledLease = pooledLease; + } + + public RenderTarget Target => PooledLease.Target; + + public bool IsReleased { get; internal set; } + + public bool WasReused => PooledLease.WasReused; + + internal RenderTargetLeaseSession Session { get; } + + internal PooledRenderTargetLease PooledLease { get; } + + public void Dispose() + { + Session.Release(this); + } + + internal void ReleaseForBackendReuse() + { + Session.ReleaseForBackendReuse(this); + } + + public RenderTarget TransferToAcceptedCache() + => Session.TransferToAcceptedCache(this); +} + +internal sealed class DeferredRenderTargetLeaseRelease : IDisposable +{ + private RenderTargetLease? _lease; + + public DeferredRenderTargetLeaseRelease(RenderTargetLease lease) + { + _lease = lease ?? throw new ArgumentNullException(nameof(lease)); + } + + public void Dispose() + { + RenderTargetLease? lease = _lease; + if (lease is null) + return; + + _lease = null; + try + { + lease.PooledLease.CompleteDeferredRelease(); + } + catch (Exception ex) + { + lease.Session.RecordCleanupFailure(ex); + } + } +} + +internal readonly record struct RenderTargetCacheContextIdentity( + object BackendContextIdentity, + long Generation); diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetPool.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetPool.cs new file mode 100644 index 0000000000..5b791adbb0 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/RenderTargetPool.cs @@ -0,0 +1,1023 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; + +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal sealed class RenderTargetPoolOptions +{ + public const long DefaultMaximumRetainedBytes = 256L * 1024 * 1024; + + public long MaximumRetainedBytes { get; init; } = DefaultMaximumRetainedBytes; + + public int MaximumIdleRequests { get; init; } = 120; + + internal Action? AfterTargetRegistrationStep { get; init; } + + internal Action? BeforeLeaseRegistration { get; init; } +} + +internal enum RenderTargetPoolRegistrationStage : byte +{ + OwnedSlot, + KnownTarget, + KnownSurface, +} + +internal readonly record struct RenderTargetPoolStatistics( + long Creates, + long Reuses, + long Misses, + long Evictions, + int OwnedTargets, + int AvailableTargets, + int LeasedTargets, + long OwnedBytes, + long RetainedBytes, + int PeakLiveTargets); + +internal enum PooledRenderTargetLeaseState : byte +{ + Leased, + Deferred, + Available, + Evicted, + CacheTransferred, +} + +/// +/// Renderer-lifetime owner for exact-size, linear-premultiplied RGBA16F intermediate targets. +/// +internal sealed class RenderTargetPool : IDisposable +{ + private static readonly object s_cpuContextIdentity = new(); + private static readonly object s_implicitContextIdentity = new(); + + private readonly IRenderTargetFactory? _factory; + private readonly RenderTargetPoolOptions _options; + private readonly Dictionary> _availableBuckets = []; + private readonly LinkedList _availableLru = []; + private readonly HashSet _ownedSlots = []; + private readonly HashSet _knownTargets = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _knownSurfaces = new(ReferenceEqualityComparer.Instance); + private RenderTargetPoolRequest? _activeRequest; + private object? _contextIdentity; + private GRRecordingContext? _graphicsContext; + private nint _contextHandle; + private bool _hasContext; + private long _requestEpoch; + private long _nextLeaseGeneration; + private long _contextGeneration; + private long _ownedBytes; + private long _retainedBytes; + private long _creates; + private long _reuses; + private long _misses; + private long _evictions; + private int _leasedTargets; + private int _peakLiveTargets; + private bool _disposed; + + public RenderTargetPool( + IRenderTargetFactory? factory, + RenderTargetPoolOptions? options = null) + { + options ??= new RenderTargetPoolOptions(); + if (options.MaximumRetainedBytes < 0) + throw new ArgumentOutOfRangeException(nameof(options), "The retained-byte limit cannot be negative."); + if (options.MaximumIdleRequests < 0) + throw new ArgumentOutOfRangeException(nameof(options), "The idle-request limit cannot be negative."); + _factory = factory; + _options = new RenderTargetPoolOptions + { + MaximumRetainedBytes = options.MaximumRetainedBytes, + MaximumIdleRequests = options.MaximumIdleRequests, + AfterTargetRegistrationStep = options.AfterTargetRegistrationStep, + BeforeLeaseRegistration = options.BeforeLeaseRegistration, + }; + } + + public RenderTargetPoolStatistics Statistics => new( + _creates, + _reuses, + _misses, + _evictions, + _ownedSlots.Count, + _availableLru.Count, + _leasedTargets, + _ownedBytes, + _retainedBytes, + _peakLiveTargets); + + public RenderTargetPoolRequest BeginRequest(RenderTarget? externalTarget = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (externalTarget is not null) + { + externalTarget.VerifyAccess(); + SKSurface surface = externalTarget.RawValue; + GRRecordingContext? context = surface.Context; + return BeginRequestCore( + context ?? s_cpuContextIdentity, + context?.Handle ?? 0, + externalTarget); + } + + object contextIdentity = _hasContext ? _contextIdentity! : s_implicitContextIdentity; + return BeginRequestCore(contextIdentity, expectedContextHandle: null, externalTarget: null); + } + + public RenderTargetPoolRequest BeginRequestForContext( + object contextIdentity, + nint expectedContextHandle, + RenderTarget? externalTarget = null) + { + ArgumentNullException.ThrowIfNull(contextIdentity); + ObjectDisposedException.ThrowIf(_disposed, this); + externalTarget?.VerifyAccess(); + return BeginRequestCore(contextIdentity, expectedContextHandle, externalTarget); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + List failures = []; + RenderTargetPoolRequest? activeRequest = _activeRequest; + try + { + activeRequest?.Dispose(); + } + catch (Exception ex) + { + AppendFailure(failures, ex); + } + + failures.AddRange(activeRequest?.CleanupFailures ?? []); + _activeRequest = null; + + foreach (TargetSlot slot in _ownedSlots.ToArray()) + Evict(slot, request: null, failures); + + _availableBuckets.Clear(); + _availableLru.Clear(); + _knownTargets.Clear(); + _knownSurfaces.Clear(); + ThrowCleanupFailures(failures); + } + + /// Evicts every unleased retained target and reports the released byte count. + /// Disposes backend resources, so it must run on the renderer's thread. + internal long ReleaseRetainedTargets() + { + ObjectDisposedException.ThrowIf(_disposed, this); + long released = _retainedBytes; + List failures = []; + EvictAllAvailable(_activeRequest, failures); + ThrowCleanupFailures(failures); + return released; + } + + internal PooledRenderTargetLease Acquire( + RenderTargetPoolRequest request, + PixelSize deviceSize) + { + if (TryAcquire(request, deviceSize, out PooledRenderTargetLease? lease)) + return lease; + throw CreateAllocationFailure(deviceSize); + } + + internal static InvalidOperationException CreateAllocationFailure(PixelSize deviceSize) + => new($"The render-target factory could not allocate {deviceSize.Width}x{deviceSize.Height} pixels."); + + /// Leases an exact-size target, reporting only when the allocator declines. + /// Every other failure — a stale slot, a contract-violating factory return — still throws. + /// + /// Whether the lease must arrive transparent. A caller that defines every pixel of the target before + /// reading any of it - a full-frame pass whose load op clears, or a shader that provably writes + /// everywhere - passes and saves the clear and the two layout transitions + /// around it. The slot's recorded contents stay unknown either way, so the next caller that does want a + /// blank target still gets one. + /// + internal bool TryAcquire( + RenderTargetPoolRequest request, + PixelSize deviceSize, + [NotNullWhen(true)] out PooledRenderTargetLease? lease, + bool clearContents = true) + { + VerifyActive(request); + if (deviceSize.Width <= 0 || deviceSize.Height <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(deviceSize), + deviceSize, + "A pooled render target requires a positive device size."); + } + + if (TryTakeAvailable(deviceSize, out TargetSlot? slot)) + { + TargetSlot reusable = slot!; + try + { + ValidateReusableSlot(reusable, request); + if (clearContents) + reusable.Target.ClearToTransparent(); + } + catch (Exception ex) + { + Evict(reusable, request, failures: null); + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; + } + + _reuses++; + lease = Lease(request, reusable, wasReused: true); + return true; + } + + _misses++; + RenderTarget? target = CreateTarget(deviceSize, request); + if (target is null && _retainedBytes > 0) + { + EvictAllAvailable(request, failures: null); + target = CreateTarget(deviceSize, request); + } + + if (target is null) + { + lease = null; + return false; + } + + bool accepted = false; + bool targetIsBorrowedOrAlreadyOwned = ReferenceEquals(target, request.ExternalTarget) + || _knownTargets.Contains(target) + || SharesLiveSurface(target, request); + try + { + SKSurface surface = ValidateFactoryTarget(target, deviceSize, request); + if (clearContents && !target.HasTransparentContents) + target.ClearToTransparent(); + long byteSize = GetByteSize(deviceSize); + long nextOwnedBytes = checked(_ownedBytes + byteSize); + slot = new TargetSlot(target, surface, deviceSize, byteSize); + try + { + _ownedSlots.Add(slot); + _options.AfterTargetRegistrationStep?.Invoke(RenderTargetPoolRegistrationStage.OwnedSlot); + _knownTargets.Add(target); + _options.AfterTargetRegistrationStep?.Invoke(RenderTargetPoolRegistrationStage.KnownTarget); + _knownSurfaces.Add(surface); + _options.AfterTargetRegistrationStep?.Invoke(RenderTargetPoolRegistrationStage.KnownSurface); + } + catch + { + _knownSurfaces.Remove(surface); + _knownTargets.Remove(target); + _ownedSlots.Remove(slot); + throw; + } + + _ownedBytes = nextOwnedBytes; + _creates++; + accepted = true; + lease = Lease(request, slot, wasReused: false); + return true; + } + catch (Exception primary) + { + if (!accepted && !targetIsBorrowedOrAlreadyOwned) + { + try + { + target.Dispose(); + } + catch (Exception cleanup) + { + request.RecordCleanupFailure(cleanup); + } + } + + ExceptionDispatchInfo.Capture(primary).Throw(); + throw; + } + } + + internal void Release(PooledRenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + VerifyLease(lease); + ReleaseCore(lease); + } + + internal void DeferRelease(PooledRenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + VerifyLease(lease); + lease.State = PooledRenderTargetLeaseState.Deferred; + } + + internal void CompleteDeferredRelease(PooledRenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!ReferenceEquals(lease.Pool, this)) + throw new InvalidOperationException("The render-target lease belongs to a different pool."); + if (lease.State == PooledRenderTargetLeaseState.Evicted) + return; + if (lease.State != PooledRenderTargetLeaseState.Deferred) + { + throw new InvalidOperationException( + $"The render-target lease cannot complete a deferred release from {lease.State}."); + } + + TargetSlot slot = lease.Slot; + if (!ReferenceEquals(slot.ActiveLease, lease) || slot.Generation != lease.Generation) + throw new InvalidOperationException("The render-target lease generation is stale."); + ReleaseCore(lease); + } + + private void ReleaseCore(PooledRenderTargetLease lease) + { + TargetSlot slot = lease.Slot; + lease.State = PooledRenderTargetLeaseState.Available; + slot.ActiveLease = null; + slot.LastAvailableLease = lease; + slot.LastUsedEpoch = _requestEpoch; + _leasedTargets--; + + if (_disposed || !IsCurrentContext(lease.Request) || slot.Target.IsDisposed) + { + lease.State = PooledRenderTargetLeaseState.Evicted; + Evict(slot, lease.Request, failures: null); + return; + } + + AddAvailable(slot); + TrimToByteBudget(lease.Request); + if (!_ownedSlots.Contains(slot)) + lease.State = PooledRenderTargetLeaseState.Evicted; + } + + internal RenderTarget TransferToAcceptedCache(PooledRenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + VerifyLease(lease); + + TargetSlot slot = lease.Slot; + slot.ActiveLease = null; + slot.LastAvailableLease = null; + lease.State = PooledRenderTargetLeaseState.CacheTransferred; + _leasedTargets--; + RemoveOwnedSlot(slot); + return slot.Target; + } + + internal void EndRequest(RenderTargetPoolRequest request) + { + if (ReferenceEquals(_activeRequest, request)) + _activeRequest = null; + } + + internal void EvictAfterReleaseFailure(PooledRenderTargetLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!ReferenceEquals(lease.Pool, this)) + throw new InvalidOperationException("The render-target lease belongs to a different pool."); + + Evict(lease.Slot, lease.Request, failures: null); + } + + private RenderTargetPoolRequest BeginRequestCore( + object contextIdentity, + nint? expectedContextHandle, + RenderTarget? externalTarget) + { + if (_activeRequest is not null) + { + throw new InvalidOperationException( + "Concurrent render-target pool requests on one renderer are unsupported."); + } + + List failures = []; + if (_hasContext && !ReferenceEquals(_contextIdentity, contextIdentity)) + EvictAllAvailable(request: null, failures); + + if (!_hasContext || !ReferenceEquals(_contextIdentity, contextIdentity)) + { + _contextIdentity = contextIdentity; + _graphicsContext = externalTarget?.RawValue.Context ?? contextIdentity as GRRecordingContext; + _contextHandle = expectedContextHandle ?? 0; + _hasContext = expectedContextHandle.HasValue; + _contextGeneration = NextGeneration(_contextGeneration); + } + else if (expectedContextHandle.HasValue && _contextHandle != expectedContextHandle.Value) + { + EvictAllAvailable(request: null, failures); + _graphicsContext = externalTarget?.RawValue.Context ?? contextIdentity as GRRecordingContext; + _contextHandle = expectedContextHandle.Value; + _hasContext = true; + _contextGeneration = NextGeneration(_contextGeneration); + } + else if (externalTarget?.RawValue.Context is { } graphicsContext) + { + _graphicsContext = graphicsContext; + } + + ThrowCleanupFailures(failures); + _requestEpoch++; + var request = new RenderTargetPoolRequest( + this, + contextIdentity, + _contextGeneration, + expectedContextHandle, + externalTarget); + _activeRequest = request; + TrimIdle(request); + return request; + } + + private static long NextGeneration(long current) + => current == long.MaxValue ? 1 : current + 1; + + private PooledRenderTargetLease Lease( + RenderTargetPoolRequest request, + TargetSlot slot, + bool wasReused) + { + try + { + long generation = ++_nextLeaseGeneration; + if (generation <= 0) + { + _nextLeaseGeneration = 1; + generation = 1; + } + + var lease = new PooledRenderTargetLease(this, request, slot, generation, wasReused); + slot.Generation = generation; + slot.LastAvailableLease = null; + slot.ActiveLease = lease; + _leasedTargets++; + _peakLiveTargets = Math.Max(_peakLiveTargets, _leasedTargets); + _options.BeforeLeaseRegistration?.Invoke(); + request.Register(lease); + return lease; + } + catch + { + Evict(slot, request, failures: null); + throw; + } + } + + private bool TryTakeAvailable(PixelSize size, out TargetSlot? slot) + { + if (_availableBuckets.TryGetValue(size, out LinkedList? bucket) + && bucket.Last is { } node) + { + slot = node.Value; + RemoveAvailable(slot); + return true; + } + + slot = null; + return false; + } + + private void AddAvailable(TargetSlot slot) + { + if (!_availableBuckets.TryGetValue(slot.Size, out LinkedList? bucket)) + { + bucket = []; + _availableBuckets.Add(slot.Size, bucket); + } + + slot.BucketNode = bucket.AddLast(slot); + slot.LruNode = _availableLru.AddLast(slot); + _retainedBytes = checked(_retainedBytes + slot.ByteSize); + } + + private void RemoveAvailable(TargetSlot slot) + { + if (slot.BucketNode is { } bucketNode + && _availableBuckets.TryGetValue(slot.Size, out LinkedList? bucket)) + { + bucket.Remove(bucketNode); + if (bucket.Count == 0) + _availableBuckets.Remove(slot.Size); + } + + if (slot.LruNode is { } lruNode) + _availableLru.Remove(lruNode); + + if (slot.BucketNode is not null || slot.LruNode is not null) + _retainedBytes -= slot.ByteSize; + slot.BucketNode = null; + slot.LruNode = null; + } + + private void TrimIdle(RenderTargetPoolRequest request) + { + while (_availableLru.First is { } node + && _requestEpoch - node.Value.LastUsedEpoch > _options.MaximumIdleRequests) + { + Evict(node.Value, request, failures: null); + } + } + + private void TrimToByteBudget(RenderTargetPoolRequest request) + { + while (_retainedBytes > _options.MaximumRetainedBytes + && _availableLru.First is { } node) + { + Evict(node.Value, request, failures: null); + } + } + + private void EvictAllAvailable(RenderTargetPoolRequest? request, List? failures) + { + while (_availableLru.First is { } node) + Evict(node.Value, request, failures); + } + + private void Evict( + TargetSlot slot, + RenderTargetPoolRequest? request, + List? failures) + { + if (!_ownedSlots.Contains(slot)) + return; + + PooledRenderTargetLease? liveLease = slot.ActiveLease; + if (liveLease is not null) + { + liveLease.State = PooledRenderTargetLeaseState.Evicted; + slot.ActiveLease = null; + _leasedTargets--; + } + else if (slot.LastAvailableLease is { State: PooledRenderTargetLeaseState.Available } availableLease) + { + availableLease.State = PooledRenderTargetLeaseState.Evicted; + } + slot.LastAvailableLease = null; + + RemoveAvailable(slot); + RemoveOwnedSlot(slot); + _evictions++; + try + { + slot.Target.Dispose(); + } + catch (Exception ex) + { + if (request is not null) + request.RecordCleanupFailure(ex); + else + failures?.Add(ex); + } + } + + private void RemoveOwnedSlot(TargetSlot slot) + { + if (!_ownedSlots.Remove(slot)) + return; + + RemoveAvailable(slot); + _knownTargets.Remove(slot.Target); + _knownSurfaces.Remove(slot.Surface); + _ownedBytes -= slot.ByteSize; + } + + /// + /// Whether 's backing surface is one this pool or the request already holds. + /// + /// + /// A factory can hand back a fresh target instance wrapping a surface something else is still drawing to. + /// Rejecting it is right, but disposing it would take that surface down with it and leave a live pool slot + /// or the caller's destination pointing at freed memory, so a rejection here only drops the reference. + /// + private bool SharesLiveSurface(RenderTarget target, RenderTargetPoolRequest request) + { + try + { + SKSurface surface = target.RawValue; + return ReferenceEquals(surface, request.ExternalSurface) || _knownSurfaces.Contains(surface); + } + catch + { + // A target that cannot even show its surface shares nothing, so the caller owns its disposal. + return false; + } + } + + private SKSurface ValidateFactoryTarget( + RenderTarget target, + PixelSize size, + RenderTargetPoolRequest request) + { + if (ReferenceEquals(target, request.ExternalTarget)) + { + throw new InvalidOperationException( + "The render-target factory returned the borrowed destination as an owned allocation."); + } + if (_knownTargets.Contains(target)) + { + throw new InvalidOperationException( + "The render-target factory returned a target instance already owned by this pool."); + } + + SKSurface surface = ValidateNewSurface(target, size); + if (ReferenceEquals(surface, request.ExternalSurface) || _knownSurfaces.Contains(surface)) + { + throw new InvalidOperationException( + "The render-target factory returned a backing surface that is already in use."); + } + + ValidateContext(surface, request); + return surface; + } + + private void ValidateReusableSlot(TargetSlot slot, RenderTargetPoolRequest request) + { + if (!_ownedSlots.Contains(slot) + || slot.ActiveLease is not null + || slot.Target.IsDisposed) + { + throw new InvalidOperationException("The pooled render target is no longer reusable."); + } + + SKSurface surface = ValidateSurfaceIdentityAndViewport(slot.Target, slot.Size); + if (!ReferenceEquals(surface, slot.Surface)) + throw new InvalidOperationException("A pooled render target changed its backing surface."); + ValidateContext(surface, request); + } + + private static SKSurface ValidateNewSurface(RenderTarget target, PixelSize size) + { + SKSurface surface = ValidateSurfaceIdentityAndViewport(target, size); + using SKImage? image = surface.Snapshot(); + using SKColorSpace expectedColorSpace = SKColorSpace.CreateSrgbLinear(); + using SKColorSpace? actualColorSpace = image?.ColorSpace; + if (image is null + || image.Width != size.Width + || image.Height != size.Height + || image.ColorType != SKColorType.RgbaF16 + || image.AlphaType != SKAlphaType.Premul + || actualColorSpace is null + || !SKColorSpace.Equal(actualColorSpace, expectedColorSpace)) + { + throw new InvalidOperationException( + "Pooled render targets must be linear-premultiplied RGBA16F surfaces."); + } + + return surface; + } + + private static SKSurface ValidateSurfaceIdentityAndViewport(RenderTarget target, PixelSize size) + { + if (target.IsDisposed || target.Width != size.Width || target.Height != size.Height) + { + throw new InvalidOperationException( + "The render-target factory returned a disposed target or a target whose exact device size is wrong."); + } + + target.VerifyAccess(); + SKSurface surface = target.RawValue; + SKRectI deviceClip = surface.Canvas.DeviceClipBounds; + if (deviceClip.Left != 0 + || deviceClip.Top != 0 + || deviceClip.Width != size.Width + || deviceClip.Height != size.Height) + { + throw new InvalidOperationException( + "The render-target surface has an incompatible device viewport."); + } + + return surface; + } + + private void ValidateContext(SKSurface surface, RenderTargetPoolRequest request) + { + GRRecordingContext? actualContext = surface.Context; + nint actual = actualContext?.Handle ?? 0; + if (request.ExpectedContextHandle is { } expected && actual != expected) + { + throw new InvalidOperationException( + "The render-target factory returned a target from an incompatible graphics context."); + } + + if (!_hasContext) + { + _contextIdentity = request.ContextIdentity; + _contextHandle = actual; + _hasContext = true; + } + else if (!ReferenceEquals(_contextIdentity, request.ContextIdentity) + || _contextHandle != actual) + { + throw new InvalidOperationException( + "The render-target factory returned targets from incompatible graphics contexts."); + } + + _graphicsContext = actualContext; + } + + private void VerifyActive(RenderTargetPoolRequest request) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(request); + if (!ReferenceEquals(_activeRequest, request) || request.IsDisposed) + throw new InvalidOperationException("The render-target pool request is no longer active."); + } + + internal void VerifyLease(PooledRenderTargetLease lease) + { + if (!ReferenceEquals(lease.Pool, this)) + throw new InvalidOperationException("The render-target lease belongs to a different pool."); + if (lease.State != PooledRenderTargetLeaseState.Leased) + { + throw new InvalidOperationException( + $"The render-target lease has already been discharged as {lease.State}."); + } + + TargetSlot slot = lease.Slot; + if (!ReferenceEquals(slot.ActiveLease, lease) || slot.Generation != lease.Generation) + throw new InvalidOperationException("The render-target lease generation is stale."); + } + + private bool IsCurrentContext(RenderTargetPoolRequest request) + => _hasContext + && ReferenceEquals(_contextIdentity, request.ContextIdentity) + && _contextGeneration == request.ContextGeneration; + + private static long GetByteSize(PixelSize size) + { + try + { + return checked((long)size.Width * size.Height * 8); + } + catch (OverflowException) + { + throw new ArgumentOutOfRangeException( + nameof(size), + size, + "The RGBA16F render-target byte size overflowed."); + } + } + + private RenderTarget? CreateTarget(PixelSize deviceSize, RenderTargetPoolRequest request) + => _factory is null + ? CreateDefaultTarget(deviceSize, ResolveAllocationContextHandle(request)) + : _factory.Create(GetAllocationDescriptor(deviceSize, request)); + + internal RenderTargetAllocationDescriptor GetAllocationDescriptor( + PixelSize deviceSize, + RenderTargetPoolRequest request) + { + VerifyActive(request); + return new RenderTargetAllocationDescriptor( + deviceSize, + _graphicsContext, + ResolveAllocationContextHandle(request)); + } + + // Only a request rendering into a caller-owned destination carries a handle of its own. A + // target-less request on a pool that already bound a context still has to allocate on that + // context, because every surface the pool hands out is checked against it. + private nint? ResolveAllocationContextHandle(RenderTargetPoolRequest request) + => request.ExpectedContextHandle ?? (_hasContext ? _contextHandle : null); + + private static RenderTarget? CreateDefaultTarget( + PixelSize deviceSize, + nint? contextHandle) + { + if (contextHandle == 0) + { + SKSurface? surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())); + return surface is null + ? null + : new CpuRenderTarget(surface, deviceSize); + } + + return RenderTarget.Create(deviceSize.Width, deviceSize.Height); + } + + private static void ThrowCleanupFailures(List failures) + { + if (failures.Count == 0) + return; + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + throw new AggregateException("One or more pooled render targets failed to dispose.", failures); + } + + private static void AppendFailure(List failures, Exception failure) + { + if (failure is AggregateException aggregate) + failures.AddRange(aggregate.Flatten().InnerExceptions); + else + failures.Add(failure); + } + + internal sealed class TargetSlot( + RenderTarget target, + SKSurface surface, + PixelSize size, + long byteSize) + { + public RenderTarget Target { get; } = target; + + public SKSurface Surface { get; } = surface; + + public PixelSize Size { get; } = size; + + public long ByteSize { get; } = byteSize; + + public long Generation { get; set; } + + public long LastUsedEpoch { get; set; } + + public PooledRenderTargetLease? ActiveLease { get; set; } + + public PooledRenderTargetLease? LastAvailableLease { get; set; } + + public LinkedListNode? BucketNode { get; set; } + + public LinkedListNode? LruNode { get; set; } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} + +internal sealed class RenderTargetPoolRequest : IDisposable +{ + private readonly RenderTargetPool _pool; + private readonly List _leases = []; + private readonly List _cleanupFailures = []; + + internal RenderTargetPoolRequest( + RenderTargetPool pool, + object contextIdentity, + long contextGeneration, + nint? expectedContextHandle, + RenderTarget? externalTarget) + { + _pool = pool; + ContextIdentity = contextIdentity; + ContextGeneration = contextGeneration; + ExpectedContextHandle = expectedContextHandle; + ExternalTarget = externalTarget; + ExternalSurface = externalTarget?.RawValue; + } + + public bool IsDisposed { get; private set; } + + public IReadOnlyList CleanupFailures => _cleanupFailures; + + internal object ContextIdentity { get; } + + internal long ContextGeneration { get; } + + internal nint? ExpectedContextHandle { get; } + + internal RenderTarget? ExternalTarget { get; } + + internal SKSurface? ExternalSurface { get; } + + public PooledRenderTargetLease Acquire(PixelSize deviceSize) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return _pool.Acquire(this, deviceSize); + } + + public bool TryAcquire( + PixelSize deviceSize, + [NotNullWhen(true)] out PooledRenderTargetLease? lease, + bool clearContents = true) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return _pool.TryAcquire(this, deviceSize, out lease, clearContents); + } + + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + ExceptionDispatchInfo? primary = null; + try + { + for (int index = _leases.Count - 1; index >= 0; index--) + { + PooledRenderTargetLease lease = _leases[index]; + if (lease.State == PooledRenderTargetLeaseState.Leased) + { + try + { + _pool.Release(lease); + } + catch (Exception ex) + { + primary ??= ExceptionDispatchInfo.Capture(ex); + try + { + _pool.EvictAfterReleaseFailure(lease); + } + catch (Exception cleanup) + { + primary ??= ExceptionDispatchInfo.Capture(cleanup); + } + } + } + } + } + finally + { + _pool.EndRequest(this); + } + + primary?.Throw(); + } + + internal void Register(PooledRenderTargetLease lease) + { + _leases.Add(lease); + } + + internal void RecordCleanupFailure(Exception exception) + { + _cleanupFailures.Add(exception); + } +} + +internal sealed class PooledRenderTargetLease : IDisposable +{ + internal PooledRenderTargetLease( + RenderTargetPool pool, + RenderTargetPoolRequest request, + RenderTargetPool.TargetSlot slot, + long generation, + bool wasReused) + { + Pool = pool; + Request = request; + Slot = slot; + Generation = generation; + WasReused = wasReused; + } + + public RenderTarget Target + { + get + { + Pool.VerifyLease(this); + return Slot.Target; + } + } + + public PixelSize DeviceSize + { + get + { + Pool.VerifyLease(this); + return Slot.Size; + } + } + + public long Generation { get; } + + public bool WasReused { get; } + + public PooledRenderTargetLeaseState State { get; internal set; } = PooledRenderTargetLeaseState.Leased; + + internal RenderTargetPool Pool { get; } + + internal RenderTargetPoolRequest Request { get; } + + internal RenderTargetPool.TargetSlot Slot { get; } + + public RenderTarget TransferToAcceptedCache() + => Pool.TransferToAcceptedCache(this); + + internal void DeferRelease() + => Pool.DeferRelease(this); + + internal void CompleteDeferredRelease() + => Pool.CompleteDeferredRelease(this); + + public void Dispose() + => Pool.Release(this); +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/ResourcePlanUseSchedule.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/ResourcePlanUseSchedule.cs new file mode 100644 index 0000000000..7a56abc69b --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/ResourcePlanUseSchedule.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; + +namespace Beutl.Graphics.Rendering; + +internal readonly record struct ResourcePlanFragmentLifetime( + RenderFragmentReference Fragment, + int AcquisitionPosition, + ImmutableArray ConsumerPositions) +{ + public int LastUsePosition + => ConsumerPositions.IsDefaultOrEmpty + ? AcquisitionPosition + : ConsumerPositions[^1]; +} + +/// +/// Structural resource-use schedule for a recorded request. Runtime-discovered streams share their producer +/// interval; their exact target sizes remain selected by the pool when the callback publishes each value. +/// +internal sealed class ResourcePlanUseSchedule +{ + private ResourcePlanUseSchedule(ImmutableArray lifetimes) + { + Lifetimes = lifetimes; + } + + public ImmutableArray Lifetimes { get; } + + public ResourcePlanUseTracker BeginExecution() + => new(Lifetimes); + + internal static ResourcePlanUseSchedule Create( + IReadOnlyList roots, + IReadOnlySet? terminalFragmentIds = null) + { + ArgumentNullException.ThrowIfNull(roots); + terminalFragmentIds ??= new HashSet(); + var ordered = new List(); + var visiting = new HashSet(ReferenceEqualityComparer.Instance); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference root in roots) + { + ArgumentNullException.ThrowIfNull(root); + Visit(root, terminalFragmentIds, visiting, visited, ordered); + } + + var positions = new Dictionary(ReferenceEqualityComparer.Instance); + var consumers = new Dictionary>(ReferenceEqualityComparer.Instance); + for (int index = 0; index < ordered.Count; index++) + { + RenderFragmentReference fragment = ordered[index]; + positions.Add(fragment, index); + consumers.Add(fragment, []); + } + + for (int index = 0; index < ordered.Count; index++) + { + RenderFragmentReference fragment = ordered[index]; + if (fragment.Id is { } id && terminalFragmentIds.Contains(id)) + continue; + foreach (RenderFragmentReference input in fragment.ExecutionInputs) + consumers[input].Add(index); + } + + for (int index = 0; index < roots.Count; index++) + consumers[roots[index]].Add(checked(ordered.Count + index)); + + return new ResourcePlanUseSchedule( + [ + .. ordered.Select(fragment => new ResourcePlanFragmentLifetime( + fragment, + positions[fragment], + [.. consumers[fragment].Order()])), + ]); + + static void Visit( + RenderFragmentReference fragment, + IReadOnlySet terminalFragmentIds, + HashSet visiting, + HashSet visited, + List ordered) + { + if (visited.Contains(fragment)) + return; + if (!visiting.Add(fragment)) + throw new InvalidOperationException("The resource-use graph contains a fragment cycle."); + + if (fragment.Id is not { } id || !terminalFragmentIds.Contains(id)) + { + foreach (RenderFragmentReference input in fragment.ExecutionInputs) + Visit(input, terminalFragmentIds, visiting, visited, ordered); + } + + visiting.Remove(fragment); + visited.Add(fragment); + ordered.Add(fragment); + } + } +} + +internal sealed class ResourcePlanUseTracker +{ + private readonly Dictionary _remainingUses; + + internal ResourcePlanUseTracker(ImmutableArray lifetimes) + { + _remainingUses = new Dictionary( + lifetimes.Length, + ReferenceEqualityComparer.Instance); + foreach (ResourcePlanFragmentLifetime lifetime in lifetimes) + _remainingUses.Add(lifetime.Fragment, lifetime.ConsumerPositions.Length); + } + + /// Completes one authored edge/root use and returns true at the producer's last use. + public bool CompleteUse(RenderFragmentReference fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + if (!_remainingUses.TryGetValue(fragment, out int remaining) || remaining <= 0) + { + throw new InvalidOperationException( + "A render fragment was consumed more times than its resource plan declares."); + } + + remaining--; + _remainingUses[fragment] = remaining; + return remaining == 0; + } + + public int GetRemainingUseCount(RenderFragmentReference fragment) + { + ArgumentNullException.ThrowIfNull(fragment); + return _remainingUses.TryGetValue(fragment, out int remaining) + ? remaining + : throw new InvalidOperationException( + "A render fragment is not part of the resource-use schedule."); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/SkslBackendBudgetResolver.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/SkslBackendBudgetResolver.cs new file mode 100644 index 0000000000..cdbcc0ee68 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/SkslBackendBudgetResolver.cs @@ -0,0 +1,101 @@ +using Beutl.Graphics.Effects; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal enum SkslBackendCapabilityClass : byte +{ + Portable, + Vulkan, + Metal, + SpirvVulkan, +} + +/// +/// Selects finite fusion budgets for the active Skia backend and the Vulkan-native lowering. +/// +/// +/// SkiaSharp exposes the Skia backend family but not its fragment-uniform, sampler, or runtime-effect child +/// ceilings. These profiles are therefore conservative engine policies rather than exact driver limits. +/// is the common-denominator profile used when target-less rasterization has not +/// allocated a backend surface yet. Backend-specific profiles may raise its limits but must not lower them. +/// Their capability classes remain part of backend program identities even when individual limits coincide. +/// Source and token limits bound fusion-generated program growth; a valid single stage remains eligible for +/// the compatibility path when it exceeds one of those limits. records the smaller +/// complete subset supported by the first native lowering in this same policy mechanism. +/// +internal static class SkslBackendBudgetResolver +{ + private const int MaxStages = 16; + private const int MaxUniformVectors = 128; + private const int MaxSourceBytes = 64 * 1024; + private const int MaxProgramTokens = 16 * 1024; + + private static readonly SkslBackendBudget s_portable = Create(SkslBackendCapabilityClass.Portable); + private static readonly SkslBackendBudget s_vulkan = Create(SkslBackendCapabilityClass.Vulkan); + private static readonly SkslBackendBudget s_metal = Create(SkslBackendCapabilityClass.Metal); + private static readonly SkslBackendBudget s_spirvVulkan = Create(SkslBackendCapabilityClass.SpirvVulkan); + + public static SkslBackendBudget Portable => s_portable; + + /// + /// Gets the initial Vulkan-native lowering budget. Native snippet fusion is not yet enabled, so one lowered + /// stage is the complete supported program rather than an accidental unbounded subset. + /// + public static SkslBackendBudget SpirvVulkan => s_spirvVulkan; + + public static SkslBackendBudget Resolve(GRBackend? backend) + => backend switch + { + GRBackend.Vulkan => s_vulkan, + GRBackend.Metal => s_metal, + _ => s_portable, + }; + + private static SkslBackendBudget Create(SkslBackendCapabilityClass capabilityClass) + { + // Portable also covers target-less rendering and backends whose family Skia cannot identify. There is no + // universal sampler floor for an arbitrary unidentified driver, so this policy is deliberately based on + // the common floor of Beutl's supported backend families rather than claiming one. OpenGL ES 3.0.6 table + // 6.32 and OpenGL 4.6 core table 23.61 both require at least 16 fragment texture-image units; D3D exposes + // at least 16 fragment-stage sampler slots as well. Vulkan 1.0 requires at least 16 + // maxPerStageDescriptorSamplers and 16 maxPerStageDescriptorSampledImages (Vulkan core specification, + // Required Limits table). Apple's Metal Feature Set Tables likewise guarantee at least 16 sampler-state + // argument-table entries per graphics function. + // + // Do not spend that entire guaranteed floor here: Skia composes a runtime effect into a larger fragment + // program with paint, blend, coverage, and clip-mask resources that ProgramMetrics cannot see. Twelve + // reserves four guaranteed slots for that surrounding program. A genuinely unsupported backend remains + // outside this supported-family guarantee even when it reaches the Portable profile. + // + // ProgramMetrics increments samplers and children together for every resource, starting with one of each + // for the implicit source. Their effective limit is therefore always the smaller value. Keep both at 12 + // instead of advertising Metal's larger texture-table limit, which this accounting model cannot exercise. + (int maxSamplers, int maxChildren) = capabilityClass switch + { + SkslBackendCapabilityClass.Portable => (12, 12), + SkslBackendCapabilityClass.Vulkan => (12, 12), + SkslBackendCapabilityClass.Metal => (12, 12), + SkslBackendCapabilityClass.SpirvVulkan => (1, 1), + _ => throw new ArgumentOutOfRangeException(nameof(capabilityClass)), + }; + + int maxStages = capabilityClass == SkslBackendCapabilityClass.SpirvVulkan + ? 1 + : MaxStages; + // Vulkan guarantees 128 push-constant bytes. The native source mapping reserves one vec4, leaving seven + // vec4 slots for description uniforms without relying on a larger device-specific limit. + int maxUniformVectors = capabilityClass == SkslBackendCapabilityClass.SpirvVulkan + ? 7 + : MaxUniformVectors; + + return new SkslBackendBudget( + capabilityClass, + maxStages, + maxUniformVectors, + maxSamplers, + maxChildren, + MaxSourceBytes, + MaxProgramTokens); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Planning/StructuralPlanCache.cs b/src/Beutl.Engine/Graphics/Rendering/Planning/StructuralPlanCache.cs new file mode 100644 index 0000000000..37a4344041 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/Planning/StructuralPlanCache.cs @@ -0,0 +1,682 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Effects; + +namespace Beutl.Graphics.Rendering; + +internal readonly record struct StructuralPlanCacheStatistics( + long Hits, + long Misses, + long Compilations, + long Replacements, + int RetainedPlans); + +/// +/// Retains the last structural request family for a renderer. Each stable depth-first family slot keeps +/// one candidate; hashes only select that candidate and the complete structural identity must still compare +/// equal before a plan is rebound to a new request. +/// +internal sealed class StructuralPlanCache : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _entries = []; + private long _hits; + private long _misses; + private long _compilations; + private long _replacements; + private bool _disposed; + + public StructuralPlanCacheStatistics Statistics + { + get + { + lock (_gate) + { + return new StructuralPlanCacheStatistics( + _hits, + _misses, + _compilations, + _replacements, + _entries.Count); + } + } + } + + public ExecutionIslandPlan GetOrCompile( + StructuralPlanIdentity identity, + RecordedRenderGraph graph, + Func compile, + int? bucketHashOverride = null, + int familySlot = 0) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(compile); + ArgumentOutOfRangeException.ThrowIfNegative(familySlot); + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + int bucketHash = bucketHashOverride ?? identity.GetHashCode(); + if (_entries.TryGetValue(familySlot, out Entry? entry) + && entry.BucketHash == bucketHash + && entry.Identity.Equals(identity)) + { + _hits++; + return entry.Template.Bind(graph); + } + + _misses++; + ExecutionIslandPlan compiled = compile(); + StructuralExecutionPlanTemplate template = StructuralExecutionPlanTemplate.Create(compiled, graph); + if (entry is not null) + _replacements++; + _entries[familySlot] = new Entry(bucketHash, identity, template); + _compilations++; + return compiled; + } + } + + public void RetainFamilySlots(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (int slot in _entries.Keys.Where(slot => slot >= count).ToArray()) + _entries.Remove(slot); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + + _disposed = true; + _entries.Clear(); + } + } + + private sealed record Entry( + int BucketHash, + StructuralPlanIdentity Identity, + StructuralExecutionPlanTemplate Template); +} + +/// +/// Complete parameter-independent identity for one recorded request graph. +/// +internal sealed class StructuralPlanIdentity : IEquatable +{ + private readonly RenderRequestPlanIdentity _request; + private readonly SkslBackendBudget _shaderBudget; + private readonly StructuralFragmentIdentity[] _fragments; + private readonly int[] _publicationRoots; + private readonly StructuralCacheBoundaryIdentity[] _cacheBoundaries; + private readonly StructuralPlanIdentity[] _nestedRequests; + + private StructuralPlanIdentity( + RenderRequestPlanIdentity request, + SkslBackendBudget shaderBudget, + StructuralFragmentIdentity[] fragments, + int[] publicationRoots, + StructuralCacheBoundaryIdentity[] cacheBoundaries, + StructuralPlanIdentity[] nestedRequests) + { + _request = request; + _shaderBudget = shaderBudget; + _fragments = fragments; + _publicationRoots = publicationRoots; + _cacheBoundaries = cacheBoundaries; + _nestedRequests = nestedRequests; + } + + public static StructuralPlanIdentity Create( + RenderRequestPlanIdentity request, + RecordedRenderGraph graph, + SkslBackendBudget shaderBudget, + RenderCacheResolution? cacheResolution = null) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(shaderBudget); + + RenderFragmentReference[] references = new RenderFragmentReference[graph.Fragments.Length]; + var indexes = new Dictionary( + graph.Fragments.Length, + ReferenceEqualityComparer.Instance); + for (int index = 0; index < graph.Fragments.Length; index++) + { + RecordedRenderFragment recorded = graph.Fragments[index]; + if (recorded.Id.RequestId != graph.RequestId || recorded.Id.Value != index + 1L) + throw new InvalidOperationException("A recorded fragment has a non-canonical graph ID."); + if (recorded.Payload is not RenderFragmentReference reference || reference.Id != recorded.Id) + { + throw new InvalidOperationException( + "A recorded fragment is missing its canonical semantic reference."); + } + + references[index] = reference; + indexes.Add(reference, index); + } + + var fragments = new StructuralFragmentIdentity[references.Length]; + for (int index = 0; index < references.Length; index++) + fragments[index] = StructuralFragmentIdentity.Create(references[index], indexes); + + int[] publicationRoots = graph.PublicationRoots + .Select(id => GetFragmentIndex(id, graph)) + .ToArray(); + StructuralCacheBoundaryIdentity[] cacheBoundaries = cacheResolution is null + ? graph.CacheCandidates + .Select(candidate => new StructuralCacheBoundaryIdentity( + GetFragmentIndex(candidate.FragmentId, graph), + RenderCacheResolutionKind.Bypass)) + .ToArray() + : cacheResolution.Decisions + .Where(static decision => decision.Kind is RenderCacheResolutionKind.Hit + or RenderCacheResolutionKind.MissCapture) + .Select(decision => new StructuralCacheBoundaryIdentity( + GetFragmentIndex(decision.Candidate.FragmentId, graph), + decision.Kind)) + .ToArray(); + StructuralPlanIdentity[] nestedRequests = graph.NestedRequests + .Select(nested => Create( + nested.Request.Options.PlanIdentity, + nested.Graph, + shaderBudget)) + .ToArray(); + + return new StructuralPlanIdentity( + request, + shaderBudget, + fragments, + publicationRoots, + cacheBoundaries, + nestedRequests); + } + + public bool Equals(StructuralPlanIdentity? other) + => other is not null + && _request.Equals(other._request) + && _shaderBudget.Equals(other._shaderBudget) + && _fragments.AsSpan().SequenceEqual(other._fragments) + && _publicationRoots.AsSpan().SequenceEqual(other._publicationRoots) + && _cacheBoundaries.AsSpan().SequenceEqual(other._cacheBoundaries) + && _nestedRequests.AsSpan().SequenceEqual(other._nestedRequests); + + public override bool Equals(object? obj) + => obj is StructuralPlanIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(_request); + hash.Add(_shaderBudget); + foreach (StructuralFragmentIdentity fragment in _fragments) + hash.Add(fragment); + foreach (int root in _publicationRoots) + hash.Add(root); + foreach (StructuralCacheBoundaryIdentity boundary in _cacheBoundaries) + hash.Add(boundary); + foreach (StructuralPlanIdentity nested in _nestedRequests) + hash.Add(nested); + return hash.ToHashCode(); + } + + private static int GetFragmentIndex(RenderFragmentId id, RecordedRenderGraph graph) + { + if (id.RequestId != graph.RequestId || id.Value <= 0 || id.Value > graph.Fragments.Length) + throw new InvalidOperationException("A structural-plan fragment ID does not belong to its graph."); + return checked((int)id.Value - 1); + } +} + +internal readonly record struct StructuralCacheBoundaryIdentity( + int FragmentIndex, + RenderCacheResolutionKind Kind); + +internal sealed class StructuralFragmentIdentity : IEquatable +{ + private readonly RenderFragmentKind _kind; + private readonly RenderValueCardinality _cardinality; + private readonly bool _contributesValuesToTarget; + private readonly bool _canBeUsedAsValueInput; + private readonly bool _hasTargetEffects; + private readonly bool _potentiallyWritesTarget; + private readonly bool _hasOpaqueExternalWork; + private readonly int[] _inputs; + private readonly object[] _components; + + private StructuralFragmentIdentity( + RenderFragmentReference reference, + int[] inputs, + object[] components) + { + _kind = reference.Kind; + _cardinality = reference.ValueCardinality; + _contributesValuesToTarget = reference.ContributesValuesToTarget; + _canBeUsedAsValueInput = reference.CanBeUsedAsValueInput; + _hasTargetEffects = reference.HasTargetEffects; + _potentiallyWritesTarget = reference.PotentiallyWritesTarget; + _hasOpaqueExternalWork = reference.HasOpaqueExternalWork; + _inputs = inputs; + _components = components; + } + + public static StructuralFragmentIdentity Create( + RenderFragmentReference reference, + IReadOnlyDictionary indexes) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(indexes); + int[] inputs = new int[reference.Inputs.Length]; + for (int index = 0; index < reference.Inputs.Length; index++) + { + if (!indexes.TryGetValue(reference.Inputs[index], out inputs[index])) + { + throw new InvalidOperationException( + "A structural-plan input is not part of the recorded graph."); + } + } + + var components = new List(); + if (reference.Kind is RenderFragmentKind.Shader or RenderFragmentKind.Opacity + && reference.Inputs.Length == 1) + { + components.Add(ExecutionIslandPlanner.HasCompatibleMergeScale( + reference.Inputs[0], + reference)); + if (reference.Kind == RenderFragmentKind.Opacity) + { + components.Add(ExecutionIslandPlanner.HasCompatibleOpacityFusionMetadata( + reference.Inputs[0], + reference)); + } + } + AddPayloadComponents(reference, components); + return new StructuralFragmentIdentity(reference, inputs, components.ToArray()); + } + + public bool Equals(StructuralFragmentIdentity? other) + { + if (other is null + || _kind != other._kind + || !_cardinality.Equals(other._cardinality) + || _contributesValuesToTarget != other._contributesValuesToTarget + || _canBeUsedAsValueInput != other._canBeUsedAsValueInput + || _hasTargetEffects != other._hasTargetEffects + || _potentiallyWritesTarget != other._potentiallyWritesTarget + || _hasOpaqueExternalWork != other._hasOpaqueExternalWork + || !_inputs.AsSpan().SequenceEqual(other._inputs) + || _components.Length != other._components.Length) + { + return false; + } + + for (int index = 0; index < _components.Length; index++) + { + if (!Equals(_components[index], other._components[index])) + return false; + } + return true; + } + + public override bool Equals(object? obj) + => obj is StructuralFragmentIdentity other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(_kind); + hash.Add(_cardinality); + hash.Add(_contributesValuesToTarget); + hash.Add(_canBeUsedAsValueInput); + hash.Add(_hasTargetEffects); + hash.Add(_potentiallyWritesTarget); + hash.Add(_hasOpaqueExternalWork); + foreach (int input in _inputs) + hash.Add(input); + foreach (object component in _components) + hash.Add(component); + return hash.ToHashCode(); + } + + private static void AddPayloadComponents( + RenderFragmentReference reference, + ICollection components) + { + switch (reference.Payload) + { + case null: + return; + case OpacityRenderFragmentPayload opacity: + components.Add(opacity.FusionDescription.StructuralIdentity); + components.Add(opacity.Opacity is >= 0 and <= 1); + return; + case BlendRenderFragmentPayload: + return; + case OpacityMaskRenderFragmentPayload mask: + AddResourceTypes([mask.Mask], components); + return; + case ShaderRenderFragmentPayload shader: + components.Add(shader.Description.StructuralIdentity); + AddWorkingScalePolicy(shader.WorkingScalePolicy, components); + return; + case GeometryRenderFragmentPayload geometry: + components.Add(geometry.Description.StructuralIdentity); + AddWorkingScalePolicy(geometry.WorkingScalePolicy, components); + return; + case LayerRenderFragmentPayload layer: + components.Add(layer.Domain.HasValue); + components.Add(layer.DomainIsQueryFootprint); + return; + case TargetLayerScopeRenderFragmentPayload targetLayer: + components.Add(targetLayer.Region.Kind != TargetRegionKind.Empty); + return; + case OpaqueRenderFragmentPayload opaque: + components.Add(opaque.Description.GetStructuralIdentity(opaque.Topology)); + components.Add(opaque.Description.Bounds.StructuralIdentity); + components.Add(opaque.Description.HitTest.StructuralIdentity); + components.Add(opaque.Description.Scale.StructuralIdentity); + components.Add(opaque.Description.InputDemand.StructuralIdentity); + components.Add(opaque.InputReadbacks.Count); + foreach (RenderInputReadback inputReadback in opaque.InputReadbacks) + { + components.Add(inputReadback.StructuralKind); + components.Add(inputReadback.ValueIndices.Count); + foreach (int valueIndex in inputReadback.ValueIndices) + components.Add(valueIndex); + } + AddResourceTypes(opaque.Description.Resources, components); + return; + case FilterEffectSegmentRenderFragmentPayload legacy: + AddWorkingScalePolicy(legacy.WorkingScalePolicy, components); + components.Add(legacy.StreamInputCount); + // Whether the segment holds an imperative callback decides why its island ends, and the + // boundary reason is part of the plan being cached. Two segments that agree on everything + // else would otherwise share a plan whose classification contradicts one of their graphs. + components.Add(legacy.HasImperativeItem); + return; + case MaterializedInputRenderFragmentPayload input: + components.Add(input.Description.HitTest.StructuralIdentity); + return; + case TargetCaptureRenderFragmentPayload capture: + AddTargetCaptureComponents(capture.Description, components); + return; + case BuiltInBackdropCaptureRenderFragmentPayload capture: + AddTargetCaptureComponents(capture.Description, components); + return; + case TargetScopeRenderFragmentPayload scope: + AddTargetScopeComponents(scope.Description, components); + return; + case RawTargetScopeRenderFragmentPayload scope: + components.Add(scope.Description.DefinitionFingerprint); + components.Add(scope.Description.Bounds.StructuralIdentity); + components.Add(scope.Description.HitTest.StructuralIdentity); + components.Add(scope.Description.Scale.StructuralIdentity); + AddResourceTypes(scope.Description.Resources, components); + return; + case RawTargetCommandRenderFragmentPayload command: + components.Add(command.Description.DefinitionFingerprint); + components.Add(command.Description.HitTest.StructuralIdentity); + AddResourceTypes(command.Description.Resources, components); + return; + case TargetCommandRenderFragmentPayload command: + components.Add(command.Description.DefinitionFingerprint); + components.Add(command.Description.Access); + components.Add(command.InputReadbacks.Count); + foreach (RenderInputReadback inputReadback in command.InputReadbacks) + { + components.Add(inputReadback.StructuralKind); + components.Add(inputReadback.ValueIndices.Count); + foreach (int valueIndex in inputReadback.ValueIndices) + components.Add(valueIndex); + } + components.Add(command.Description.HitTest.StructuralIdentity); + AddResourceTypes(command.Description.Resources, components); + return; + default: + throw new InvalidOperationException( + $"Render fragment kind '{reference.Kind}' has an unrecognized structural payload."); + } + } + + private static void AddTargetCaptureComponents( + TargetCaptureDescription description, + ICollection components) + { + components.Add(description.HitTest.StructuralIdentity); + components.Add(description.Scale.StructuralIdentity); + } + + private static void AddWorkingScalePolicy( + FilterEffectWorkingScalePolicy? policy, + ICollection components) + { + components.Add(policy.HasValue); + if (policy is { } value) + components.Add(value.StructuralIdentity); + } + + private static void AddTargetScopeComponents( + TargetScopeDescription description, + ICollection components) + { + components.Add(description.DefinitionFingerprint); + components.Add(description.Bounds.StructuralIdentity); + components.Add(description.HitTest.StructuralIdentity); + components.Add(description.Scale.StructuralIdentity); + components.Add(description.IsValueReplayMap); + components.Add(description.TransformSpace); + components.Add(description.BuiltInBackdropCapturesBackingTarget); + AddResourceTypes(description.Resources, components); + } + + private static void AddResourceTypes( + IReadOnlyList resources, + ICollection components) + { + components.Add(resources.Count); + foreach (RenderResource resource in resources) + components.Add(resource.GetType()); + } + + private static void AddResourceTypes( + IReadOnlyList resources, + ICollection components) + { + components.Add(resources.Count); + foreach (RenderResourceBinding binding in resources) + { + components.Add(binding.Slot.ValueType); + } + } +} + +internal sealed class StructuralExecutionPlanTemplate +{ + private readonly int _fragmentCount; + private readonly IslandTemplate[] _islands; + private readonly BoundaryTemplate[] _boundaries; + + private StructuralExecutionPlanTemplate( + int fragmentCount, + IslandTemplate[] islands, + BoundaryTemplate[] boundaries) + { + _fragmentCount = fragmentCount; + _islands = islands; + _boundaries = boundaries; + } + + public static StructuralExecutionPlanTemplate Create( + ExecutionIslandPlan plan, + RecordedRenderGraph graph) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(graph); + IslandTemplate[] islands = plan.Islands + .Select(island => IslandTemplate.Create(island, graph)) + .ToArray(); + BoundaryTemplate[] boundaries = plan.Boundaries + .Select(boundary => BoundaryTemplate.Create(boundary, graph)) + .ToArray(); + return new StructuralExecutionPlanTemplate(graph.Fragments.Length, islands, boundaries); + } + + public ExecutionIslandPlan Bind(RecordedRenderGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + if (graph.Fragments.Length != _fragmentCount) + { + throw new InvalidOperationException( + "A cached structural plan cannot bind to a graph with a different fragment count."); + } + + RenderFragmentReference[] references = graph.Fragments + .Select(static fragment => fragment.Payload as RenderFragmentReference + ?? throw new InvalidOperationException( + "A cached structural plan requires executable semantic fragment references.")) + .ToArray(); + ImmutableArray islands = + [.. _islands.Select(template => template.Bind(graph, references))]; + ImmutableArray boundaries = + [.. _boundaries.Select(template => template.Bind(graph))]; + return new ExecutionIslandPlan(islands, boundaries); + } + + private sealed record IslandTemplate( + int Id, + ExecutionIslandKind Kind, + int[] Fragments, + bool PlansGpuPass, + ShaderRunTemplate? ShaderRun) + { + public static IslandTemplate Create( + ExecutionIsland island, + RecordedRenderGraph graph) + => new( + island.Id.Value, + island.Kind, + island.Fragments.Select(id => GetFragmentIndex(id, graph)).ToArray(), + island.PlansGpuPass, + island.ShaderRun is { } run ? ShaderRunTemplate.Create(run, graph) : null); + + public ExecutionIsland Bind( + RecordedRenderGraph graph, + RenderFragmentReference[] references) + => new( + new ExecutionIslandId(Id), + Kind, + [.. Fragments.Select(index => graph.Fragments[index].Id)], + PlansGpuPass, + ShaderRun?.Bind(graph, references)); + } + + private sealed record ShaderRunTemplate( + int Id, + int Input, + int Output, + StageTemplate[] Stages, + SkslMergedProgram Program, + ShaderRunCoverageSource CoverageSource) + { + public static ShaderRunTemplate Create( + CompiledShaderRun run, + RecordedRenderGraph graph) + => new( + run.Id.Value, + GetFragmentIndex(GetId(run.Input), graph), + GetFragmentIndex(GetId(run.Output), graph), + run.Stages.Select(stage => StageTemplate.Create(stage, graph)).ToArray(), + run.Program, + run.CoverageSource); + + public CompiledShaderRun Bind( + RecordedRenderGraph graph, + RenderFragmentReference[] references) + => new( + new CompiledShaderRunId(Id), + references[Input], + references[Output], + [.. Stages.Select(stage => stage.Bind(graph, references))], + Program, + CoverageSource); + } + + private sealed record StageTemplate( + int Fragment, + RenderFragmentKind Kind, + SkslCoverageBehavior CoverageBehavior, + int ProgramStageIndex) + { + public static StageTemplate Create( + CompiledShaderStage stage, + RecordedRenderGraph graph) + => new( + GetFragmentIndex(stage.FragmentId, graph), + stage.Kind, + stage.CoverageBehavior, + stage.ProgramStageIndex); + + public CompiledShaderStage Bind( + RecordedRenderGraph graph, + RenderFragmentReference[] references) + { + RenderFragmentReference reference = references[Fragment]; + if (reference.Kind != Kind) + throw new InvalidOperationException("A cached Shader stage changed semantic kind."); + ShaderDescription description = Kind switch + { + RenderFragmentKind.Shader + => ((ShaderRenderFragmentPayload)reference.Payload!).Description, + RenderFragmentKind.Opacity + => ((OpacityRenderFragmentPayload)reference.Payload!).FusionDescription, + _ => throw new InvalidOperationException("A cached Shader run contains a non-Shader stage."), + }; + return new CompiledShaderStage( + graph.Fragments[Fragment].Id, + reference, + Kind, + description, + CoverageBehavior, + ProgramStageIndex); + } + } + + private sealed record BoundaryTemplate( + int? Before, + int? After, + ExecutionIslandBoundaryReason Reason, + ImmutableArray BackendLimits) + { + public static BoundaryTemplate Create( + ExecutionIslandBoundary boundary, + RecordedRenderGraph graph) + => new( + boundary.BeforeFragmentId is { } before ? GetFragmentIndex(before, graph) : null, + boundary.AfterFragmentId is { } after ? GetFragmentIndex(after, graph) : null, + boundary.Reason, + boundary.BackendLimits); + + public ExecutionIslandBoundary Bind(RecordedRenderGraph graph) + => new( + Before is { } before ? graph.Fragments[before].Id : null, + After is { } after ? graph.Fragments[after].Id : null, + Reason, + BackendLimits); + } + + private static RenderFragmentId GetId(RenderFragmentReference reference) + => reference.Id + ?? throw new InvalidOperationException("A cached plan fragment has not been committed."); + + private static int GetFragmentIndex(RenderFragmentId id, RecordedRenderGraph graph) + { + if (id.RequestId != graph.RequestId || id.Value <= 0 || id.Value > graph.Fragments.Length) + throw new InvalidOperationException("A cached plan fragment ID does not belong to its graph."); + return checked((int)id.Value - 1); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs index 0c16662b8a..d51e176a07 100644 --- a/src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs @@ -2,16 +2,27 @@ public sealed class PushRenderNode : ContainerRenderNode { - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - return context.Input.Select(r => - RenderNodeOperation.CreateDecorator(r, canvas => + private static readonly TargetScopeDefinition s_definition = + TargetScopeDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => { using (canvas.Push()) { - r.Render(canvas); + session.ReplayInput(); } - })) - .ToArray(); + }), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + + public override void Process(RenderNodeContext context) + { + context.PublishMappedInputs( + s_definition.Call(default), + static (context, input, value) => context.TargetScope(input, value)); } + + private readonly record struct PushState; } diff --git a/src/Beutl.Engine/Graphics/Rendering/RasterShaderMapping.cs b/src/Beutl.Engine/Graphics/Rendering/RasterShaderMapping.cs new file mode 100644 index 0000000000..36623f5606 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RasterShaderMapping.cs @@ -0,0 +1,163 @@ +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +internal static class RasterShaderMapping +{ + private static readonly SKSamplingOptions s_linearSampling = + new(SKFilterMode.Linear, SKMipmapMode.None); + private static readonly SKSamplingOptions s_cubicSampling = + new(SKCubicResampler.Mitchell); + + public static SKSamplingOptions SamplingFor(float sourceScale, float destinationScale) + => destinationScale > sourceScale ? s_cubicSampling : s_linearSampling; + + /// + /// Resolves the complete-output device frame a WholeSource stage is evaluated in, given the destination + /// footprint the renderer actually allocated for the requested region. + /// + public static ShaderEvaluationFrame CreateWholeSourceFrame( + Rect outputBounds, + PixelRect destinationDeviceBounds, + Rect destinationRasterBounds, + float workingScale) + { + var deviceGridOffset = new Vector( + (destinationDeviceBounds.X / workingScale) - destinationRasterBounds.X, + (destinationDeviceBounds.Y / workingScale) - destinationRasterBounds.Y); + PixelRect deviceBounds = PixelRect.FromRect( + outputBounds.Translate(deviceGridOffset), + workingScale); + return new ShaderEvaluationFrame( + deviceBounds, + deviceBounds.ToRect(workingScale).Translate(-deviceGridOffset), + destinationDeviceBounds.Position - deviceBounds.Position); + } + + public static SKShader CreateSemanticImageShader( + SKImage image, + GRRecordingContext? recordingContext, + Rect sourceBounds, + float sourceScale, + PixelRect sourceDeviceBounds, + Rect sourceRasterBounds, + float destinationScale, + Rect destinationRasterBounds, + SKShaderTileMode tileMode) + { + ArgumentNullException.ThrowIfNull(image); + var imageBounds = new PixelRect(new PixelSize(image.Width, image.Height)); + PixelRect semanticSubset; + Rect semanticRasterBounds; + Rect canonicalRasterBounds = sourceDeviceBounds.ToRect(sourceScale); + if (sourceRasterBounds == canonicalRasterBounds) + { + PixelRect semanticDeviceBounds = PixelRect.FromRect(sourceBounds, sourceScale); + if (!sourceDeviceBounds.Contains(semanticDeviceBounds)) + { + throw new ArgumentException( + "The source device bounds must contain the complete semantic source bounds.", + nameof(sourceDeviceBounds)); + } + + semanticSubset = new PixelRect( + semanticDeviceBounds.X - sourceDeviceBounds.X, + semanticDeviceBounds.Y - sourceDeviceBounds.Y, + semanticDeviceBounds.Width, + semanticDeviceBounds.Height); + semanticRasterBounds = semanticDeviceBounds.ToRect(sourceScale); + } + else + { + // Round the semantic extent on the shared device grid. Subtracting raster-local + // floating-point origins first can move an exact edge across a pixel boundary and + // include a transparent apron in the subset that Clamp treats as the source edge. + Vector deviceGridOffset = canonicalRasterBounds.Position - sourceRasterBounds.Position; + PixelRect semanticDeviceBounds = PixelRect.FromRect( + sourceBounds.Translate(deviceGridOffset), + sourceScale); + semanticSubset = new PixelRect( + semanticDeviceBounds.X - sourceDeviceBounds.X, + semanticDeviceBounds.Y - sourceDeviceBounds.Y, + semanticDeviceBounds.Width, + semanticDeviceBounds.Height); + semanticRasterBounds = semanticDeviceBounds + .ToRect(sourceScale) + .Translate(-deviceGridOffset); + } + + if (!imageBounds.Contains(semanticSubset)) + { + throw new ArgumentException( + "The source raster bounds must contain the complete semantic source bounds.", + nameof(sourceRasterBounds)); + } + + SKMatrix localMatrix = CreateLocalMatrix( + destinationScale, + sourceScale, + destinationRasterBounds, + semanticRasterBounds); + if (semanticSubset == imageBounds) + { + return image.ToShader( + tileMode, + tileMode, + SKSamplingOptions.Default, + localMatrix); + } + + using SKImage subset = recordingContext is null + ? image.Subset(semanticSubset.ToSKRectI()) + : image.Subset(recordingContext, semanticSubset.ToSKRectI()); + if (subset is null) + throw new InvalidOperationException("The semantic shader source subset could not be created."); + return subset.ToShader( + tileMode, + tileMode, + SKSamplingOptions.Default, + localMatrix); + } + + public static SKMatrix CreateLocalMatrix( + float destinationScale, + float sourceScale, + Rect destinationRasterBounds, + Rect sourceRasterBounds) + { + float scale = destinationScale / sourceScale; + float offsetX = (float)( + -(destinationRasterBounds.X - sourceRasterBounds.X) * destinationScale); + float offsetY = (float)( + -(destinationRasterBounds.Y - sourceRasterBounds.Y) * destinationScale); + return new SKMatrix( + scale, + 0, + offsetX, + 0, + scale, + offsetY, + 0, + 0, + 1); + } +} + +/// +/// Describes the device frame a shader stage's coord argument is expressed in. +/// +/// The frame's footprint on the composition-device grid. +/// The frame's stage-local logical footprint. +/// +/// The device offset added to a destination-local coordinate to obtain the stage's coord. It is non-zero +/// only when a WholeSource stage was asked for a strict subset of its complete output. +/// +internal readonly record struct ShaderEvaluationFrame( + PixelRect DeviceBounds, + Rect RasterBounds, + PixelPoint FragmentOrigin) +{ + public static ShaderEvaluationFrame Destination(PixelRect deviceBounds, Rect rasterBounds) + => new(deviceBounds, rasterBounds, default); +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs index f4144ca002..d80897ba12 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs @@ -21,21 +21,53 @@ public bool Update(Rect clip, ClipOperation operation) changed = true; } - HasChanges = true; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return context.Input.Select(r => - { - return RenderNodeOperation.CreateDecorator(r, canvas => + Rect clip = Clip; + ClipOperation operation = Operation; + var metadata = new RectClipMetadata(clip, operation); + TargetScopeDefinition definition = TargetScopeDefinition.Create( + static (session, state) => session.Canvas.Use(canvas => { - using (canvas.PushClip(Clip, Operation)) + using (canvas.PushClip(state.Clip, state.Operation)) { - r.Render(canvas); + session.ReplayInput(); } - }); - }).ToArray(); + }), + RenderBoundsContract.Create( + metadata.TransformBounds, + metadata.GetRequiredInputBounds), + RenderHitTestContract.Custom(metadata.HitTest), + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + + context.PublishMappedInputs( + definition.Call(metadata), + static (context, input, value) => context.TargetScope(input, value)); + } + + private readonly record struct RectClipMetadata(Rect Clip, ClipOperation Operation) + { + public Rect TransformBounds(Rect value) + => Operation == ClipOperation.Intersect ? value.Intersect(Clip) : value; + + public Rect GetRequiredInputBounds(Rect value) + => Operation == ClipOperation.Intersect ? value.Intersect(Clip) : value; + + public bool HitTest(RenderHitTestContext context, Point point) + { + bool insideClip = Clip.Contains(point); + bool clipAcceptsPoint = Operation == ClipOperation.Intersect ? insideClip : !insideClip; + return clipAcceptsPoint && context.Inputs.Any(input => input.HitTest(point)); + } } } diff --git a/src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs index 628ac18909..13ff85c293 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs @@ -1,4 +1,5 @@ -using Beutl.Media; +using Beutl.Engine; +using Beutl.Media; namespace Beutl.Graphics.Rendering; @@ -21,33 +22,61 @@ public bool Update(Rect rect, Brush.Resource? fill, Pen.Resource? pen) changed = true; } + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return - [ - RenderNodeOperation.CreateLambda(PenHelper.GetBounds(Rect, Pen?.Resource), - canvas => canvas.DrawRectangle(Rect, Fill?.Resource, Pen?.Resource), HitTest) - ]; + Rect rect = Rect; + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + Rect bounds = PenHelper.GetBounds(rect, pen); + if (bounds.Width == 0 || bounds.Height == 0) + return; + + var hitTestState = new RectangleHitTestState( + rect, + fill is not null, + pen?.StrokeAlignment ?? StrokeAlignment.Inside, + pen?.Thickness ?? 0); + + var state = (rect, hitTestState); + context.Publish(context.PaintedSource( + state, + draw: static (canvas, fill, pen, state) => + canvas.DrawRectangle(state.rect, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.Custom((_, point) => hitTestState.HitTest(point)), + scale: RenderScaleContract.Vector)); } - private bool HitTest(Point point) + private readonly record struct RectangleHitTestState( + Rect Rect, + bool HasFill, + StrokeAlignment StrokeAlignment, + float Thickness) { - StrokeAlignment alignment = Pen?.Resource.StrokeAlignment ?? StrokeAlignment.Inside; - float thickness = Pen?.Resource.Thickness ?? 0; - thickness = PenHelper.GetRealThickness(alignment, thickness); - - if (Fill != null) - { - Rect rect = Rect.Inflate(thickness); - return rect.ContainsExclusive(point); - } - else + public bool HitTest(Point point) { - Rect borderRect = Rect.Inflate(thickness); - Rect emptyRect = Rect.Deflate(thickness); + float realThickness = PenHelper.GetRealThickness(StrokeAlignment, Thickness); + + if (HasFill) + { + Rect rect = Rect.Inflate(realThickness); + return rect.ContainsExclusive(point); + } + + Rect borderRect = Rect.Inflate(realThickness); + Rect emptyRect = Rect.Deflate(realThickness); return borderRect.ContainsExclusive(point) && !emptyRect.ContainsExclusive(point); } } diff --git a/src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs index 2635b619aa..d65f7cc4cc 100644 --- a/src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs @@ -1,46 +1,43 @@ namespace Beutl.Graphics.Rendering; // 単一の子ノードを参照するだけで、Disposeしないノード -public class ReferencesChildRenderNode(RenderNode? child) : RenderNode +public class ReferencesChildRenderNode : RenderNode { - public RenderNode? Child { get; private set; } = child; + // An array, not a plain field: ChildNodes hands out a span, and Update runs once per frame. + private RenderNode[] _child; + + public ReferencesChildRenderNode(RenderNode? child) + { + _child = child is null ? [] : [child]; + } + + public RenderNode? Child => _child.Length == 0 ? null : _child[0]; + + public override ReadOnlySpan ChildNodes => _child; public bool Update(RenderNode? item) { if (Child != item) { HasChanges = true; + _child = item is null ? [] : [item]; } HasChanges |= item?.HasChanges == true; - Child = item; return HasChanges; } - public override void PrepareForProcess(ImmediateCanvas canvas) + public override void Process(RenderNodeContext context) { - if (Child is { IsDisposed: false }) + if (Child is { IsDisposed: false } child) { - Child.PrepareForProcess(canvas); + context.PublishRange(context.RecordSubtree(child)); } } - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - if (Child != null && !Child.IsDisposed) - { - // Forward the working-scale ceiling into the nested pull. - var processor = new RenderNodeProcessor( - Child, context.IsRenderCacheEnabled, context.OutputScale, context.MaxWorkingScale); - return processor.PullToRoot(); - } - - return []; - } - protected override void OnDispose(bool disposing) { - Child = null; + _child = []; } } diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderBoundsContract.cs b/src/Beutl.Engine/Graphics/Rendering/RenderBoundsContract.cs new file mode 100644 index 0000000000..f7b5372e7a --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderBoundsContract.cs @@ -0,0 +1,177 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Declares conservative forward output bounds and backward required-input bounds for recorded work. +/// +public readonly struct RenderBoundsContract +{ + private readonly Func? _transformBounds; + private readonly Func? _getRequiredInputBounds; + private readonly object? _structuralIdentity; + + private RenderBoundsContract( + Func transformBounds, + Func getRequiredInputBounds, + bool requiresFullInput, + object structuralIdentity) + { + _transformBounds = transformBounds; + _getRequiredInputBounds = getRequiredInputBounds; + RequiresFullInput = requiresFullInput; + _structuralIdentity = structuralIdentity; + } + + public static RenderBoundsContract Identity { get; } = new( + IdentityMap, + IdentityMap, + requiresFullInput: false, + RenderBoundsStructuralIdentity.Identity); + + public static RenderBoundsContract FullInput { get; } = new( + IdentityMap, + IdentityMap, + requiresFullInput: true, + RenderBoundsStructuralIdentity.FullInput); + + public bool RequiresFullInput { get; } + + public static RenderBoundsContract Create( + Func transformBounds, + Func getRequiredInputBounds) + { + ArgumentNullException.ThrowIfNull(transformBounds); + ArgumentNullException.ThrowIfNull(getRequiredInputBounds); + RenderDescriptionValidation.ValidatePureMetadataCallback( + transformBounds, + nameof(transformBounds)); + RenderDescriptionValidation.ValidatePureMetadataCallback( + getRequiredInputBounds, + nameof(getRequiredInputBounds)); + return new RenderBoundsContract( + transformBounds, + getRequiredInputBounds, + requiresFullInput: false, + RenderBoundsStructuralIdentity.Create(transformBounds, getRequiredInputBounds)); + } + + public static RenderBoundsContract CreateFullInput( + Func transformBounds) + { + ArgumentNullException.ThrowIfNull(transformBounds); + RenderDescriptionValidation.ValidatePureMetadataCallback( + transformBounds, + nameof(transformBounds)); + return new RenderBoundsContract( + transformBounds, + IdentityMap, + requiresFullInput: true, + RenderBoundsStructuralIdentity.CreateFullInput(transformBounds)); + } + + public Rect TransformBounds(Rect inputBounds) + { + ThrowIfNotInitialized(); + RenderRectValidation.ThrowIfInvalidInput(inputBounds, nameof(inputBounds)); + Rect result = _transformBounds!(inputBounds); + RenderRectValidation.ThrowIfInvalidResult(result, "The forward bounds mapping returned an invalid rectangle."); + return result; + } + + public Rect GetRequiredInputBounds(Rect requestedOutputBounds) + { + ThrowIfNotInitialized(); + RenderRectValidation.ThrowIfInvalidInput(requestedOutputBounds, nameof(requestedOutputBounds)); + Rect result = _getRequiredInputBounds!(requestedOutputBounds); + RenderRectValidation.ThrowIfInvalidResult(result, "The backward bounds mapping returned an invalid rectangle."); + return result; + } + + internal object StructuralIdentity + { + get + { + ThrowIfNotInitialized(); + return _structuralIdentity!; + } + } + + internal void ThrowIfUninitialized(string parameterName) + { + if (_transformBounds is null || _getRequiredInputBounds is null || _structuralIdentity is null) + { + throw new ArgumentException( + "default(RenderBoundsContract) is uninitialized; use Identity, FullInput, Create, or CreateFullInput.", + parameterName); + } + } + + private void ThrowIfNotInitialized() + { + if (_transformBounds is null || _getRequiredInputBounds is null || _structuralIdentity is null) + { + throw new InvalidOperationException( + "default(RenderBoundsContract) is uninitialized; use Identity, FullInput, Create, or CreateFullInput."); + } + } + + private static Rect IdentityMap(Rect value) => value; +} + +internal readonly record struct RenderBoundsStructuralIdentity( + RenderBoundsContractKind Kind, + object? ForwardMethod, + object? BackwardMethod) +{ + public static RenderBoundsStructuralIdentity Identity { get; } = + new(RenderBoundsContractKind.Identity, null, null); + + public static RenderBoundsStructuralIdentity FullInput { get; } = + new(RenderBoundsContractKind.FullInput, null, null); + + public static RenderBoundsStructuralIdentity Create( + Func transformBounds, + Func getRequiredInputBounds) + => new( + RenderBoundsContractKind.Custom, + transformBounds.Method, + getRequiredInputBounds.Method); + + public static RenderBoundsStructuralIdentity CreateFullInput( + Func transformBounds) + => new(RenderBoundsContractKind.CustomFullInput, transformBounds.Method, null); +} + +internal enum RenderBoundsContractKind : byte +{ + Identity, + FullInput, + Custom, + CustomFullInput, +} + +internal static class RenderRectValidation +{ + public static bool IsFiniteNonNegative(Rect value) + => float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Width) + && float.IsFinite(value.Height) + && value.Width >= 0 + && value.Height >= 0; + + public static void ThrowIfInvalidInput(Rect value, string parameterName) + { + if (!IsFiniteNonNegative(value)) + { + throw new ArgumentException( + "Bounds must be finite and have non-negative dimensions.", + parameterName); + } + } + + public static void ThrowIfInvalidResult(Rect value, string message) + { + if (!IsFiniteNonNegative(value)) + throw new InvalidOperationException(message); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderExecutionInput.cs b/src/Beutl.Engine/Graphics/Rendering/RenderExecutionInput.cs new file mode 100644 index 0000000000..57688510c3 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderExecutionInput.cs @@ -0,0 +1,605 @@ +using System.Runtime.ExceptionServices; + +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +/// Identifies one authored input handle's contiguous values in a flattened execution session. +/// The zero-based index of the first value in the session's input list. +/// The number of runtime values produced by the authored input handle. +public readonly record struct RenderExecutionInputRange(int StartIndex, int Count) +{ + /// Gets the exclusive end index in the session's input list. + public int EndIndex => checked(StartIndex + Count); + + internal static IReadOnlyList CopyAndValidate( + IReadOnlyList inputs, + IReadOnlyList inputRanges, + string parameterName) + { + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(inputRanges); + RenderExecutionInputRange[] copiedRanges = inputRanges.ToArray(); + int expectedStart = 0; + foreach (RenderExecutionInputRange range in copiedRanges) + { + if (range.StartIndex != expectedStart || range.Count < 0) + { + throw new ArgumentException( + "Execution input ranges must be non-negative, contiguous, and in authored order.", + parameterName); + } + + expectedStart = range.EndIndex; + } + + if (expectedStart != inputs.Count) + { + throw new ArgumentException( + "Execution input ranges must cover every flattened execution input exactly once.", + parameterName); + } + + return Array.AsReadOnly(copiedRanges); + } +} + +public sealed class RenderExecutionInput +{ + private readonly RenderExecutionSessionToken _token; + private readonly Rect _bounds; + private readonly EffectiveScale _effectiveScale; + private readonly PixelRect _deviceBounds; + private readonly Rect _rasterBounds; + private readonly Action _draw; + private readonly Action _drawDeviceSpace; + private readonly Func? _createShader; + private readonly Func? _createSnapshot; + private readonly bool _readbackDeclared; + private bool _snapshotUsed; + + internal RenderExecutionInput( + RenderExecutionSessionToken token, + Rect bounds, + EffectiveScale effectiveScale, + Action draw, + Action drawDeviceSpace, + Func? createShader, + Func? createSnapshot, + bool readbackDeclared) + : this( + token, + bounds, + effectiveScale, + PixelRect.FromRect(bounds, effectiveScale.Value), + draw, + drawDeviceSpace, + createShader, + createSnapshot, + readbackDeclared) + { + } + + internal RenderExecutionInput( + RenderExecutionSessionToken token, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Action draw, + Action drawDeviceSpace, + Func? createShader, + Func? createSnapshot, + bool readbackDeclared) + : this( + token, + bounds, + effectiveScale, + deviceBounds, + deviceBounds.ToRect(effectiveScale.Value), + draw, + drawDeviceSpace, + createShader, + createSnapshot, + readbackDeclared) + { + } + + internal RenderExecutionInput( + RenderExecutionSessionToken token, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Rect rasterBounds, + Action draw, + Action drawDeviceSpace, + Func? createShader, + Func? createSnapshot, + bool readbackDeclared) + { + ArgumentNullException.ThrowIfNull(token); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(bounds, nameof(bounds)); + if (effectiveScale.IsUnbounded) + { + throw new ArgumentException( + "An execution input requires a concrete effective scale.", + nameof(effectiveScale)); + } + + ArgumentNullException.ThrowIfNull(draw); + ArgumentNullException.ThrowIfNull(drawDeviceSpace); + if (readbackDeclared && createSnapshot is null) + { + throw new ArgumentException( + "Declared input readback requires a snapshot provider.", + nameof(createSnapshot)); + } + + _token = token; + _bounds = bounds; + _effectiveScale = effectiveScale; + _deviceBounds = ValidateDeviceBounds( + bounds, + effectiveScale.Value, + deviceBounds, + rasterBounds); + _rasterBounds = rasterBounds; + _draw = draw; + _drawDeviceSpace = drawDeviceSpace; + _createShader = createShader; + _createSnapshot = createSnapshot; + _readbackDeclared = readbackDeclared; + } + + internal RenderExecutionInput( + RenderExecutionSessionToken token, + Rect bounds, + EffectiveScale effectiveScale, + PixelRect deviceBounds, + Rect rasterBounds, + SKImage image, + Func? createSnapshot, + bool readbackDeclared) + : this( + token, + bounds, + effectiveScale, + deviceBounds, + rasterBounds, + (canvas, destination, paint, sampling) => + canvas.DrawExecutionInput(image, destination, paint, sampling), + (canvas, point) => canvas.DrawExecutionInputDeviceSpace(image, point), + (x, y) => image.ToShader( + x, + y, + SKSamplingOptions.Default, + SKMatrix.CreateScaleTranslation( + 1f / effectiveScale.Value, + 1f / effectiveScale.Value, + (float)rasterBounds.X, + (float)rasterBounds.Y)), + createSnapshot, + readbackDeclared) + { + ArgumentNullException.ThrowIfNull(image); + } + + public Rect Bounds + { + get { _token.ThrowIfInactive(); return _bounds; } + } + + public EffectiveScale EffectiveScale + { + get { _token.ThrowIfInactive(); return _effectiveScale; } + } + + public PixelRect DeviceBounds + { + get { _token.ThrowIfInactive(); return _deviceBounds; } + } + + public PixelSize DeviceSize + { + get { _token.ThrowIfInactive(); return _deviceBounds.Size; } + } + + /// + /// Gets the translation from input-local coordinates to the composition-device grid used to + /// round . + /// + public Vector DeviceGridOffset + { + get + { + _token.ThrowIfInactive(); + return new Vector( + (_deviceBounds.X / _effectiveScale.Value) - _rasterBounds.X, + (_deviceBounds.Y / _effectiveScale.Value) - _rasterBounds.Y); + } + } + + /// + /// Gets the pixel-aligned logical footprint represented by the complete backing image. + /// This can conservatively extend beyond because of device-pixel rounding. + /// + public Rect RasterBounds + { + get { _token.ThrowIfInactive(); return _rasterBounds; } + } + + public Point LogicalOrigin + { + get + { + _token.ThrowIfInactive(); + return _rasterBounds.Position; + } + } + + public void Draw(ImmediateCanvas canvas) + { + ArgumentNullException.ThrowIfNull(canvas); + _token.VerifyActiveCanvas(canvas); + _draw(canvas, _rasterBounds, null, null); + } + + /// + /// Draws the input's pixels through and , so a + /// caller can modulate them -- with a colour filter, an alpha, an image filter -- and choose how + /// they are resampled, inside the same draw. + /// + /// + /// Filling a rectangle with the input's shader instead leaves the caller to resample it through a + /// tile mode, which is a poor substitute: the shader is point-sampled, so a minified input reduces + /// to whichever texels the sample points happen to hit, and a decal domain narrower than the + /// sample footprint drops out altogether -- the input disappears. + /// + public void Draw(ImmediateCanvas canvas, SKPaint paint, SKSamplingOptions sampling) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(paint); + _token.VerifyActiveCanvas(canvas); + _draw(canvas, _rasterBounds, paint, sampling); + } + + public void DrawDeviceSpace(ImmediateCanvas canvas, Point devicePoint) + { + ArgumentNullException.ThrowIfNull(canvas); + if (!float.IsFinite(devicePoint.X) || !float.IsFinite(devicePoint.Y)) + throw new ArgumentException("The device-space point must be finite.", nameof(devicePoint)); + + PixelPoint canvasOrigin = _token.GetActiveCanvasDeviceOrigin(canvas); + _drawDeviceSpace( + canvas, + new Point(devicePoint.X - canvasOrigin.X, devicePoint.Y - canvasOrigin.Y)); + } + + public void UseShader( + Action use, + SKShaderTileMode x = SKShaderTileMode.Decal, + SKShaderTileMode y = SKShaderTileMode.Decal) + { + _token.ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(use); + if (!Enum.IsDefined(x)) + throw new ArgumentOutOfRangeException(nameof(x), x, "The shader tile mode is invalid."); + if (!Enum.IsDefined(y)) + throw new ArgumentOutOfRangeException(nameof(y), y, "The shader tile mode is invalid."); + if (_createShader is null) + throw new InvalidOperationException("This execution input does not expose a GPU shader view."); + + using SKShader shader = _createShader(x, y) + ?? throw new InvalidOperationException("The input shader provider returned null."); + _token.AuthorizeResource(shader, () => use(shader)); + } + + public void UseSnapshot(Action use) + { + _token.ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(use); + if (!_readbackDeclared || _createSnapshot is null) + throw new InvalidOperationException("CPU readback was not declared for this execution input."); + if (_snapshotUsed) + throw new InvalidOperationException("An execution input snapshot is a one-shot lease."); + + _snapshotUsed = true; + using Bitmap snapshot = _createSnapshot() + ?? throw new InvalidOperationException("The input snapshot provider returned null."); + _token.AuthorizeResource(snapshot, () => use(snapshot)); + } + + private static PixelRect ValidateDeviceBounds( + Rect bounds, + float density, + PixelRect deviceBounds, + Rect rasterBounds) + { + if (deviceBounds.Width <= 0 || deviceBounds.Height <= 0) + { + throw new ArgumentException( + "An execution input requires non-empty device bounds.", + nameof(deviceBounds)); + } + + if (!DeviceBoundsValidation.MatchesExtent(rasterBounds.Width, density, deviceBounds.Width) + || !DeviceBoundsValidation.MatchesExtent(rasterBounds.Height, density, deviceBounds.Height) + || rasterBounds.X > bounds.X + || rasterBounds.Y > bounds.Y + || rasterBounds.Right < bounds.Right + || rasterBounds.Bottom < bounds.Bottom) + { + throw new ArgumentException( + "Execution input raster bounds must match the backing size and contain the semantic bounds.", + nameof(deviceBounds)); + } + + return deviceBounds; + } +} + +internal sealed class RenderExecutionSessionToken +{ + private readonly Dictionary _authorizedResources = new(ReferenceEqualityComparer.Instance); + private readonly DrawableBrushMaterializer? _drawableBrushMaterializer; + private IDisposable? _callbackGuard = RenderExecutionCallbackGuard.Enter(); + private bool _active = true; + private ImmediateCanvas? _activeCanvas; + private RenderCallbackCanvas? _activeFacade; + private DrawableBrushMaterializer? _previousDrawableBrushMaterializer; + + public RenderExecutionSessionToken(DrawableBrushMaterializer? drawableBrushMaterializer = null) + { + _drawableBrushMaterializer = drawableBrushMaterializer; + } + + public void ThrowIfInactive() + { + if (!_active) + throw new InvalidOperationException("The render execution callback has completed."); + } + + public void Complete() + { + ThrowIfInactive(); + bool hasActiveCanvas = _activeCanvas is not null; + RestoreDrawableBrushMaterializer(); + _active = false; + _activeCanvas = null; + _activeFacade = null; + _authorizedResources.Clear(); + Interlocked.Exchange(ref _callbackGuard, null)?.Dispose(); + if (hasActiveCanvas) + throw new InvalidOperationException("An execution canvas is still active."); + } + + public void RunAndComplete(Action action) + { + ArgumentNullException.ThrowIfNull(action); + RunAndComplete( + () => + { + action(); + return true; + }); + } + + public T RunAndComplete(Func action) + { + ArgumentNullException.ThrowIfNull(action); + ExceptionDispatchInfo? primaryFailure = null; + T result = default!; + try + { + result = action(); + } + catch (Exception ex) + { + primaryFailure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + try + { + Complete(); + } + catch when (primaryFailure is not null) + { + // The callback failure remains primary; session cleanup is best-effort on this path. + } + } + + primaryFailure?.Throw(); + return result; + } + + public void EnterCanvas(ImmediateCanvas canvas, RenderCallbackCanvas? facade) + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(canvas); + if (_activeCanvas is not null) + throw new InvalidOperationException("Only one callback canvas may be active in an execution session."); + + _activeCanvas = canvas; + _activeFacade = facade; + _previousDrawableBrushMaterializer = canvas.DrawableBrushMaterializer; + if (_drawableBrushMaterializer is not null) + canvas.DrawableBrushMaterializer = _drawableBrushMaterializer; + } + + public void UseRawCanvas(ImmediateCanvas canvas, Action use) + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(use); + EnterCanvas(canvas, facade: null); + try + { + canvas.ConfigureRawExecutionCallback(this); + use(canvas); + } + finally + { + try + { + canvas.CloseWithoutFlush(); + } + finally + { + ExitCanvas(canvas); + } + } + } + + public void ExitCanvas(ImmediateCanvas canvas) + { + if (!ReferenceEquals(_activeCanvas, canvas)) + throw new InvalidOperationException("The supplied canvas is not the active execution canvas."); + + RestoreDrawableBrushMaterializer(); + _activeCanvas = null; + _activeFacade = null; + } + + public bool IsActiveCanvas(ImmediateCanvas canvas) + => _active && ReferenceEquals(_activeCanvas, canvas); + + public ImmediateCanvas GetActiveCanvas(RenderCallbackCanvas facade) + { + ThrowIfInactive(); + if (_activeCanvas is null || !ReferenceEquals(_activeFacade, facade)) + { + throw new InvalidOperationException( + "The operation must run while this callback canvas facade is active."); + } + + return _activeCanvas; + } + + public void VerifyActiveCanvas(ImmediateCanvas canvas) + { + ThrowIfInactive(); + if (!ReferenceEquals(_activeCanvas, canvas) || _activeFacade is null) + { + throw new InvalidOperationException( + "An execution input may be drawn only on the active same-session callback canvas."); + } + } + + public PixelPoint GetActiveCanvasDeviceOrigin(ImmediateCanvas canvas) + { + VerifyActiveCanvas(canvas); + return _activeFacade!.DeviceOriginUnchecked; + } + + public void AuthorizeResource(object resource, Action use) + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(use); + + _authorizedResources.TryGetValue(resource, out int count); + _authorizedResources[resource] = count + 1; + try + { + use(); + } + finally + { + if (count == 0) + _authorizedResources.Remove(resource); + else + _authorizedResources[resource] = count; + } + } + + public void UseResource( + RenderResource resource, + IReadOnlyList declaredResources, + Action use) + where T : class + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(declaredResources); + ArgumentNullException.ThrowIfNull(use); + if (!declaredResources.Any(declared => ReferenceEquals(declared.SlotIdentity, resource.SlotIdentity))) + { + throw new InvalidOperationException("The render resource was not declared by this operation."); + } + + resource.Registry.Use( + resource, + value => + { + AuthorizeResource(value, () => use(value)); + return true; + }); + } + + public void UseResources( + IReadOnlyList resources, + Action use) + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(resources); + ArgumentNullException.ThrowIfNull(use); + UseResourceAt(0); + + void UseResourceAt(int index) + { + if (index == resources.Count) + { + use(); + return; + } + + RenderResource resource = resources[index]; + resource.Registry.UseUntyped( + resource, + value => + { + AuthorizeResource(value, () => UseResourceAt(index + 1)); + return true; + }); + } + } + + public void UseResource( + RenderResourceSlot slot, + IReadOnlyList declaredResources, + Action use) + where T : class + { + ThrowIfInactive(); + ArgumentNullException.ThrowIfNull(slot); + ArgumentNullException.ThrowIfNull(declaredResources); + ArgumentNullException.ThrowIfNull(use); + RenderResourceBinding? binding = declaredResources.FirstOrDefault( + item => ReferenceEquals(item.Slot, slot)); + if (binding is null) + { + throw new KeyNotFoundException( + "No resource was bound to the requested slot for this execution callback."); + } + + if (binding.Resource is not RenderResource resource) + { + throw new InvalidOperationException( + "The resource bound to the requested slot does not match the slot's declared type."); + } + + UseResource(resource, declaredResources.Select(static item => item.Resource).ToArray(), use); + } + + public bool IsResourceAuthorized(object resource) + => _active && _authorizedResources.ContainsKey(resource); + + private void RestoreDrawableBrushMaterializer() + { + if (_activeCanvas is { IsDisposed: false } canvas) + canvas.DrawableBrushMaterializer = _previousDrawableBrushMaterializer; + _previousDrawableBrushMaterializer = null; + } + +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderFragmentHandle.cs b/src/Beutl.Engine/Graphics/Rendering/RenderFragmentHandle.cs new file mode 100644 index 0000000000..c32e8daa79 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderFragmentHandle.cs @@ -0,0 +1,667 @@ +using System.Collections.Immutable; + +namespace Beutl.Graphics.Rendering; + +/// Describes concrete recording-time metadata for a render fragment. +/// The fragment's conservative logical value or query bounds. +/// The density at which the fragment can supply materializable values. +public readonly record struct RenderFragmentMetadata(Rect Bounds, EffectiveScale EffectiveScale); + +/// +/// Identifies a fragment recorded by the active transaction. +/// +/// +/// A handle is a borrowed, non-executable view of one ordered fragment stream; it is not necessarily +/// one bitmap and does not own resources. Handles are transaction-scoped. Every public member throws +/// after the owning node's +/// call completes. +/// +public sealed class RenderFragmentHandle +{ + private readonly IRenderFragmentHandleOwner _owner; + private readonly RenderFragmentReference _reference; + + internal RenderFragmentHandle( + IRenderFragmentHandleOwner owner, + RenderFragmentReference reference) + { + _owner = owner; + _reference = reference; + } + + /// Tries to get concrete recording-time bounds and effective-scale metadata. + /// + /// Receives the concrete metadata, or when the fragment still depends on an + /// unresolved owning target domain. + /// + /// when is concrete and author-readable. + /// This method does not execute deferred work or resolve graph-wide regions of interest. + public bool TryGetMetadata(out RenderFragmentMetadata metadata) + { + VerifyActive(); + if (!_reference.HasConcreteRecordingMetadata) + { + metadata = default; + return false; + } + + metadata = new RenderFragmentMetadata( + _reference.RecordedBounds, + _reference.RecordedEffectiveScale); + return true; + } + + /// Gets the declared number of materializable values the fragment may produce. + public RenderValueCardinality ValueCardinality + { + get + { + VerifyActive(); + return _reference.ValueCardinality; + } + } + + /// Gets whether publishing the fragment automatically composites its values into the target. + /// + /// A value may be non-contributing, and a target-effect fragment may still mutate or read the target + /// when this property is . + /// + public bool ContributesValuesToTarget + { + get + { + VerifyActive(); + return _reference.ContributesValuesToTarget; + } + } + + /// Gets whether the complete fragment stream may be consumed by another value-producing fragment. + /// + /// This is conservative recording metadata, not a promise that the fragment is pure or independent of + /// target-token dependencies. + /// + public bool CanBeUsedAsValueInput + { + get + { + VerifyActive(); + return _reference.CanBeUsedAsValueInput; + } + } + + /// Tries to evaluate the fragment's concrete recorded CPU-only hit-test contract. + /// The point in the fragment's request coordinate space. + /// + /// Receives the hit-test result, or when the fragment still depends on an unresolved + /// owning target domain. + /// + /// when was evaluated from concrete metadata. + /// This method does not execute deferred rendering or pixel readback. + public bool TryHitTest(Point point, out bool result) + { + VerifyActive(); + if (!_reference.HasConcreteRecordingMetadata) + { + result = false; + return false; + } + + result = _reference.HitTest(point); + return true; + } + + internal RenderFragmentReference GetReference(IRenderFragmentHandleOwner owner) + { + VerifyActive(); + if (!ReferenceEquals(_owner, owner)) + { + throw new InvalidOperationException( + "The render fragment handle belongs to a different recording transaction."); + } + + return _reference; + } + + private void VerifyActive() + { + _owner.VerifyActive(); + _owner.VerifyOwns(_reference); + } +} + +internal interface IRenderFragmentHandleOwner +{ + void VerifyActive(); + + void VerifyOwns(RenderFragmentReference reference); +} + +internal sealed class RenderFragmentReference +{ + private Func _hitTest; + + public RenderFragmentReference( + RenderFragmentKind kind, + Rect bounds, + EffectiveScale effectiveScale, + RenderValueCardinality valueCardinality, + bool contributesValuesToTarget, + bool canBeUsedAsValueInput, + bool hasTargetEffects, + bool hasOpaqueExternalWork, + IEnumerable? inputs, + object? payload, + Func? hitTest, + RenderFragmentBoundsRequirement boundsRequirement = RenderFragmentBoundsRequirement.Finite, + bool hasDirectSymbolicBoundsDependency = false) + { + valueCardinality.ThrowIfUninitialized(nameof(valueCardinality)); + if (!Enum.IsDefined(boundsRequirement)) + throw new ArgumentOutOfRangeException(nameof(boundsRequirement)); + if (!RenderRectValidation.IsFiniteNonNegative(bounds)) + { + throw new ArgumentException( + "Recorded fragment bounds must be finite and have non-negative dimensions.", + nameof(bounds)); + } + + Kind = kind; + RecordedBounds = bounds; + Bounds = bounds; + RecordedEffectiveScale = effectiveScale; + EffectiveScale = effectiveScale; + BoundsRequirement = boundsRequirement; + ValueCardinality = valueCardinality; + ContributesValuesToTarget = contributesValuesToTarget; + CanBeUsedAsValueInput = canBeUsedAsValueInput; + HasTargetEffects = hasTargetEffects; + HasOpaqueExternalWork = hasOpaqueExternalWork; + Inputs = inputs is null ? [] : [.. inputs]; + SupportsIndependentOutputDensities = payload is OpaqueRenderFragmentPayload + || (kind == RenderFragmentKind.ContributeValues + && Inputs.Length == 1 + && Inputs[0].SupportsIndependentOutputDensities); + HasConcreteRecordingMetadata = !hasDirectSymbolicBoundsDependency + && boundsRequirement == RenderFragmentBoundsRequirement.Finite + && (kind == RenderFragmentKind.Layer + || Inputs.All(static input => input.HasConcreteRecordingMetadata)); + HasSymbolicBoundsDependency = hasDirectSymbolicBoundsDependency + || boundsRequirement == RenderFragmentBoundsRequirement.OwningTargetDomain + || Inputs.Any(static input => input.HasSymbolicBoundsDependency); + Payload = payload; + PotentiallyWritesTarget = ComputePotentiallyWritesTarget(); + HasSymbolicTargetWrite = ComputeHasSymbolicTargetWrite(); + _hitTest = hitTest ?? (static _ => false); + } + + public RenderFragmentKind Kind { get; } + + public Rect RecordedBounds { get; } + + public Rect Bounds { get; private set; } + + public EffectiveScale RecordedEffectiveScale { get; } + + public EffectiveScale EffectiveScale { get; private set; } + + public RenderFragmentBoundsRequirement BoundsRequirement { get; } + + public bool HasConcreteRecordingMetadata { get; } + + public bool HasSymbolicBoundsDependency { get; } + + public RenderValueCardinality ValueCardinality { get; } + + public bool ContributesValuesToTarget { get; } + + public bool CanBeUsedAsValueInput { get; } + + public bool HasTargetEffects { get; } + + public bool PotentiallyWritesTarget { get; } + + /// + /// Gets whether this fragment writes target pixels that does not describe. + /// + /// + /// A full-target write - a clear, an opaque raw command - states its extent symbolically and contributes + /// no value bounds, so a consumer that scopes by recorded bounds alone would clip it away entirely. + /// A finite region restores a described extent: it bounds what the scope can reach whatever is inside it. + /// + public bool HasSymbolicTargetWrite { get; } + + public bool HasOpaqueExternalWork { get; } + + public bool SupportsIndependentOutputDensities { get; } + + public ImmutableArray Inputs { get; } + + public bool SuppressesInputExecution + => Kind == RenderFragmentKind.TargetLayerScope + && Payload is TargetLayerScopeRenderFragmentPayload layer + && layer.Region.Kind == TargetRegionKind.Empty; + + public ImmutableArray ExecutionInputs + => SuppressesInputExecution + ? ImmutableArray.Empty + : Inputs; + + public object? Payload { get; } + + public RenderFragmentId? Id { get; set; } + + public ImmutableArray ValueIds { get; set; } = []; + + public bool AllowsFanOut => CanBeUsedAsValueInput; + + public bool HitTest(Point point) + => Kind == RenderFragmentKind.FilterEffectSegment + && BoundsRequirement == RenderFragmentBoundsRequirement.OwningTargetDomain + ? Bounds.Contains(point) + : _hitTest(point); + + public void ApplyResolvedMetadata( + Rect bounds, + EffectiveScale effectiveScale, + Func? hitTest = null) + { + if (!RenderRectValidation.IsFiniteNonNegative(bounds)) + { + throw new InvalidOperationException( + "Resolved fragment bounds must be finite and have non-negative dimensions."); + } + + Bounds = bounds; + EffectiveScale = effectiveScale; + if (hitTest is not null) + _hitTest = hitTest; + } + + private bool ComputeHasSymbolicTargetWrite() + { + bool inputsWriteSymbolically = Inputs.Any(static input => input.HasSymbolicTargetWrite); + return Kind switch + { + RenderFragmentKind.TargetCommand + => Payload is TargetCommandRenderFragmentPayload command + && command.Description.AffectedRegion.Kind == TargetRegionKind.Full, + RenderFragmentKind.RawTargetCommand or RenderFragmentKind.RawTargetScope => true, + RenderFragmentKind.TargetCapture or RenderFragmentKind.BuiltInBackdropCapture => false, + RenderFragmentKind.TargetLayerScope + => Payload is TargetLayerScopeRenderFragmentPayload layer + && layer.Region.Kind == TargetRegionKind.Full + && inputsWriteSymbolically, + _ => inputsWriteSymbolically, + }; + } + + private bool ComputePotentiallyWritesTarget() + { + bool replayWrites = Kind == RenderFragmentKind.OpacityMask + ? Inputs.Length > 0 + && (Inputs[0].ContributesValuesToTarget || Inputs[0].PotentiallyWritesTarget) + : Inputs.Any(static input => + input.ContributesValuesToTarget || input.PotentiallyWritesTarget); + return Kind switch + { + RenderFragmentKind.TargetCommand + => Payload is TargetCommandRenderFragmentPayload command + && command.Description.AffectedRegion.Kind != TargetRegionKind.Empty, + RenderFragmentKind.RawTargetCommand => true, + RenderFragmentKind.TargetCapture or RenderFragmentKind.BuiltInBackdropCapture => false, + RenderFragmentKind.TargetLayerScope + => Payload is TargetLayerScopeRenderFragmentPayload layer + && layer.Region.Kind != TargetRegionKind.Empty + && replayWrites, + RenderFragmentKind.TargetScope + or RenderFragmentKind.Blend + or RenderFragmentKind.Opacity + or RenderFragmentKind.OpacityMask + => replayWrites, + RenderFragmentKind.RawTargetScope => true, + _ => false, + }; + } +} + +internal enum RenderFragmentBoundsRequirement : byte +{ + Finite, + OwningTargetDomain, +} + +internal static class TargetWriteMetadataResolver +{ + public static bool TryResolveFinite( + RenderFragmentReference reference, + out Rect? affectedBounds) + { + ArgumentNullException.ThrowIfNull(reference); + if (!reference.PotentiallyWritesTarget) + { + affectedBounds = null; + return true; + } + + switch (reference.Kind) + { + case RenderFragmentKind.TargetCommand: + return TryResolveRegion( + ((TargetCommandRenderFragmentPayload)reference.Payload!).Description.AffectedRegion, + targetDomain: null, + out affectedBounds); + case RenderFragmentKind.RawTargetCommand: + case RenderFragmentKind.RawTargetScope: + affectedBounds = null; + return false; + case RenderFragmentKind.TargetLayerScope: + return TryResolveRegion( + ((TargetLayerScopeRenderFragmentPayload)reference.Payload!).Region, + targetDomain: null, + out affectedBounds); + case RenderFragmentKind.TargetScope: + return TryResolveFiniteTargetScope(reference, out affectedBounds); + case RenderFragmentKind.Blend: + if (RequiresFullTargetRegion(reference)) + { + affectedBounds = null; + return false; + } + return TryResolveFiniteReplay(reference, out affectedBounds); + case RenderFragmentKind.Opacity: + case RenderFragmentKind.OpacityMask: + return TryResolveFiniteReplay(reference, out affectedBounds); + default: + affectedBounds = null; + return false; + } + } + + public static Rect? Resolve( + RenderFragmentReference reference, + Rect? targetDomain) + { + ArgumentNullException.ThrowIfNull(reference); + if (!reference.PotentiallyWritesTarget) + return null; + + return reference.Kind switch + { + RenderFragmentKind.TargetCommand + => ResolveRegion( + ((TargetCommandRenderFragmentPayload)reference.Payload!).Description.AffectedRegion, + targetDomain), + RenderFragmentKind.RawTargetCommand or RenderFragmentKind.RawTargetScope + => ResolveRegion(TargetRegion.Full, targetDomain), + RenderFragmentKind.TargetLayerScope + => ResolveRegion( + ((TargetLayerScopeRenderFragmentPayload)reference.Payload!).Region, + targetDomain), + RenderFragmentKind.TargetScope + => ResolveTargetScope(reference, targetDomain), + RenderFragmentKind.Blend + when RequiresFullTargetRegion(reference) + => ResolveRegion(TargetRegion.Full, targetDomain), + RenderFragmentKind.Blend + or RenderFragmentKind.Opacity + or RenderFragmentKind.OpacityMask + => ResolveReplayBounds(reference, targetDomain), + _ => null, + }; + } + + private static bool RequiresFullTargetRegion(RenderFragmentReference reference) + { + return BlendModeRenderNode.RequiresFullTargetRegion( + ((BlendRenderFragmentPayload)reference.Payload!).BlendMode); + } + + private static bool TryResolveFiniteTargetScope( + RenderFragmentReference reference, + out Rect? affectedBounds) + { + if (!TryResolveFiniteReplay(reference, out Rect? replayBounds)) + { + affectedBounds = null; + return false; + } + + if (replayBounds is not { } bounds) + { + affectedBounds = null; + return true; + } + + affectedBounds = ((TargetScopeRenderFragmentPayload)reference.Payload!) + .Description.Bounds.TransformBounds(bounds); + return true; + } + + private static bool TryResolveFiniteReplay( + RenderFragmentReference reference, + out Rect? affectedBounds) + { + Rect result = default; + bool hasBounds = false; + int inputCount = reference.Kind == RenderFragmentKind.OpacityMask + ? Math.Min(1, reference.Inputs.Length) + : reference.Inputs.Length; + for (int i = 0; i < inputCount; i++) + { + RenderFragmentReference input = reference.Inputs[i]; + if (input.ContributesValuesToTarget) + { + if (!input.HasConcreteRecordingMetadata) + { + affectedBounds = null; + return false; + } + + result = result.Union(input.RecordedBounds); + hasBounds = true; + } + + if (!TryResolveFinite(input, out Rect? inputAffectedBounds)) + { + affectedBounds = null; + return false; + } + + if (inputAffectedBounds is { } affected) + { + result = result.Union(affected); + hasBounds = true; + } + } + + affectedBounds = hasBounds ? result : null; + return true; + } + + private static bool TryResolveRegion( + TargetRegion region, + Rect? targetDomain, + out Rect? affectedBounds) + { + switch (region.Kind) + { + case TargetRegionKind.Empty: + affectedBounds = null; + return true; + case TargetRegionKind.Region: + affectedBounds = region.Value; + return true; + case TargetRegionKind.Full when targetDomain is { } domain: + affectedBounds = domain; + return true; + case TargetRegionKind.Full: + affectedBounds = null; + return false; + default: + throw new InvalidOperationException("The target region is uninitialized."); + } + } + + private static Rect? ResolveTargetScope( + RenderFragmentReference reference, + Rect? targetDomain) + { + var payload = (TargetScopeRenderFragmentPayload)reference.Payload!; + Rect? localDomain = targetDomain is { } domain + ? payload.Description.Bounds.GetRequiredInputBounds(domain) + : null; + Rect? replayBounds = ResolveReplayBounds(reference, localDomain); + if (replayBounds is not { } bounds) + return null; + + return payload.Description.Bounds.TransformBounds(bounds); + } + + private static Rect? ResolveReplayBounds( + RenderFragmentReference reference, + Rect? targetDomain) + { + Rect result = default; + bool hasBounds = false; + int inputCount = reference.Kind == RenderFragmentKind.OpacityMask + ? Math.Min(1, reference.Inputs.Length) + : reference.Inputs.Length; + for (int i = 0; i < inputCount; i++) + { + RenderFragmentReference input = reference.Inputs[i]; + if (input.ContributesValuesToTarget) + { + result = result.Union(input.Bounds); + hasBounds = true; + } + + if (Resolve(input, targetDomain) is { } affected) + { + result = result.Union(affected); + hasBounds = true; + } + } + + return hasBounds ? result : null; + } + + private static Rect? ResolveRegion(TargetRegion region, Rect? targetDomain) + { + return region.Kind switch + { + TargetRegionKind.Empty => null, + TargetRegionKind.Region => region.Value, + TargetRegionKind.Full when targetDomain is { } domain => domain, + TargetRegionKind.Full => throw new RenderTargetDomainRequiredException( + "A target-less request with a Full target write requires a finite TargetDomain."), + _ => throw new InvalidOperationException("The target region is uninitialized."), + }; + } +} + +internal static class RenderFragmentTargetDependency +{ + public static bool HasExternalTargetDependency(RenderFragmentReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return Visit(reference, visited); + } + + private static bool Visit( + RenderFragmentReference reference, + ISet visited) + { + if (!visited.Add(reference)) + return false; + + if (reference.Kind == RenderFragmentKind.Layer) + { + // A finite Layer owns a fresh transparent target. Target operations below it are + // self-contained inputs to the resulting value, not dependencies on the caller's target token. + return false; + } + + if (reference.Kind is RenderFragmentKind.TargetCapture + or RenderFragmentKind.BuiltInBackdropCapture + or RenderFragmentKind.TargetCommand + or RenderFragmentKind.RawTargetCommand + or RenderFragmentKind.TargetLayerScope + or RenderFragmentKind.RawTargetScope) + { + return true; + } + + if (reference.Kind == RenderFragmentKind.TargetScope + && ((TargetScopeRenderFragmentPayload)reference.Payload!).Description.IsValueReplayMap is false) + { + return true; + } + + return reference.Inputs.Any(input => Visit(input, visited)); + } +} + +/// +/// Answers, for every , the device pixel grid a fragment replays its inputs +/// onto. +/// +/// +/// The unmatched arm answers , so a form whose target state the +/// planner cannot analyse — an opaque external barrier, or a kind added after this switch was written — costs +/// upstream cache reuse instead of serving a phase-dependent raster at the wrong grid phase. +/// +internal static class RenderFragmentDeviceGrid +{ + public static RenderDeviceGridMapping ResolveMapping(RenderFragmentReference reference) + => reference.Kind switch + { + RenderFragmentKind.TargetScope + => ((TargetScopeRenderFragmentPayload)reference.Payload!).Description.DeviceGridMapping, + // Every kind whose replay, composition, or value materialization is engine-owned and free of + // author-supplied target state. + RenderFragmentKind.ContributeValues + or RenderFragmentKind.Opacity + or RenderFragmentKind.Blend + or RenderFragmentKind.OpacityMask + or RenderFragmentKind.Shader + or RenderFragmentKind.Geometry + or RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + or RenderFragmentKind.FilterEffectSegment + or RenderFragmentKind.MaterializedInput + or RenderFragmentKind.TargetCapture + or RenderFragmentKind.Layer + or RenderFragmentKind.TargetLayerScope + or RenderFragmentKind.TargetCommand + or RenderFragmentKind.BuiltInBackdropCapture + => RenderDeviceGridMapping.Preserved, + _ => RenderDeviceGridMapping.Remapped, + }; +} + +internal enum RenderFragmentKind : byte +{ + ContributeValues, + Opacity, + Blend, + OpacityMask, + Shader, + Geometry, + OpaqueSource, + OpaqueMap, + OpaqueCombine, + OpaqueExpand, + FilterEffectSegment, + MaterializedInput, + TargetCapture, + Layer, + TargetLayerScope, + TargetScope, + RawTargetScope, + RawTargetCommand, + TargetCommand, + BuiltInBackdropCapture, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderInputDemandContract.cs b/src/Beutl.Engine/Graphics/Rendering/RenderInputDemandContract.cs new file mode 100644 index 0000000000..bee285a47d --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderInputDemandContract.cs @@ -0,0 +1,92 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Declares how a one-input operation carries a resolved output demand back to its input. +/// +/// +/// Demand travels the opposite way from supply: a consumer asks for a density, and every operation between +/// it and a source decides what density that source has to produce. An operation that resamples its input — +/// a shader that enlarges what it samples, for instance — needs its input at a different density than it is +/// itself asked for, and only the operation knows the factor. Leaving demand unchanged is correct for an +/// operation that consumes its input at the density its own consumer asked for; for one that enlarges, it +/// lets an unbounded or vector input rasterize below the density the enlargement consumes, and the result is +/// blurred by exactly the enlargement factor. is . +/// +public readonly struct RenderInputDemandContract +{ + private readonly Func? _map; + private readonly object? _structuralIdentity; + + private RenderInputDemandContract( + Func map, + object structuralIdentity) + { + _map = map; + _structuralIdentity = structuralIdentity; + } + + /// Gets the contract that passes a resolved output demand to the input untouched. + public static RenderInputDemandContract Unchanged => default; + + /// + /// Creates a contract that maps a resolved output demand to the input demand that satisfies it. + /// + /// + /// A pure metadata callback that maps a concrete output demand to the concrete input demand. It must + /// return a finite positive density; the engine bounds the result by the request ceiling. It may be + /// evaluated again during graph-wide metadata resolution, so it must remain deterministic and + /// side-effect-free. + /// + /// A declarative backward-demand mapping contract. + public static RenderInputDemandContract MapOutputDemandToInput( + Func map) + { + ArgumentNullException.ThrowIfNull(map); + RenderDescriptionValidation.ValidatePureMetadataCallback(map, nameof(map)); + return new RenderInputDemandContract((_, demand) => map(demand), map.Method); + } + + /// + /// Creates a contract that maps a resolved output demand to the demand on each input separately. + /// + /// + /// A pure metadata callback that maps an input's zero-based index and the concrete output demand to that + /// input's concrete demand. It must return a finite positive density for every index; the engine bounds + /// each result by the request ceiling. It may be evaluated again during graph-wide metadata resolution, so + /// it must remain deterministic and side-effect-free. + /// + /// A declarative per-input backward-demand mapping contract. + /// + /// This is what a many-input operation needs when it resamples its inputs asymmetrically — enlarging one + /// while passing another through. A single map cannot express that, and leaving demand unchanged lets the + /// enlarged input materialize below the density the enlargement consumes. + /// + public static RenderInputDemandContract MapOutputDemandPerInput( + Func map) + { + ArgumentNullException.ThrowIfNull(map); + RenderDescriptionValidation.ValidatePureMetadataCallback(map, nameof(map)); + return new RenderInputDemandContract(map, map.Method); + } + + internal bool IsUnchanged => _map is null; + + internal object StructuralIdentity => _structuralIdentity ?? nameof(Unchanged); + + internal EffectiveScale Resolve(int inputIndex, EffectiveScale outputDemand) + { + if (outputDemand.IsUnbounded) + throw new ArgumentException("Output demand must be concrete.", nameof(outputDemand)); + if (_map is null) + return outputDemand; + + EffectiveScale mapped = _map(inputIndex, outputDemand); + if (mapped.IsUnbounded) + { + throw new InvalidOperationException( + "An output-demand mapping must return a concrete positive density."); + } + + return EffectiveScale.At(mapped.Value); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNode.cs index f8f119cddb..b276035057 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RenderNode.cs @@ -4,6 +4,9 @@ namespace Beutl.Graphics.Rendering; public abstract class RenderNode : IDisposable { + private bool _hasChanges; + private long _changeVersion; + protected RenderNode() { Cache = new RenderNodeCache(this); @@ -20,25 +23,49 @@ protected RenderNode() public bool IsDisposed { get; private set; } - public bool HasChanges { get; set; } + public bool HasChanges + { + get => _hasChanges; + set + { + _hasChanges = value; + if (value) + { + _changeVersion++; + } + } + } + + internal long ChangeVersion => _changeVersion; + + /// The nodes this node records through, in recording order. + /// + /// Content dependency, not ownership: a node that only references another node and never disposes it + /// still reports it here, because revalidation and cache validity follow what a node's output is built + /// from. Disposal and cache teardown follow ownership instead. The relation must be acyclic and the + /// span must stay valid while a caller iterates it. A node that discovers what it records through only + /// while processing, and so cannot hold a stable span, leaves this empty: both traversals then stop at + /// it, so nothing below it is revalidated or render-cached and the node itself must never be cacheable. + /// + public virtual ReadOnlySpan ChildNodes => default; + + internal RenderNodeCache Cache { get; } - public RenderNodeCache Cache { get; } + public abstract void Process(RenderNodeContext context); - /// - /// Runs before with the canvas the pass is compositing onto, for a node - /// whose output depends on that canvas: a filter effect rasterizes its inputs during processing, - /// so an operation returned by can be drawn before an earlier sibling's. - /// + /// Prepares this node for one request, before its children are recorded. /// - /// Called on the root only, so a node that owns or references other nodes must forward it — see - /// and . + /// Recording walks children before their parent, so a node whose children depend on the request - one + /// that records a nested graph at the request's density, say - cannot rebuild them from + /// : they are already recorded by then. Override this to reconcile them against + /// first. It runs before on every request, however + /// the node is reached - walked as part of a subtree, or recorded with explicit inputs - so an override + /// that changes nothing must cost nothing. /// - public virtual void PrepareForProcess(ImmediateCanvas canvas) + public virtual void PrepareForRequest(RenderNodePreparation preparation) { } - public abstract RenderNodeOperation[] Process(RenderNodeContext context); - public void Dispose() { if (!IsDisposed) @@ -50,6 +77,14 @@ public void Dispose() } } + internal void ClearChanges(long observedVersion) + { + if (_hasChanges && _changeVersion == observedVersion) + { + _hasChanges = false; + } + } + protected virtual void OnDispose(bool disposing) { } diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs index 731d63741a..d36ae700c1 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs @@ -1,84 +1,1764 @@ -namespace Beutl.Graphics.Rendering; +using System.Collections.Immutable; +using Beutl.Engine; +using Beutl.Graphics.Effects; +using Beutl.Media; -public class RenderNodeContext( - RenderNodeOperation[] input, - float outputScale = 1f, - float maxWorkingScale = float.PositiveInfinity) +namespace Beutl.Graphics.Rendering; + +internal delegate void PaintedSourceDraw( + ImmediateCanvas canvas, + Brush.Resource? fill, + Pen.Resource? pen, + TState state); + +/// +/// Records declarative render fragments for one active call. +/// +/// +/// The engine creates and seals each transaction. Methods record metadata only; deferred callbacks run later. +/// The context, its borrowed , and all handles obtained from it become invalid when the +/// process call returns. They do not own rendering resources and cannot be retained for a later request. +/// +public sealed class RenderNodeContext { - public RenderNodeOperation[] Input { get; } = input; + private readonly NodeRecordingTransaction _transaction; + private readonly IReadOnlyList _inputs; + private readonly RenderIntent _intent; + private readonly RenderRequestPurpose _purpose; + private readonly Rect? _targetDomain; + private readonly float _outputScale; + private readonly float _maxWorkingScale; + + internal RenderNodeContext(NodeRecordingTransaction transaction) + { + _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + _inputs = transaction.Inputs; + _intent = transaction.Request.Options.Intent; + _purpose = transaction.Request.Options.Purpose; + _targetDomain = transaction.Request.Options.TargetDomain; + _outputScale = transaction.Request.Options.OutputScale; + _maxWorkingScale = transaction.Request.Options.MaxWorkingScale; + } + + /// Gets the non-null ordered fragment inputs borrowed by the current node transaction. + public IReadOnlyList Inputs + { + get { VerifyActive(); return _inputs; } + } + + /// Gets the render intent of the current request. + public RenderIntent Intent + { + get { VerifyActive(); return _intent; } + } - public bool IsRenderCacheEnabled { get; set; } = true; + /// Gets the purpose of the current request. + public RenderRequestPurpose Purpose + { + get { VerifyActive(); return _purpose; } + } + + /// Gets the optional finite logical domain available to root target accesses. + public Rect? TargetDomain + { + get { VerifyActive(); return _targetDomain; } + } + + /// Gets whether the current transaction remains eligible for persistent render caching. + public bool IsRenderCacheEnabled + { + get { VerifyActive(); return _transaction.IsRenderCacheEnabled; } + } /// - /// The final render-target scale s_out (device px per logical unit at the root). - /// Sanitized to positive-finite at construction. - /// Informational only for intermediates — never clamps working scale. + /// Gets the positive finite final output density in device pixels per root logical unit. /// - public float OutputScale { get; } = - float.IsFinite(outputScale) && outputScale > 0f ? outputScale : 1f; + /// This is informational for intermediate values and does not clamp their working density. + public float OutputScale + { + get { VerifyActive(); return _outputScale; } + } /// - /// Global working-scale ceiling. Preview caps at 2 * s_out; export passes +Inf. - /// A degenerate value (NaN / non-positive) is treated as +Inf. + /// Gets the sanitized request-wide ceiling for intermediate working densities. /// - public float MaxWorkingScale { get; } = SanitizeMaxWorkingScale(maxWorkingScale); + /// The value is positive finite or positive infinity. + public float MaxWorkingScale + { + get { VerifyActive(); return _maxWorkingScale; } + } + + /// Tries to calculate the union of all current input bounds from concrete recording metadata. + /// + /// Receives the logical input-bounds union, or when any input still depends on an + /// unresolved owning target domain. An empty input list succeeds with an empty rectangle. + /// + /// when every input has concrete recording metadata. + /// This method does not execute deferred work or resolve graph-wide regions of interest. + public bool TryCalculateInputBounds(out Rect bounds) + { + VerifyActive(); + Rect result = default; + foreach (RenderFragmentHandle input in _inputs) + { + RenderFragmentReference reference = _transaction.GetReference(input); + if (!reference.HasConcreteRecordingMetadata) + { + bounds = default; + return false; + } - /// Canonical ceiling rule: a degenerate value (NaN or non-positive) means "no ceiling" (+Inf); other values pass through. - public static float SanitizeMaxWorkingScale(float maxWorkingScale) => - float.IsNaN(maxWorkingScale) || maxWorkingScale <= 0f ? float.PositiveInfinity : maxWorkingScale; + result = result.Union(reference.RecordedBounds); + } - public Rect CalculateBounds() + bounds = result; + return true; + } + + internal bool TryCalculateFiniteIsolationDomain(out Rect domain) { - return Input.Aggregate(default, (current, operation) => current.Union(operation.Bounds)); + VerifyActive(); + Rect result = default; + foreach (RenderFragmentHandle input in _inputs) + { + RenderFragmentReference reference = _transaction.GetReference(input); + if (reference.ContributesValuesToTarget) + { + if (!reference.HasConcreteRecordingMetadata) + { + domain = default; + return false; + } + + result = result.Union(reference.RecordedBounds); + } + + if (!TargetWriteMetadataResolver.TryResolveFinite(reference, out Rect? affectedBounds)) + { + domain = default; + return false; + } + + if (affectedBounds is { } affected) + result = result.Union(affected); + } + + domain = result; + return true; + } + + /// Monotonically disables persistent render caching for the current node transaction. + /// + /// A node that records a child it does not list in must call this, + /// because the cache cannot observe a change reported only by that unlisted child. + /// + public void DisableRenderCache() + { + GetTransaction().DisableRenderCache(); + } + + /// Publishes every current input unchanged and in order. + public void PassThrough() => GetTransaction().PassThrough(); + + /// Publishes one recorded fragment stream as a node output. + /// A non-null handle borrowed from the active transaction. + public void Publish(RenderFragmentHandle fragment) + => GetTransaction().Publish(fragment); + + /// Abandons a recorded fragment so it is neither published nor executed. + /// Required for a target-effect fragment recorded only to inspect its metadata. + /// A non-null unpublished handle borrowed from the active transaction. + public void Drop(RenderFragmentHandle fragment) + => GetTransaction().Drop(fragment); + + /// Publishes recorded fragment streams in enumeration order. + /// A non-null sequence of non-null handles borrowed from the active transaction. + public void PublishRange(IEnumerable fragments) + { + ArgumentNullException.ThrowIfNull(fragments); + NodeRecordingTransaction transaction = GetTransaction(); + foreach (RenderFragmentHandle fragment in fragments) + { + transaction.Publish(fragment); + } + } + + /// Maps every current input to one output and publishes the mapped outputs in input order. + /// + /// A synchronous callback that returns one active, unpublished handle for each borrowed input without + /// publishing fragments itself. + /// + /// + /// This is explicit publication for a one-to-one input transform. An empty input list invokes no callbacks and + /// publishes no output. Use , , or + /// directly for other topologies or publication orders. + /// + public void PublishMappedInputs(Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + PublishMappedInputs(mapper, static (_, input, callback) => callback(input)); + } + + /// Maps every current input to one output and publishes the mapped outputs in input order. + /// The callback state supplied for every input. + /// The callback state supplied for every input. + /// + /// A synchronous callback that returns one active, unpublished handle for each borrowed input without + /// publishing fragments itself. + /// + /// + /// Pass explicit state with a callback when the recording path must avoid a + /// per-call capture. The context and every input handle remain transaction-scoped and must not be retained. + /// + public void PublishMappedInputs( + TState state, + Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + NodeRecordingTransaction transaction = GetTransaction(); + foreach (RenderFragmentHandle input in _inputs) + { + int publicationCount = transaction.PublicationCount; + RenderFragmentHandle mapped = mapper(this, input, state); + if (transaction.PublicationCount != publicationCount) + { + throw new InvalidOperationException( + "A PublishMappedInputs mapper must return its output without publishing fragments."); + } + + transaction.Publish(mapped); + } + } + + /// Wraps a value-eligible fragment so its values contribute to target composition when published. + /// + /// A non-null transaction-scoped fragment whose is + /// . + /// + /// + /// The borrowed original handle when it already contributes; otherwise a new transaction-scoped contributing + /// handle. The result is not published automatically. + /// + public RenderFragmentHandle ContributeValues(RenderFragmentHandle input) + { + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + EnsureValueInput(reference, nameof(input)); + if (reference.ContributesValuesToTarget) + return input; + + return transaction.CreateFragment( + RenderFragmentKind.ContributeValues, + reference.Bounds, + reference.EffectiveScale, + reference.ValueCardinality, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + reference.HasTargetEffects, + reference.HasOpaqueExternalWork, + [reference], + payload: null, + reference.HitTest); + } + + /// Records a deferred premultiplied-opacity scope around one fragment stream. + /// A non-null fragment borrowed from the active transaction. + /// A finite opacity value. Values outside [0, 1] are clamped. + /// A new transaction-scoped fragment handle. The result is not published automatically. + /// is not finite. + public RenderFragmentHandle Opacity(RenderFragmentHandle input, float opacity) + { + opacity = OpacityRenderNode.Normalize(opacity); + + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + return transaction.CreateFragment( + RenderFragmentKind.Opacity, + reference.Bounds, + reference.EffectiveScale, + reference.ValueCardinality, + reference.ContributesValuesToTarget, + reference.CanBeUsedAsValueInput, + reference.HasTargetEffects, + reference.HasOpaqueExternalWork, + [reference], + new OpacityRenderFragmentPayload( + opacity, + OpacityRenderNode.CreateFusionDescription(opacity)), + reference.HitTest); + } + + /// Records a blend-mode boundary around one input. + /// A non-null fragment borrowed from the active transaction. + /// The blend mode applied during target composition. + /// A new transaction-scoped blend fragment. The result is not published automatically. + /// + /// is not a defined value. + /// + public RenderFragmentHandle Blend(RenderFragmentHandle input, BlendMode blendMode) + { + if (!Enum.IsDefined(blendMode)) + throw new ArgumentOutOfRangeException(nameof(blendMode), blendMode, "The blend mode is not defined."); + + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + return transaction.CreateFragment( + RenderFragmentKind.Blend, + reference.Bounds, + reference.EffectiveScale, + reference.ValueCardinality, + reference.ContributesValuesToTarget, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + reference.HasOpaqueExternalWork, + [reference], + new BlendRenderFragmentPayload(blendMode), + reference.HitTest); + } + + /// Records an opacity-mask fragment and its declarative brush dependencies. + /// A non-null fragment borrowed from the active transaction. + /// + /// The non-null mask resource whose scalar state and declared dependencies are captured during recording. + /// + /// The finite logical coordinate frame used to map the mask brush. + /// Whether to invert the sampled mask alpha. + /// A new transaction-scoped mask fragment. The result is not published automatically. + public RenderFragmentHandle OpacityMask( + RenderFragmentHandle input, + RenderResource mask, + Rect brushBounds, + bool invert = false) + { + ArgumentNullException.ThrowIfNull(mask); + RenderRectValidation.ThrowIfInvalidInput(brushBounds, nameof(brushBounds)); + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + RenderDescriptionValidation.ThrowIfUndeclarable(mask, nameof(mask)); + return transaction.CreateFragment( + RenderFragmentKind.OpacityMask, + reference.Bounds, + reference.EffectiveScale, + reference.ValueCardinality, + reference.ContributesValuesToTarget, + reference.CanBeUsedAsValueInput, + reference.HasTargetEffects, + reference.HasOpaqueExternalWork, + [reference], + new OpacityMaskRenderFragmentPayload( + mask, + brushBounds, + invert), + reference.HitTest); + } + + /// Records a deferred shader transformation over one value-eligible fragment. + /// + /// A non-null transaction-scoped fragment whose is + /// . + /// + /// + /// The non-null caller-owned immutable shader contract. Every declared resource must belong to this request + /// family. + /// + /// A new transaction-scoped shader fragment. The result is not published automatically. + internal RenderFragmentHandle Shader( + RenderFragmentHandle input, + ShaderDescription description) + => Shader(input, description, workingScalePolicy: null); + + /// Records one shader definition call over a value-eligible input. + public RenderFragmentHandle Shader( + RenderFragmentHandle input, + ShaderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return Shader(input, call.Description, workingScalePolicy: null); + } + + internal RenderFragmentHandle Shader( + RenderFragmentHandle input, + ShaderDescription description, + FilterEffectWorkingScalePolicy? workingScalePolicy) + { + ArgumentNullException.ThrowIfNull(description); + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + EnsureValueInput(reference, nameof(input)); + ValidateDescriptionResources( + description.Resources.Select(static binding => binding.Resource).ToArray(), + nameof(description)); + + Rect bounds = description.Bounds.TransformBounds(reference.Bounds); + bool materializes = description.Kind == ShaderDescriptionKind.WholeSource; + EffectiveScale scale; + if (workingScalePolicy is { } policy) + { + scale = policy.Resolve( + [reference], + bounds, + OutputScale, + MaxWorkingScale); + } + else if (materializes) + { + float workingScale = RenderScaleUtilities.ResolveWorkingScale( + [reference.EffectiveScale], + OutputScale, + MaxWorkingScale); + workingScale = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, workingScale); + scale = EffectiveScale.At(workingScale); + } + else + { + scale = reference.EffectiveScale; + } + + return transaction.CreateFragment( + RenderFragmentKind.Shader, + bounds, + scale, + reference.ValueCardinality, + reference.ContributesValuesToTarget, + canBeUsedAsValueInput: true, + reference.HasTargetEffects, + reference.HasOpaqueExternalWork, + [reference], + new ShaderRenderFragmentPayload( + description, + workingScalePolicy), + reference.HitTest); + } + + /// Records a deferred geometry callback over one value-eligible fragment. + /// + /// A non-null transaction-scoped fragment whose is + /// . + /// + /// + /// The non-null caller-owned immutable geometry contract. Every declared resource must belong to this request + /// family. + /// + /// A new transaction-scoped geometry fragment. The result is not published automatically. + internal RenderFragmentHandle Geometry( + RenderFragmentHandle input, + GeometryDescription description) + => Geometry(input, description, workingScalePolicy: null); + + /// Records a deferred geometry call over one value-eligible fragment. + public RenderFragmentHandle Geometry( + RenderFragmentHandle input, + GeometryCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return Geometry(input, call.Description, workingScalePolicy: null); + } + + internal RenderFragmentHandle Geometry( + RenderFragmentHandle input, + GeometryDescription description, + FilterEffectWorkingScalePolicy? workingScalePolicy) + { + ArgumentNullException.ThrowIfNull(description); + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + EnsureValueInput(reference, nameof(input)); + ValidateDescriptionResources(description.Resources, nameof(description)); + + Rect bounds = description.Bounds.TransformBounds(reference.Bounds); + EffectiveScale scale; + if (workingScalePolicy is { } policy) + { + scale = policy.Resolve( + [reference], + bounds, + OutputScale, + MaxWorkingScale); + } + else + { + float workingScale = RenderScaleUtilities.ResolveWorkingScale( + [reference.EffectiveScale], + OutputScale, + MaxWorkingScale); + workingScale = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, workingScale); + scale = EffectiveScale.At(workingScale); + } + + Func hitTest = CreateHitTest( + description.HitTest, + bounds, + [reference], + description.Resources); + RenderValueCardinality cardinality = RenderValueCardinality.Range( + minimum: 0, + maximum: reference.ValueCardinality.Maximum); + return transaction.CreateFragment( + RenderFragmentKind.Geometry, + bounds, + scale, + cardinality, + reference.ContributesValuesToTarget, + canBeUsedAsValueInput: true, + reference.HasTargetEffects, + reference.HasOpaqueExternalWork, + [reference], + new GeometryRenderFragmentPayload( + description, + workingScalePolicy), + hitTest); + } + + internal RenderFragmentHandle PaintedSource( + TState state, + PaintedSourceDraw draw, + Brush.Resource? fill, + Pen.Resource? pen, + Rect outputBounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + bool directReplayAtExactIntegerReduction = false, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + bool supportsDirectDstOut = true, + IEnumerable? resources = null, + Thickness rasterOutset = default) + { + ArgumentNullException.ThrowIfNull(draw); + hitTest.ThrowIfUninitialized(nameof(hitTest)); + scale.ThrowIfUninitialized(nameof(scale)); + RenderDescriptionValidation.ThrowIfFiniteNonEmpty(outputBounds, nameof(outputBounds)); + GetTransaction(); + + var declaredResources = new List( + RenderDescriptionValidation.CopyResources(resources, nameof(resources))); + RenderResource? fillResource = null; + RenderResource? penResource = null; + if (fill is not null) + { + fillResource = Borrow(fill); + declaredResources.Add(fillResource); + } + if (pen is not null) + { + penResource = Borrow(pen); + declaredResources.Add(penResource); + } + + var source = new PlainPaintedSource( + state, + draw, + fill, + pen, + declaredResources + .DistinctBy(static resource => resource.SlotIdentity) + .ToArray()); + OpaqueRenderDescription description = OpaqueRenderDescription.CreateEngineSource( + execute: source.Execute, + directReplay: !ContainsDrawableBrush(fill, pen) + ? source.ExecuteDirect + : null, + bounds: OpaqueRenderBoundsContract.Source(outputBounds, rasterOutset), + hitTest: hitTest, + scale: scale, + directReplayAtExactIntegerReduction: directReplayAtExactIntegerReduction, + deviceGridSensitivity: deviceGridSensitivity, + supportsDirectDstOut: supportsDirectDstOut, + resources: declaredResources); + return OpaqueSource(description); + } + + private static bool ContainsDrawableBrush(Brush.Resource? fill, Pen.Resource? pen) + => ContainsDrawableBrush(fill) || ContainsDrawableBrush(pen?.Brush); + + private static bool ContainsDrawableBrush(Brush.Resource? brush) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + while (brush is BrushPresenter.Resource presenter) + { + if (!visited.Add(brush)) + return true; + + brush = presenter.Target; + } + + return brush is DrawableBrush.Resource; + } + + /// Records an opaque value source whose callback runs only during execution. + /// + /// A non-null caller-owned source-topology description whose declared resources belong to this request family. + /// + /// A new transaction-scoped source fragment. The result is not published automatically. + internal RenderFragmentHandle OpaqueSource(OpaqueRenderDescription description) + { + ArgumentNullException.ThrowIfNull(description); + description.ThrowIfIncompatible(OpaqueRenderTopology.Source, nameof(description)); + IReadOnlyList inputReadbacks = description.ResolveInputReadbacks( + inputCount: 0, + parameterName: nameof(description)); + ValidateDescriptionResources(description.Resources, nameof(description)); + + Rect bounds = description.Bounds.TransformBounds([]); + EffectiveScale scale = description.Scale.Resolve([], bounds, OutputScale, MaxWorkingScale); + Func hitTest = CreateHitTest(description.HitTest, bounds, [], description.Resources); + return GetTransaction().CreateFragment( + RenderFragmentKind.OpaqueSource, + bounds, + scale, + description.ValueCardinality, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: !description.HasDirectReplayMaterializationContract, + inputs: null, + new OpaqueRenderFragmentPayload(OpaqueRenderTopology.Source, description, inputReadbacks), + hitTest); + } + + /// Records an opaque value-source call. + public RenderFragmentHandle OpaqueSource(OpaqueRenderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return OpaqueSource(call.Description); + } + + /// Records an opaque one-input value transformation. + /// A non-null value-eligible fragment borrowed from the active transaction. + /// + /// A non-null caller-owned map-topology description whose declared resources belong to this request family. + /// + /// A new transaction-scoped opaque fragment. The result is not published automatically. + internal RenderFragmentHandle OpaqueMap( + RenderFragmentHandle input, + OpaqueRenderDescription description) + { + ArgumentNullException.ThrowIfNull(description); + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + EnsureValueInput(reference, nameof(input)); + description.ThrowIfIncompatible(OpaqueRenderTopology.Map, nameof(description)); + IReadOnlyList inputReadbacks = description.ResolveInputReadbacks( + inputCount: 1, + parameterName: nameof(description)); + ValidateDescriptionResources(description.Resources, nameof(description)); + + Rect bounds = description.Bounds.TransformBounds([reference.Bounds]); + EffectiveScale scale = description.Scale.Resolve( + [reference.EffectiveScale], + bounds, + OutputScale, + MaxWorkingScale); + RenderValueCardinality cardinality = description.ValueCardinality.Equals(RenderValueCardinality.Single) + ? reference.ValueCardinality + : RenderValueCardinality.Range(0, reference.ValueCardinality.Maximum); + Func hitTest = CreateHitTest( + description.HitTest, + bounds, + [reference], + description.Resources); + return transaction.CreateFragment( + RenderFragmentKind.OpaqueMap, + bounds, + scale, + cardinality, + reference.ContributesValuesToTarget, + canBeUsedAsValueInput: true, + hasTargetEffects: reference.HasTargetEffects, + hasOpaqueExternalWork: true, + [reference], + new OpaqueRenderFragmentPayload(OpaqueRenderTopology.Map, description, inputReadbacks), + hitTest); + } + + /// Records an opaque one-input value-transformation call. + public RenderFragmentHandle OpaqueMap( + RenderFragmentHandle input, + OpaqueRenderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return OpaqueMap(input, call.Description); + } + + /// Records an opaque many-input combination. + /// + /// A non-null ordered list of non-null value-eligible fragments borrowed from the active transaction. + /// + /// + /// A non-null caller-owned combine-topology description whose declared resources belong to this request family. + /// + /// A new transaction-scoped opaque fragment. The result is not published automatically. + internal RenderFragmentHandle OpaqueCombine( + IReadOnlyList inputs, + OpaqueRenderDescription description) + => RecordOpaqueMany(inputs, description, OpaqueRenderTopology.Combine); + + /// Records an opaque many-input combination call. + public RenderFragmentHandle OpaqueCombine( + IReadOnlyList inputs, + OpaqueRenderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return RecordOpaqueMany(inputs, call.Description, OpaqueRenderTopology.Combine); + } + + /// Records an opaque many-input fragment that may expand value cardinality. + /// + /// A non-null ordered list of non-null value-eligible fragments borrowed from the active transaction. + /// + /// + /// A non-null caller-owned expand-topology description whose declared resources belong to this request family. + /// + /// A new transaction-scoped opaque fragment. The result is not published automatically. + internal RenderFragmentHandle OpaqueExpand( + IReadOnlyList inputs, + OpaqueRenderDescription description) + => RecordOpaqueMany(inputs, description, OpaqueRenderTopology.Expand); + + /// Records an opaque many-input expansion call. + public RenderFragmentHandle OpaqueExpand( + IReadOnlyList inputs, + OpaqueRenderCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return RecordOpaqueMany(inputs, call.Description, OpaqueRenderTopology.Expand); + } + + internal RenderFragmentHandle FilterEffectSegment( + IReadOnlyList inputs, + RenderResource effectContext, + Rect outputBounds, + bool requiresOwningTargetDomain = false, + IReadOnlyList? boundsItems = null, + FilterEffectWorkingScalePolicy? workingScalePolicy = null) + { + ArgumentNullException.ThrowIfNull(effectContext); + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + foreach (RenderFragmentReference reference in references) + EnsureValueInput(reference, nameof(inputs)); + ValidateDescriptionResources([effectContext], nameof(effectContext)); + + RenderRectValidation.ThrowIfInvalidInput(outputBounds, nameof(effectContext)); + IReadOnlyList recordedBoundsItems = boundsItems ?? []; + Rect[] bufferBounds = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + references.Select(static item => item.Bounds).ToArray(), + recordedBoundsItems, + outputBounds); + EffectiveScale scale; + if (workingScalePolicy is { } policy) + { + scale = policy.Resolve( + references.Select(static item => item.EffectiveScale).ToArray(), + references.Select(static item => item.Bounds).ToArray(), + bufferBounds, + OutputScale, + MaxWorkingScale); + } + else + { + scale = FilterEffectWorkingScalePolicy.ResolveMaterialized( + references.Select(static item => item.EffectiveScale).ToArray(), + bufferBounds, + OutputScale, + MaxWorkingScale); + } + + RenderValueCardinality cardinality = ResolveFilterEffectSegmentCardinality( + references, + recordedBoundsItems, + outputBounds, + requiresOwningTargetDomain); + + return transaction.CreateFragment( + RenderFragmentKind.FilterEffectSegment, + outputBounds, + scale, + cardinality, + references.Any(static item => item.ContributesValuesToTarget), + canBeUsedAsValueInput: true, + references.Any(static item => item.HasTargetEffects), + hasOpaqueExternalWork: true, + references, + new FilterEffectSegmentRenderFragmentPayload( + effectContext, + [.. recordedBoundsItems], + workingScalePolicy, + references.Length), + outputBounds.Contains, + requiresOwningTargetDomain + ? RenderFragmentBoundsRequirement.OwningTargetDomain + : RenderFragmentBoundsRequirement.Finite); + } + + /// Records a declared render target as an existing materialized value without copying it. + /// + /// The non-null immutable target, bounds, concrete density, and hit-test contract. The target resource must + /// belong to this request family; its resource registration determines disposal ownership. + /// + /// A new transaction-scoped materialized input. The result is not published automatically. + public RenderFragmentHandle MaterializedInput(MaterializedInputDescription description) + { + ArgumentNullException.ThrowIfNull(description); + ValidateDescriptionResources([description.Target], nameof(description)); + Func hitTest = CreateHitTest(description.HitTest, description.Bounds, [], []); + return GetTransaction().CreateFragment( + RenderFragmentKind.MaterializedInput, + description.Bounds, + description.EffectiveScale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs: null, + new MaterializedInputRenderFragmentPayload(description), + hitTest); + } + + /// Records a declared capture of the active target. + /// The non-null immutable capture region, bounds, scale, and access contract. + /// + /// A new transaction-scoped, non-contributing value fragment that contains the captured pixels when executed. + /// The result is not published automatically. + /// + /// The captured value is request-owned until it is released or transferred to an accepted cache. + public RenderFragmentHandle TargetCapture(TargetCaptureDescription description) + { + ArgumentNullException.ThrowIfNull(description); + EffectiveScale scale = description.Scale.PreservesTargetSupply + ? EffectiveScale.Unbounded + : description.Scale.ResolveDeclared( + description.Bounds, + OutputScale, + MaxWorkingScale); + Func hitTest = CreateHitTest(description.HitTest, description.Bounds, [], []); + return GetTransaction().CreateFragment( + RenderFragmentKind.TargetCapture, + description.Bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: false, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + inputs: null, + new TargetCaptureRenderFragmentPayload(description), + hitTest); + } + + internal RenderFragmentHandle BuiltInBackdropCapture(object identity) + { + ArgumentNullException.ThrowIfNull(identity); + if (identity is not IBuiltInBackdropCaptureSink) + { + throw new ArgumentException( + "A built-in backdrop capture identity must accept successful fallback publication.", + nameof(identity)); + } + NodeRecordingTransaction transaction = GetTransaction(); + var placeholder = new Rect(0, 0, 1, 1); + var description = TargetCaptureDescription.Create( + TargetRegion.Full, + placeholder, + RenderHitTestContract.None, + TargetCaptureScaleContract.PreserveTargetSupply); + RenderFragmentHandle handle = transaction.CreateFragment( + RenderFragmentKind.BuiltInBackdropCapture, + placeholder, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: false, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + inputs: null, + new BuiltInBackdropCaptureRenderFragmentPayload(description, identity), + hitTest: null, + boundsRequirement: RenderFragmentBoundsRequirement.OwningTargetDomain); + transaction.BindBuiltInBackdrop(identity, handle); + return handle; + } + + internal bool TryBuiltInBackdrop( + object identity, + out RenderFragmentHandle? capture) + => GetTransaction().TryGetBuiltInBackdrop(identity, out capture); + + /// Records a finite off-screen layer and returns its composited value. + /// A non-null ordered list of non-null fragments replayed inside the layer. + /// The finite logical layer domain. + /// + /// when the layer occupies its whole for bounds queries even + /// where it draws nothing — a fixed-size viewport such as a nested scene, whose layout footprint is the frame + /// it references rather than its content. Output bounds, rasterization regions, and hit testing stay + /// content-derived either way; only the queried footprint changes. + /// + /// + /// A new transaction-scoped single-value fragment. The result is not published automatically and owns no + /// execution resource itself. + /// + /// + /// A finite Layer is a concrete-metadata barrier. If any input has symbolic recording metadata, the result uses + /// the complete for conservative bounds and hit testing. + /// + public RenderFragmentHandle Layer( + IReadOnlyList inputs, + Rect domain, + bool domainIsQueryFootprint = false) + { + if (!RenderRectValidation.IsFiniteNonNegative(domain) + || domain.Width == 0 + || domain.Height == 0) + { + throw new ArgumentException("A finite Layer domain must be finite and non-empty.", nameof(domain)); + } + + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + bool hasConcreteInputMetadata = references.All( + static reference => reference.HasConcreteRecordingMetadata); + bool contributes = false; + Rect bounds = default; + foreach (RenderFragmentReference reference in references) + { + if (reference.ContributesValuesToTarget) + { + contributes = true; + bounds = bounds.Union(reference.Bounds); + } + + if (TargetWriteMetadataResolver.Resolve(reference, domain) is { } affected) + { + contributes = true; + bounds = bounds.Union(affected); + } + } + bounds = hasConcreteInputMetadata + ? bounds.Intersect(domain) + : domain; + // The layer's own bounds are clipped to the domain above, so a point the domain excludes names + // content the layer cannot render however far an input's geometry reaches. + Func hitTest = hasConcreteInputMetadata + ? point => domain.Contains(point) && references.Any(item => item.HitTest(point)) + : domain.Contains; + return transaction.CreateFragment( + RenderFragmentKind.Layer, + bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributes, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: references.Any(static item => item.HasOpaqueExternalWork), + references, + new LayerRenderFragmentPayload(domain, domainIsQueryFootprint), + hitTest); } /// - /// Computes the working scale w for a buffer-allocating boundary: - /// w = min( max(s_out, densest concrete supply), maxWorkingScale ). - /// Vector inputs are excluded from the supply max; the output scale is a floor, not a ceiling. + /// Records an off-screen layer whose finite domain is resolved from its owning target after surrounding + /// target scopes are known. /// - public static float ResolveWorkingScale( - ReadOnlySpan inputs, - float outputScale, - float maxWorkingScale = float.PositiveInfinity) + /// A non-null ordered list of non-null fragments replayed inside the layer. + /// + /// A new transaction-scoped single-value fragment. The result is not published automatically and remains + /// symbolic until graph-wide target-domain resolution. + /// + /// + /// Use this form when a mixed painter sequence must become value-eligible but no finite domain is available + /// during recording. Graph finalization rejects the fragment unless an enclosing scope or request supplies a + /// finite owning target domain. + /// + public RenderFragmentHandle OwningTargetLayer( + IReadOnlyList inputs) { - if (!float.IsFinite(outputScale) || outputScale <= 0f) - outputScale = 1f; + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + Rect recordedBounds = CalculateReferenceBounds(references); + return transaction.CreateFragment( + RenderFragmentKind.Layer, + recordedBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + references.Any(static reference => + reference.ContributesValuesToTarget || reference.PotentiallyWritesTarget), + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: references.Any(static item => item.HasOpaqueExternalWork), + references, + new LayerRenderFragmentPayload(Domain: null), + point => references.Any(item => item.HitTest(point)), + boundsRequirement: RenderFragmentBoundsRequirement.OwningTargetDomain); + } - float supply = outputScale; - foreach (EffectiveScale e in inputs) + /// Records ordered target work scoped to a symbolic target region. + /// A non-null ordered list of non-null fragments replayed inside the scope. + /// The target region resolved after surrounding domains are known. + /// + /// A new transaction-scoped, non-value-eligible target-effect fragment. The result is not published + /// automatically. + /// + public RenderFragmentHandle TargetLayerScope( + IReadOnlyList inputs, + TargetRegion region) + { + region.ThrowIfUninitialized(nameof(region)); + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + return transaction.CreateFragment( + RenderFragmentKind.TargetLayerScope, + CalculateReferenceBounds(references), + EffectiveScale.Unbounded, + AggregateCardinality(references), + contributesValuesToTarget: false, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: references.Any(static item => item.HasOpaqueExternalWork), + references, + new TargetLayerScopeRenderFragmentPayload(region), + CreateTargetLayerScopeHitTest(region, references), + hasDirectSymbolicBoundsDependency: region.Kind == TargetRegionKind.Full); + } + + // A finite region bounds what the scope can put on its target the same way it bounds rasterization, so a + // point outside it names content this scope cannot render however far an input's geometry reaches. A Full + // region has no recording-time extent to test against and defers to its inputs, and an Empty one renders + // nothing at all. + private static Func CreateTargetLayerScopeHitTest( + TargetRegion region, + ImmutableArray references) + { + if (region.Kind == TargetRegionKind.Empty) + return static _ => false; + + if (region.Kind != TargetRegionKind.Region) + return point => references.Any(item => item.HitTest(point)); + + Rect bounds = region.Value; + return point => bounds.Contains(point) && references.Any(item => item.HitTest(point)); + } + + /// Records a guarded target scope around one input. + /// A non-null fragment borrowed from the active transaction and replayed inside the scope. + /// + /// The non-null caller-owned guarded scope contract. Every declared resource must belong to this request family. + /// + /// A new transaction-scoped target scope. The result is not published automatically. + internal RenderFragmentHandle TargetScope( + RenderFragmentHandle input, + TargetScopeDescription description) + { + ArgumentNullException.ThrowIfNull(description); + return RecordTargetScope(input, description, raw: false); + } + + /// Records a guarded target-scope call around one input. + public RenderFragmentHandle TargetScope( + RenderFragmentHandle input, + TargetScopeCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return RecordTargetScope(input, call.Description, raw: false); + } + + /// Records an opaque external target scope around one input. + /// A non-null fragment borrowed from the active transaction and replayed inside the scope. + /// + /// The non-null caller-owned raw scope contract. Every declared resource must belong to this request family. + /// + /// A new transaction-scoped external-work boundary. The result is not published automatically. + internal RenderFragmentHandle RawTargetScope( + RenderFragmentHandle input, + RawTargetScopeDescription description) + { + ArgumentNullException.ThrowIfNull(description); + return RecordTargetScope(input, description, raw: true); + } + + /// Records an opaque external target-scope call around one input. + public RenderFragmentHandle RawTargetScope( + RenderFragmentHandle input, + RawTargetScopeCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return RecordTargetScope(input, call.Description, raw: true); + } + + /// Records an opaque external command against the active target. + /// + /// The non-null caller-owned raw command contract. Every declared resource must belong to this request family. + /// + /// A new transaction-scoped external-work boundary. The result is not published automatically. + internal RenderFragmentHandle RawTargetCommand(RawTargetCommandDescription description) + { + ArgumentNullException.ThrowIfNull(description); + ValidateDescriptionResources(description.Resources, nameof(description)); + Func hitTest = CreateHitTest( + description.HitTest, + description.QueryBounds, + [], + description.Resources); + return GetTransaction().CreateFragment( + RenderFragmentKind.RawTargetCommand, + description.QueryBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.None, + contributesValuesToTarget: false, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: true, + inputs: null, + new RawTargetCommandRenderFragmentPayload(description), + hitTest); + } + + /// Records an opaque external target-command call. + public RenderFragmentHandle RawTargetCommand(RawTargetCommandCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return RawTargetCommand(call.Description); + } + + /// Records a guarded command that consumes declared values and accesses the active target. + /// + /// A non-null ordered list of non-null value-eligible fragments borrowed from the active transaction and made + /// available to the command. + /// + /// + /// The non-null caller-owned guarded command contract. Every declared resource must belong to this request + /// family. + /// + /// A new transaction-scoped target command. The result is not published automatically. + internal RenderFragmentHandle TargetCommand( + IReadOnlyList inputs, + TargetCommandDescription description) + { + ArgumentNullException.ThrowIfNull(description); + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + foreach (RenderFragmentReference reference in references) + EnsureValueInput(reference, nameof(inputs)); + IReadOnlyList inputReadbacks = description.ResolveInputReadbacks( + references.Length, + nameof(description)); + ValidateDescriptionResources(description.Resources, nameof(description)); + + Func hitTest = CreateHitTest( + description.HitTest, + description.QueryBounds, + references, + description.Resources); + return transaction.CreateFragment( + RenderFragmentKind.TargetCommand, + description.QueryBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.None, + contributesValuesToTarget: false, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + references, + new TargetCommandRenderFragmentPayload(description, inputReadbacks), + hitTest); + } + + /// Records a guarded target-command call. + public RenderFragmentHandle TargetCommand( + IReadOnlyList inputs, + TargetCommandCall call) + where TState : notnull + { + ArgumentNullException.ThrowIfNull(call); + return TargetCommand(inputs, call.Description); + } + + /// Records a root and its descendants into the current request without executing them. + /// The non-null caller-owned subtree root. + /// A non-null borrowed list of the subtree's transaction-scoped outputs. + public IReadOnlyList RecordSubtree(RenderNode root) + => GetTransaction().RecordNode(root, [], subtree: true); + + /// Records another node with explicit inputs into the current request. + /// The non-null caller-owned node to record. + /// A non-null ordered list of non-null inputs remapped into the child transaction. + /// A non-null borrowed list of the child node's outputs remapped into this transaction. + public IReadOnlyList RecordNode( + RenderNode node, + IReadOnlyList inputs) + => GetTransaction().RecordNode(node, inputs, subtree: false); + + internal RecordedNestedRenderTarget RecordNestedTarget( + RenderNode root, + Rect targetDomain, + Rect? requestedRegion = null) + => RecordNestedTargetCore( + root, + targetDomain, + requestedRegion, + workingScale: null); + + internal RecordedNestedRenderTarget RecordNestedTargetAtScale( + RenderNode root, + Rect targetDomain, + float workingScale, + Rect? requestedRegion = null) + => RecordNestedTargetCore( + root, + targetDomain, + requestedRegion, + workingScale); + + private RecordedNestedRenderTarget RecordNestedTargetCore( + RenderNode root, + Rect targetDomain, + Rect? requestedRegion, + float? workingScale) + { + ArgumentNullException.ThrowIfNull(root); + var binding = new NestedRenderTargetBinding(); + RenderResource? bindingResource = null; + NodeRecordingTransaction transaction = GetTransaction(); + try + { + bindingResource = transaction.Own(binding); + RenderRequestOptions nestedOptions = workingScale is { } scale + ? transaction.Request.Options.CreateNestedAtScale( + binding, + scale, + targetDomain, + requestedRegion ?? targetDomain) + : transaction.Request.Options.CreateNested( + binding, + targetDomain, + requestedRegion ?? targetDomain); + RecordedNestedRenderRequest recording = transaction.RecordNestedRequest( + root, + nestedOptions); + return new RecordedNestedRenderTarget(recording, bindingResource, binding); + } + catch (Exception ex) + { + if (bindingResource is not null) + { + _ = transaction.RollbackResourcesAndCapture([bindingResource], ex); + } + else + { + transaction.Request.Options.Owner.RecordPrimaryFailure(ex); + try + { + binding.Dispose(); + } + catch (Exception cleanupFailure) + { + transaction.Request.Options.Owner.RecordCleanupFailure(cleanupFailure); + } + } + + throw; + } + } + + /// Transfers a disposable resource to the current request family. + /// The disposable resource type. + /// The non-null resource whose ownership is transferred. + /// A non-null declared resource handle owned by the request family. + /// + /// Ownership transfers when this method succeeds. The family disposes the resource exactly once on rollback, + /// failure, or normal completion. + /// + public RenderResource Own(T resource) + where T : class, IDisposable + => GetTransaction().Own(resource); + + /// Registers a caller-owned resource that the current request may borrow. + /// The resource type. + /// The non-null caller-owned resource. + /// A non-null declared resource handle that never transfers disposal ownership. + /// + /// The request borrows the resource only for its active family and never disposes it. Resource registrations + /// do not provide persistent render-cache identity; cache eligibility follows the node's change reporting. + /// + public RenderResource Borrow(T resource) + where T : class + => GetTransaction().Borrow(resource); + + internal void RollbackResources(IReadOnlyList resources) + => GetTransaction().RollbackResources(resources); + + internal Exception? RollbackResourcesAndCapture( + IReadOnlyList resources, + Exception primaryFailure) + => GetTransaction().RollbackResourcesAndCapture(resources, primaryFailure); + + internal RenderFragmentMetadata GetRecordedMetadataHint(RenderFragmentHandle fragment) + { + RenderFragmentReference reference = GetTransaction().GetReference(fragment); + return new RenderFragmentMetadata(reference.RecordedBounds, reference.RecordedEffectiveScale); + } + + internal bool TryCalculateRecordedOutputExtent( + IReadOnlyList fragments, + out Rect extent) + { + ArgumentNullException.ThrowIfNull(fragments); + NodeRecordingTransaction transaction = GetTransaction(); + Rect result = default; + foreach (RenderFragmentHandle fragment in fragments) + { + RenderFragmentReference reference = transaction.GetReference(fragment); + if (reference.ValueCardinality.Maximum != 0) + { + if (!reference.HasConcreteRecordingMetadata) + { + extent = default; + return false; + } + + result = result.Union(reference.RecordedBounds); + } + + if (!TargetWriteMetadataResolver.TryResolveFinite(reference, out Rect? affectedBounds)) + { + extent = default; + return false; + } + + if (affectedBounds is { } affected) + result = result.Union(affected); + } + + extent = result; + return true; + } + + internal Func GetRecordedHitTest(RenderFragmentHandle fragment) + => GetTransaction().GetReference(fragment).HitTest; + + internal Rect CalculateRecordedInputBoundsHint() + { + NodeRecordingTransaction transaction = GetTransaction(); + Rect result = default; + foreach (RenderFragmentHandle input in _inputs) + { + result = result.Union(transaction.GetReference(input).RecordedBounds); + } + + return result; + } + + /// + /// Gets whether a recorded input writes target pixels that + /// does not describe. + /// + /// + /// A node that scopes its inputs by their recorded value bounds has to ask this first: a full-target + /// write contributes no value bounds, so scoping by them alone turns the whole scope empty and drops the + /// write. Such a node scopes by instead. + /// + internal bool HasSymbolicInputTargetWrite() + { + NodeRecordingTransaction transaction = GetTransaction(); + foreach (RenderFragmentHandle input in _inputs) + { + if (transaction.GetReference(input).HasSymbolicTargetWrite) + return true; + } + + return false; + } + + private NodeRecordingTransaction GetTransaction() + { + VerifyActive(); + return _transaction; + } + + private void VerifyActive() => _transaction.VerifyActive(); + + private static void EnsureValueInput(RenderFragmentReference reference, string parameterName) + { + if (!reference.CanBeUsedAsValueInput) + { + throw new ArgumentException( + "The fragment cannot be consumed as a materialized value input. Use a finite Layer explicitly.", + parameterName); + } + } + + private RenderFragmentHandle RecordOpaqueMany( + IReadOnlyList inputs, + OpaqueRenderDescription description, + OpaqueRenderTopology topology) + { + ArgumentNullException.ThrowIfNull(description); + NodeRecordingTransaction transaction = GetTransaction(); + ImmutableArray references = + transaction.GetReferences(inputs, nameof(inputs)); + foreach (RenderFragmentReference reference in references) + EnsureValueInput(reference, nameof(inputs)); + + description.ThrowIfIncompatible(topology, nameof(description)); + IReadOnlyList inputReadbacks = description.ResolveInputReadbacks( + references.Length, + nameof(description)); + ValidateDescriptionResources(description.Resources, nameof(description)); + Rect bounds = description.Bounds.TransformBounds( + references.Select(static item => item.Bounds).ToArray()); + EffectiveScale scale = description.Scale.Resolve( + references.Select(static item => item.EffectiveScale).ToArray(), + bounds, + OutputScale, + MaxWorkingScale); + Func hitTest = CreateHitTest( + description.HitTest, + bounds, + references, + description.Resources); + return transaction.CreateFragment( + topology == OpaqueRenderTopology.Combine + ? RenderFragmentKind.OpaqueCombine + : RenderFragmentKind.OpaqueExpand, + bounds, + scale, + description.ValueCardinality, + references.Any(static item => item.ContributesValuesToTarget), + canBeUsedAsValueInput: true, + hasTargetEffects: references.Any(static item => item.HasTargetEffects), + hasOpaqueExternalWork: true, + references, + new OpaqueRenderFragmentPayload(topology, description, inputReadbacks), + hitTest); + } + + private RenderFragmentHandle RecordTargetScope( + RenderFragmentHandle input, + object description, + bool raw) + { + NodeRecordingTransaction transaction = GetTransaction(); + RenderFragmentReference reference = transaction.GetReference(input); + RenderBoundsContract boundsContract; + RenderHitTestContract hitTestContract; + RenderScaleContract scaleContract; + IReadOnlyList resourceBindings; + if (description is TargetScopeDescription typed) + { + boundsContract = typed.Bounds; + hitTestContract = typed.HitTest; + scaleContract = typed.Scale; + resourceBindings = typed.Resources; + } + else if (description is RawTargetScopeDescription rawDescription) + { + boundsContract = rawDescription.Bounds; + hitTestContract = rawDescription.HitTest; + scaleContract = rawDescription.Scale; + resourceBindings = rawDescription.Resources; + } + else + { + throw new ArgumentException("The target scope description type is invalid.", nameof(description)); + } + + ValidateDescriptionResources(resourceBindings, nameof(description)); + Rect bounds = boundsContract.TransformBounds(reference.Bounds); + EffectiveScale scale = scaleContract.Resolve( + [reference.EffectiveScale], + bounds, + OutputScale, + MaxWorkingScale); + Func hitTest = CreateHitTest(hitTestContract, bounds, [reference], resourceBindings); + bool isValueReplayMap = !raw + && ((TargetScopeDescription)description).IsValueReplayMap; + return transaction.CreateFragment( + raw ? RenderFragmentKind.RawTargetScope : RenderFragmentKind.TargetScope, + bounds, + scale, + reference.ValueCardinality, + reference.ContributesValuesToTarget, + canBeUsedAsValueInput: isValueReplayMap + && reference.CanBeUsedAsValueInput + && reference.ValueCardinality.Equals(RenderValueCardinality.Single) + && reference.ContributesValuesToTarget + && !RenderFragmentTargetDependency.HasExternalTargetDependency(reference), + hasTargetEffects: isValueReplayMap ? reference.HasTargetEffects : true, + hasOpaqueExternalWork: raw || reference.HasOpaqueExternalWork, + [reference], + raw + ? new RawTargetScopeRenderFragmentPayload((RawTargetScopeDescription)description) + : new TargetScopeRenderFragmentPayload((TargetScopeDescription)description), + hitTest); + } + + private void ValidateDescriptionResources( + IReadOnlyList resources, + string parameterName) + { + NodeRecordingTransaction transaction = GetTransaction(); + foreach (RenderResource resource in resources) + { + if (!ReferenceEquals(resource.Registry, transaction.Request.Options.Owner.ResourceRegistry) + || resource.RegistrationState == RenderResourceRegistrationState.Released) + { + throw new ArgumentException( + "Every declared render resource must belong to the active request family.", + parameterName); + } + } + } + + private void ValidateDescriptionResources( + IReadOnlyList resources, + string parameterName) + => ValidateDescriptionResources( + resources.Select(static binding => binding.Resource).ToArray(), + parameterName); + + private static Func CreateHitTest( + RenderHitTestContract contract, + Rect outputBounds, + IReadOnlyList inputs, + IReadOnlyList resources) + { + RenderHitTestInput[] views = inputs + .Select(static item => new RenderHitTestInput(item.Bounds, item.HitTest)) + .ToArray(); + return point => contract.Evaluate(outputBounds, views, resources, point); + } + + private static Rect CalculateReferenceBounds( + IEnumerable references) + { + Rect result = default; + foreach (RenderFragmentReference reference in references) + { + result = result.Union(reference.Bounds); + } + + return result; + } + + private static RenderValueCardinality AggregateCardinality( + IEnumerable references) + { + int minimum = 0; + int? maximum = 0; + foreach (RenderFragmentReference reference in references) + { + minimum = checked(minimum + reference.ValueCardinality.Minimum); + maximum = maximum is null || reference.ValueCardinality.Maximum is null + ? null + : checked(maximum.Value + reference.ValueCardinality.Maximum.Value); + } + + return RenderValueCardinality.Range(minimum, maximum); + } + + private static RenderValueCardinality ResolveFilterEffectSegmentCardinality( + IReadOnlyList inputs, + IReadOnlyList items, + Rect outputBounds, + bool requiresOwningTargetDomain) + { + if (items.Count == 0 || items.Any(static item => item is not IFEItem_Skia)) + return RenderValueCardinality.Dynamic; + + RenderValueCardinality inputCardinality = AggregateCardinality(inputs); + if (inputCardinality.Equals(RenderValueCardinality.Single)) + { + bool outputMayBeEmpty = requiresOwningTargetDomain + || outputBounds.Width == 0 + || outputBounds.Height == 0 + || items.Any(static item => + item is IFEItem_Skia { ResolveBoundsAtExecutionTime: true }); + return outputMayBeEmpty + ? RenderValueCardinality.ZeroOrOne + : RenderValueCardinality.Single; + } + + return inputCardinality.Equals(RenderValueCardinality.ZeroOrOne) + ? RenderValueCardinality.ZeroOrOne + : RenderValueCardinality.Dynamic; + } + + private sealed class PlainPaintedSource( + TState state, + PaintedSourceDraw draw, + Brush.Resource? fill, + Pen.Resource? pen, + IReadOnlyList declaredResources) + { + public void Execute(OpaqueRenderSession session) { - if (e.IsUnbounded) continue; - if (e.Value > supply) supply = e.Value; + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(canvas => Draw(session.Token, canvas)); + session.Publish(output); } - return MathF.Min(supply, SanitizeMaxWorkingScale(maxWorkingScale)); + public void ExecuteDirect(EngineDirectRenderSession session) + => Draw(session.Token, session.Canvas); + + private void Draw(RenderExecutionSessionToken token, ImmediateCanvas canvas) + => token.UseResources( + declaredResources, + () => draw(canvas, fill, pen, state)); } - /// Max device-buffer axis (px). GPU textures larger than this typically fail to allocate. - public const int MaxBufferDimension = 16384; +} + + +internal sealed record OpacityRenderFragmentPayload( + float Opacity, + ShaderDescription FusionDescription); + +internal sealed record BlendRenderFragmentPayload(BlendMode BlendMode); + +internal sealed record OpacityMaskRenderFragmentPayload( + RenderResource Mask, + Rect BrushBounds, + bool Invert); + +internal sealed record ShaderRenderFragmentPayload( + ShaderDescription Description, + FilterEffectWorkingScalePolicy? WorkingScalePolicy = null); + +internal sealed record GeometryRenderFragmentPayload( + GeometryDescription Description, + FilterEffectWorkingScalePolicy? WorkingScalePolicy = null); + +internal sealed record LayerRenderFragmentPayload(Rect? Domain, bool DomainIsQueryFootprint = false); +internal sealed record TargetLayerScopeRenderFragmentPayload(TargetRegion Region); + +internal sealed record OpaqueRenderFragmentPayload( + OpaqueRenderTopology Topology, + OpaqueRenderDescription Description, + IReadOnlyList InputReadbacks); + +internal sealed record FilterEffectSegmentRenderFragmentPayload( + RenderResource Context, + ImmutableArray BoundsItems, + FilterEffectWorkingScalePolicy? WorkingScalePolicy, + int StreamInputCount) +{ /// - /// Clamps a working scale so the device buffer for stays within - /// on each axis. Returns w unchanged when the buffer already fits. - /// Distinct from (quality ceiling); this is a per-buffer size guard. + /// Whether the segment runs an imperative effect callback. Such a callback crops and re-lays-out its + /// targets in whole device pixels, so the executor strips the sub-pixel phase from the ambient device + /// grid for this segment and for every nested frame that materializes its inputs. Only the ambient + /// phase is stripped: a callback whose own target bounds carry a fractional device phase still + /// allocates off the grid, and an input produced by a separate render request keeps that request's + /// own grid. /// - public static float ClampWorkingScaleToBufferBudget( - Rect logicalBounds, float w, int maxDimension = MaxBufferDimension) + public bool HasImperativeItem + { + get + { + if (BoundsItems.IsDefaultOrEmpty) + return false; + + // ImmutableArray's own enumerator is a struct; Enumerable.Any would box it on a path the + // executor walks for every legacy-filter fragment it runs. + foreach (IFEItem item in BoundsItems) + { + if (item is IFEItem_Custom) + return true; + } + + return false; + } + } + + public bool SupportsDirectReplay + => StreamInputCount == 1 + && !BoundsItems.IsDefaultOrEmpty + && BoundsItems.All(static item => + item is IFEItem_Skia + { + SupportsDirectReplay: true, + ResolveBoundsAtExecutionTime: false, + }); +} + +internal static class FilterEffectSegmentDirectReplaySupport +{ + public static bool CanMaterialize(RenderFragmentReference fragment) { - if (!float.IsFinite(w) || w <= 0f) return w; + if (!fragment.ContributesValuesToTarget || !TryGetPayload(fragment, out _)) + return false; - double maxAxis = Math.Max(Math.Abs((double)logicalBounds.Width), Math.Abs((double)logicalBounds.Height)); - // Degenerate bounds (NaN/Inf) must not introduce a non-finite density. - if (!double.IsFinite(maxAxis) || maxAxis <= 0) return w; + RenderFragmentReference input = fragment.Inputs[0]; + while (TryGetPayload(input, out _)) + input = input.Inputs[0]; - double largestAxisPx = Math.Ceiling(maxAxis * w); - if (largestAxisPx <= maxDimension || largestAxisPx <= 0) return w; + return input.ContributesValuesToTarget + && input.ValueCardinality.Equals(RenderValueCardinality.Single); + } - // Step the float factor down until ceil(axis * fit) <= maxDimension (at most one ULP). - float fit = (float)(w * (maxDimension / largestAxisPx)); - while (fit > 0f && Math.Ceiling(maxAxis * fit) > maxDimension) - fit = MathF.BitDecrement(fit); - return MathF.Max(MathF.Min(w, fit), 0f); + private static bool TryGetPayload( + RenderFragmentReference fragment, + out FilterEffectSegmentRenderFragmentPayload payload) + { + if (fragment.Kind == RenderFragmentKind.FilterEffectSegment + && fragment.Inputs.Length == 1 + && fragment.Payload is FilterEffectSegmentRenderFragmentPayload + { + SupportsDirectReplay: true, + } directPayload) + { + payload = directPayload; + return true; + } + + payload = null!; + return false; } } + +internal sealed record MaterializedInputRenderFragmentPayload( + MaterializedInputDescription Description); + +internal sealed record TargetCaptureRenderFragmentPayload( + TargetCaptureDescription Description); + +internal sealed record BuiltInBackdropCaptureRenderFragmentPayload( + TargetCaptureDescription Description, + object Identity); + +internal sealed record TargetScopeRenderFragmentPayload( + TargetScopeDescription Description); + +internal sealed record RawTargetScopeRenderFragmentPayload( + RawTargetScopeDescription Description); + +internal sealed record RawTargetCommandRenderFragmentPayload( + RawTargetCommandDescription Description); + +internal sealed record TargetCommandRenderFragmentPayload( + TargetCommandDescription Description, + IReadOnlyList InputReadbacks); + +internal interface IBuiltInBackdropCaptureSink +{ + bool TryCommitBackdropCapture(Bitmap bitmap, float density) + { + CommitBackdropCapture(bitmap, density); + return true; + } + + void CommitBackdropCapture(Bitmap bitmap, float density); +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodeOperation.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodeOperation.cs deleted file mode 100644 index e578cd3356..0000000000 --- a/src/Beutl.Engine/Graphics/Rendering/RenderNodeOperation.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Beutl.Media.Source; -using SkiaSharp; - -namespace Beutl.Graphics.Rendering; - -public abstract class RenderNodeOperation : IDisposable -{ - public bool IsDisposed { get; private set; } - - // Invalidになることはない - public abstract Rect Bounds { get; } - - /// - /// Supply density: for vector ops, concrete for bitmaps. - /// - public virtual EffectiveScale EffectiveScale => EffectiveScale.Unbounded; - - public abstract void Render(ImmediateCanvas canvas); - - public abstract bool HitTest(Point point); - - public void Dispose() - { - if (!IsDisposed) - { - OnDispose(true); - IsDisposed = true; - GC.SuppressFinalize(this); - } - } - - protected virtual void OnDispose(bool disposing) - { - } - - /// - /// Disposes every operation in , swallowing an individual - /// fault so one throwing op cannot abort the sweep. Used to release the ops a loop never reached after a throw. - /// - internal static void DisposeAll(ReadOnlySpan ops) - { - foreach (var op in ops) - { - try - { - op.Dispose(); - } - catch - { - // Best-effort: a faulting Dispose must not stop the remaining ops from being released. - } - } - } - - public static RenderNodeOperation CreateDecorator( - RenderNodeOperation child, Action render, - Func? hitTest = null, - Action? onDispose = null) - { - return CreateLambda(child.Bounds, render, hitTest: hitTest ?? child.HitTest, onDispose: () => - { - child.Dispose(); - onDispose?.Invoke(); - }, effectiveScale: child.EffectiveScale); - } - - public static RenderNodeOperation CreateLambda( - Rect bounds, Action render, - Func? hitTest = null, - Action? onDispose = null, - EffectiveScale effectiveScale = default) - { - return new LambdaRenderNodeOperation(bounds, render, hitTest, onDispose, effectiveScale); - } - - public static RenderNodeOperation CreateFromRenderTarget( - Rect bounds, Point position, RenderTarget renderTarget, EffectiveScale effectiveScale = default) - { - // Dest size comes from the buffer footprint (pixels / density), not from bounds. - Action render = effectiveScale.IsUnbounded || effectiveScale.Value == 1f - ? canvas => - { - if (canvas.Density == 1f) - canvas.DrawRenderTarget(renderTarget, position); - else - canvas.DrawRenderTargetScaled(renderTarget, new Rect( - position.X, position.Y, renderTarget.Width, renderTarget.Height)); - } - : canvas => canvas.DrawRenderTargetScaled(renderTarget, new Rect( - bounds.X, bounds.Y, - renderTarget.Width / effectiveScale.Value, renderTarget.Height / effectiveScale.Value)); - return CreateLambda(bounds, render, bounds.Contains, renderTarget.Dispose, effectiveScale); - } - - public static RenderNodeOperation CreateFromSurface( - Rect bounds, Point position, SKSurface surface, EffectiveScale effectiveScale = default) - { - Action render = effectiveScale.IsUnbounded || effectiveScale.Value == 1f - ? canvas => - { - if (canvas.Density == 1f) - canvas.DrawSurface(surface, position); - else - canvas.DrawSurfaceScaled(surface, position, 1f); - } - : canvas => canvas.DrawSurfaceScaled(surface, bounds.Position, effectiveScale.Value); - return CreateLambda(bounds, render, bounds.Contains, surface.Dispose, effectiveScale); - } - - public static RenderNodeOperation CreateFromSurface( - Rect bounds, Point position, Ref surface, EffectiveScale effectiveScale = default) - { - Action render = effectiveScale.IsUnbounded || effectiveScale.Value == 1f - ? canvas => - { - if (canvas.Density == 1f) - canvas.DrawSurface(surface.Value, position); - else - canvas.DrawSurfaceScaled(surface.Value, position, 1f); - } - : canvas => canvas.DrawSurfaceScaled(surface.Value, bounds.Position, effectiveScale.Value); - return CreateLambda(bounds, render, bounds.Contains, surface.Dispose, effectiveScale); - } - - private class LambdaRenderNodeOperation( - Rect bounds, - Action render, - Func? hitTest, - Action? onDispose, - EffectiveScale effectiveScale) - : RenderNodeOperation - { - public override Rect Bounds => bounds; - - public override EffectiveScale EffectiveScale => effectiveScale; - - public override void Render(ImmediateCanvas canvas) => render(canvas); - - public override bool HitTest(Point point) => hitTest?.Invoke(point) ?? false; - - protected override void OnDispose(bool disposing) => onDispose?.Invoke(); - } -} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodePreparation.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodePreparation.cs new file mode 100644 index 0000000000..d2b74ab62b --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderNodePreparation.cs @@ -0,0 +1,36 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// The request values a node needs before its children are recorded. +/// +/// +/// Recording walks children before their parent, so a node that owns scale-dependent children has no way to +/// rebuild them from - by then they have already been recorded. This is what +/// it gets first, and it carries only what is settled before any fragment exists: the request's own values. +/// +public readonly struct RenderNodePreparation +{ + internal RenderNodePreparation(RenderRequestOptions options) + { + OutputScale = options.OutputScale; + MaxWorkingScale = options.MaxWorkingScale; + Intent = options.Intent; + Purpose = options.Purpose; + TargetDomain = options.TargetDomain; + } + + /// Gets the density of the final target this request delivers to. + public float OutputScale { get; } + + /// Gets the ceiling on any working density this request resolves. + public float MaxWorkingScale { get; } + + /// Gets what this request's output is for. + public RenderIntent Intent { get; } + + /// Gets the kind of answer this request asks for. + public RenderRequestPurpose Purpose { get; } + + /// Gets the region this request's output is clipped to, when it has one. + public Rect? TargetDomain { get; } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs deleted file mode 100644 index ef970dcb5c..0000000000 --- a/src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs +++ /dev/null @@ -1,279 +0,0 @@ -using Beutl.Collections.Pooled; -using Beutl.Media; - -namespace Beutl.Graphics.Rendering; - -public class RenderNodeProcessor( - RenderNode root, - bool useRenderCache, - float outputScale = 1f, - float maxWorkingScale = float.PositiveInfinity) -{ - public RenderNode Root { get; } = root; - - /// Output scale s_out seeded into every . Sanitized to positive-finite. - public float OutputScale { get; } = float.IsFinite(outputScale) && outputScale > 0f ? outputScale : 1f; - - /// Working-scale ceiling seeded into every . +Inf = no ceiling. - public float MaxWorkingScale { get; } = RenderNodeContext.SanitizeMaxWorkingScale(maxWorkingScale); - - /// - /// Allocates the intermediate used to rasterize each operation. - /// Override to substitute a custom allocation (e.g. pooling). Defaults to . - /// - protected virtual RenderTarget? CreateRenderTarget(int width, int height) - => RenderTarget.Create(width, height); - - public void Render(ImmediateCanvas canvas) - { - Root.PrepareForProcess(canvas); - var ops = PullToRoot(); - int consumed = 0; - try - { - foreach (var op in ops) - { - op.Render(canvas); - consumed++; - op.Dispose(); - } - } - catch - { - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - throw; - } - } - - /// - /// Rasterizes one operation into its own render target at scale . - /// Returns for zero-area. The op is always disposed. - /// - internal (RenderTarget RenderTarget, Rect Bounds)? RasterizeAt(RenderNodeOperation op, float w) - { - var rect = w == 1f ? PixelRect.FromRect(op.Bounds) : PixelRect.FromRect(op.Bounds, w); - if (rect.Width <= 0 || rect.Height <= 0) - { - op.Dispose(); - return null; - } - - // A throwing OnDispose leaves the op's IsDisposed false, so the catch keys off this flag - // (not IsDisposed) to avoid re-disposing — and re-running OnDispose on — an op already torn down. - RenderTarget? renderTarget = null; - bool opDisposeStarted = false; - try - { - renderTarget = CreateRenderTarget(rect.Width, rect.Height); - if (renderTarget == null) - { - // Defer op disposal to the catch's best-effort path so a throwing op.Dispose() - // cannot mask the null-allocation failure. - throw new Exception("RenderTarget is null"); - } - - using var canvas = new ImmediateCanvas(renderTarget, w, MaxWorkingScale, logicalSize: op.Bounds.Size); - canvas.Clear(); - - Rect opBounds = op.Bounds; - using (canvas.PushTransform(Matrix.CreateTranslation(-opBounds.X, -opBounds.Y))) - { - op.Render(canvas); - opDisposeStarted = true; - op.Dispose(); - } - - return (renderTarget, opBounds); - } - catch - { - // renderTarget.Dispose() is GPU-native teardown that can itself throw; swallow cleanup - // faults so the in-flight render exception propagates and the op is still disposed. - DisposeBestEffort(renderTarget); - if (!opDisposeStarted) - DisposeBestEffort(op); - throw; - } - } - - internal List<(RenderTarget RenderTarget, Rect Bounds)> RasterizeToRenderTargets() - { - return RasterizeToRenderTargets(PullToRoot()); - } - - /// Rasterizes already-pulled operations at . Each op is consumed by . - internal List<(RenderTarget RenderTarget, Rect Bounds)> RasterizeToRenderTargets(RenderNodeOperation[] ops) - { - var list = new List<(RenderTarget, Rect)>(); - int consumed = 0; - try - { - foreach (var op in ops) - { - consumed++; - if (RasterizeAt(op, OutputScale) is { } result) - { - list.Add(result); - } - } - - return list; - } - catch - { - // Clean up remaining ops (RasterizeAt already disposed the faulting one). - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - DisposeRenderTargets(list); - throw; - } - } - - public List Rasterize() - { - var list = new List(); - var ops = PullToRoot(); - int consumed = 0; - try - { - foreach (var op in ops) - { - consumed++; - if (RasterizeAt(op, OutputScale) is { } result) - { - try - { - list.Add(result.RenderTarget.Snapshot()); - } - finally - { - // Best-effort: a throwing GPU-native teardown must not discard the bitmap - // just snapshotted from this target. - DisposeBestEffort(result.RenderTarget); - } - } - } - - return list; - } - catch - { - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - DisposeBitmaps(list); - throw; - } - } - - public Bitmap RasterizeAndConcat() - { - var ops = PullToRoot(); - var bounds = ops.Aggregate(Rect.Empty, (a, n) => a.Union(n.Bounds)); - float w = OutputScale; - var rect = w == 1f ? PixelRect.FromRect(bounds) : PixelRect.FromRect(bounds, w); - RenderTarget? renderTarget = null; - ImmediateCanvas? canvas = null; - int consumed = 0; - try - { - renderTarget = - CreateRenderTarget(rect.Width, rect.Height) ?? throw new Exception("RenderTarget is null"); - canvas = new ImmediateCanvas(renderTarget, w, MaxWorkingScale, logicalSize: bounds.Size); - canvas.Clear(); - - using (canvas.PushTransform(Matrix.CreateTranslation(-bounds.X, -bounds.Y))) - { - foreach (var op in ops) - { - op.Render(canvas); - consumed++; - op.Dispose(); - } - } - - return renderTarget.Snapshot(); - } - catch - { - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - throw; - } - finally - { - // Best-effort on both success and failure: a throwing GPU-native teardown must neither - // mask an in-flight render exception nor discard a successfully snapshotted bitmap. - DisposeBestEffort(canvas); - DisposeBestEffort(renderTarget); - } - } - - private static void DisposeRenderTargets(List<(RenderTarget RenderTarget, Rect Bounds)> targets) - { - foreach (var item in targets) - { - DisposeBestEffort(item.RenderTarget); - } - } - - private static void DisposeBitmaps(List bitmaps) - { - foreach (var bmp in bitmaps) - { - DisposeBestEffort(bmp); - } - } - - private static void DisposeBestEffort(IDisposable? disposable) - { - if (disposable == null) - return; - - try - { - disposable.Dispose(); - } - catch - { - // Preserve the original render/rasterize failure while still sweeping the rest. - } - } - - public RenderNodeOperation[] PullToRoot() - { - return Pull(Root); - } - - public RenderNodeOperation[] Pull(RenderNode node) - { - if (useRenderCache && node.Cache is { IsCached: true } cache) - { - // Replay tiles with the density they were rasterized at. - return cache.UseCache() - .Select(i => RenderNodeOperation.CreateFromRenderTarget( - bounds: i.Bounds, - position: i.Bounds.Position, - renderTarget: i.RenderTarget, - effectiveScale: EffectiveScale.At(cache.Density))) - .ToArray(); - } - - RenderNodeOperation[] input = []; - if (node is ContainerRenderNode container) - { - using var operations = new PooledList(); - foreach (RenderNode innerNode in container.Children) - { - operations.AddRange(Pull(innerNode)); - } - - input = operations.ToArray(); - } - - var context = new RenderNodeContext(input, OutputScale, MaxWorkingScale); - var result = node.Process(context); - if (useRenderCache && !context.IsRenderCacheEnabled) - { - node.Cache.ReportRenderCount(0); - } - - return result; - } -} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodeRasterization.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodeRasterization.cs new file mode 100644 index 0000000000..5205b53a81 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderNodeRasterization.cs @@ -0,0 +1,74 @@ +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +public sealed class RenderNodeRasterization : IDisposable +{ + private Bitmap? _bitmap; + + internal RenderNodeRasterization(Rect bounds, float outputScale, Bitmap? bitmap) + { + if (!RenderRectValidation.IsFiniteNonNegative(bounds)) + { + throw new ArgumentException( + "Rasterization bounds must be finite and have non-negative dimensions.", + nameof(bounds)); + } + + if (!float.IsFinite(outputScale) || outputScale <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(outputScale), + outputScale, + "Rasterization output scale must be positive and finite."); + } + + bool empty = bounds.Width == 0 || bounds.Height == 0; + if (empty != (bitmap is null)) + { + throw new ArgumentException( + "An empty rasterization has no bitmap, while a non-empty rasterization requires one.", + nameof(bitmap)); + } + + Bounds = bounds; + OutputScale = outputScale; + _bitmap = bitmap; + } + + public Rect Bounds { get; } + + public float OutputScale { get; } + + public Bitmap? Bitmap + { + get + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return _bitmap; + } + } + + public bool IsEmpty => Bounds.Width == 0 || Bounds.Height == 0; + + public bool IsDisposed { get; private set; } + + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + Bitmap? bitmap = Interlocked.Exchange(ref _bitmap, null); + bitmap?.Dispose(); + } +} + +public readonly record struct RenderNodeMeasurement( + Rect OutputBounds, + Rect QueryBounds, + EffectiveScale EffectiveScale, + RenderValueCardinality ValueCardinality, + bool HasFragments, + bool HasContributingValues, + bool HasTargetEffects); diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs b/src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs new file mode 100644 index 0000000000..ba539f0513 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderNodeRenderer.cs @@ -0,0 +1,1056 @@ +using System.Runtime.ExceptionServices; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics.Rendering; + +/// Describes one complete request issued through a . +public sealed record RenderNodeRenderRequest +{ + /// Gets the intent that selects allocation-failure behavior. + public RenderIntent Intent { get; init; } = RenderIntent.Preview; + + /// Gets the optional finite logical domain for target-less root target accesses. + /// + /// A non-null value must be finite and non-empty. It is used by target-less renderer operations when a + /// root fragment requires a target domain. Rendering into a supplied canvas uses its destination viewport + /// instead. is valid for self-bounded graphs that do not require a root + /// access. + /// + public Rect? TargetDomain { get; init; } + + /// Gets the optional final logical output region requested by the caller. + /// + /// selects the complete conservative output extent. A finite empty rectangle is a + /// successful empty request. This property does not provide or shrink . + /// + public Rect? RequestedRegion { get; init; } + + /// Gets the requested device-pixel density for target-less rasterization and metadata queries. + /// + /// Non-finite and non-positive values are sanitized to 1. Rendering into a supplied canvas uses the + /// destination density instead. + /// + public float OutputScale { get; init; } = 1; + + /// Gets the maximum working density allowed for intermediate values. + /// + /// NaN and non-positive values are sanitized to positive infinity. Positive finite values and positive + /// infinity are preserved. + /// + public float MaxWorkingScale { get; init; } = float.PositiveInfinity; + + /// Gets the persistent render-node cache admission policy for this request. + public RenderCacheOptions CacheOptions { get; init; } = RenderCacheOptions.Default; + + /// Gets the execution purpose observed by render callbacks and cache policy. + /// + /// and preserve this value. + /// Metadata-only measurement and hit-testing use their dedicated engine purposes. + /// + public RenderRequestPurpose Purpose { get; init; } = RenderRequestPurpose.Auxiliary; + + internal FusionMode FusionMode { get; init; } = FusionMode.Enabled; +} + +/// Configures renderer-lifetime ownership and the request used when an operation omits one. +public sealed class RenderNodeRendererOptions +{ + /// Gets the complete default request copied and sanitized for the renderer lifetime. + public RenderNodeRenderRequest DefaultRequest { get; init; } = new(); + + /// Gets the optional caller-owned factory for renderer-owned intermediate targets. + /// selects the engine's current-backend RGBA16F allocator. + public IRenderTargetFactory? TargetFactory { get; init; } +} + +/// Identifies the pixel format required for a renderer-owned target allocation. +public enum RenderTargetPixelFormat : byte +{ + /// Linear-sRGB, premultiplied-alpha RGBA with 16-bit floating-point components. + LinearPremultipliedRgba16Float, +} + +/// Describes one renderer-owned target allocation. +public readonly record struct RenderTargetAllocationDescriptor +{ + internal RenderTargetAllocationDescriptor( + PixelSize deviceSize, + GRRecordingContext? graphicsContext, + nint? graphicsContextHandle) + { + DeviceSize = deviceSize; + GraphicsContext = graphicsContext; + GraphicsContextHandle = graphicsContextHandle; + } + + /// Gets the exact positive device-pixel size. + public PixelSize DeviceSize { get; } + + /// Gets the required pixel format. + public RenderTargetPixelFormat PixelFormat => + RenderTargetPixelFormat.LinearPremultipliedRgba16Float; + + /// + /// Gets the borrowed Skia context for a context-bound GPU request, or for a + /// CPU request or a target-less request whose backend is not bound yet. + /// + /// + /// The factory may use this value only for the duration of + /// . + /// + public GRRecordingContext? GraphicsContext { get; } + + /// + /// Gets the required Skia context handle: a positive value for GPU, zero for CPU, or + /// when a target-less request has not bound a backend yet. + /// + public nint? GraphicsContextHandle { get; } + + /// Gets the required GPU backend, or when no GPU context is bound. + public GRBackend? GraphicsBackend => GraphicsContext?.Backend; +} + +/// Creates fresh linear-premultiplied RGBA16F targets requested by a renderer. +public interface IRenderTargetFactory +{ + /// Creates a target satisfying the exact allocation requirements. + /// The size, format, backend, and device/context requirements. + /// A new target, or when allocation cannot be satisfied. + /// + /// Every non-null return transfers exclusive ownership to the renderer immediately and must be fresh, + /// unleased, and satisfy the size, format, and context requirements in . + /// The renderer disposes an invalid non-null return. The factory itself remains caller-owned and is never + /// disposed by the renderer. + /// + RenderTarget? Create(RenderTargetAllocationDescriptor allocation); +} + +/// +/// Records, plans, and executes one render-node root while retaining reusable plans, programs, and targets. +/// +/// +/// The renderer borrows , its cache, , +/// render destinations, and returned rasterizations. It owns its plan/program caches and pooled targets. +/// Public calls on one instance are synchronous and must not overlap. After , every public +/// rendering or metadata method throws . +/// +public sealed class RenderNodeRenderer : IDisposable +{ + private readonly RenderTargetLeaseRegistry _targetRegistry; + private readonly StructuralPlanCache _structuralPlanCache; + private readonly ProgramCache _programCache; + private readonly ProgramCache _spirvProgramCache; + private RenderCacheDeviceContextIdentity? _programCacheContext; + + /// Creates a renderer for a caller-owned root node. + /// The non-null caller-owned root recorded for every request. + /// + /// Renderer ownership options and a default request copied for the renderer lifetime, or + /// to use defaults. + /// + /// is . + /// + /// The configured render intent or request purpose is not defined. + /// + /// + /// A configured target domain or requested region is not finite, or the target domain is empty. + /// + public RenderNodeRenderer(RenderNode root, RenderNodeRendererOptions? options = null) + { + ArgumentNullException.ThrowIfNull(root); + options ??= new RenderNodeRendererOptions(); + ArgumentNullException.ThrowIfNull(options.DefaultRequest); + + Root = root; + Options = new RenderNodeRendererOptions + { + DefaultRequest = CopyAndSanitizeRequest(options.DefaultRequest), + TargetFactory = options.TargetFactory, + }; + _targetRegistry = new RenderTargetLeaseRegistry(Options.TargetFactory); + _structuralPlanCache = new StructuralPlanCache(); + _programCache = SkRuntimeEffectProgramCache.Create(); + _spirvProgramCache = SpirvShaderProgramCache.Create(); + } + + /// Gets the caller-owned root node. + public RenderNode Root { get; } + + /// Gets the sanitized renderer option snapshot owned by this renderer. + public RenderNodeRendererOptions Options { get; } + + /// Gets whether this renderer has released its owned state. + public bool IsDisposed { get; private set; } + + internal RenderExecutionStatistics LastExecutionStatistics { get; private set; } + + internal StructuralPlanCacheStatistics StructuralPlanCacheStatistics + => _structuralPlanCache.Statistics; + + internal ProgramCacheStatistics ProgramCacheStatistics + => CombineProgramCacheStatistics(_programCache.Statistics, _spirvProgramCache.Statistics); + + internal RenderTargetPoolStatistics TargetPoolStatistics => _targetRegistry.Statistics; + + internal long ReleaseRetainedTargets() + { + ThrowIfDisposed(); + return _targetRegistry.ReleaseRetainedTargets(); + } + + /// Synchronously renders the selected root stream into a borrowed destination. + /// The non-null caller-owned destination canvas. + /// + /// A complete request, or to use . + /// The destination supplies output scale and target domain; its maximum working scale clamps this request. + /// + /// + /// The call preserves the destination's active transform, clip, opacity, blend mode, density, and ownership. + /// A singular active transform completes value-only self-bounded work as a successful no-op. Domain-independent + /// target effects still execute for ordering, while work that requires the destination's root target domain + /// remains invalid because no inverse domain exists. The call does not close, dispose, flush, submit, clear, or + /// snapshot the destination implicitly. Expanded execution preserves rectangular clips exactly. Because Skia + /// does not expose the active clip path, a non-rectangular destination clip is reproduced conservatively by its + /// device bounding box; the destination's original clip still constrains the final commit. + /// + /// is . + /// This renderer or is disposed. + /// + /// The request purpose is reserved for metadata-only measurement or hit testing. + /// + public void Render( + ImmediateCanvas destination, + RenderNodeRenderRequest? requestOptions = null) + { + RenderExecutionCallbackGuard.ThrowIfRendererLaunchForbidden(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(destination); + ObjectDisposedException.ThrowIf(destination.IsDisposed, destination); + RenderNodeRenderRequest effectiveRequest = ResolveRequest(requestOptions); + ThrowIfInvalidExecutionPurpose(effectiveRequest.Purpose); + + bool hasExplicitEmptySelection = effectiveRequest.RequestedRegion is { } requested + && (requested.Width == 0 || requested.Height == 0); + float maxWorkingScale = MathF.Min(effectiveRequest.MaxWorkingScale, destination.MaxWorkingScale); + bool hasInvertibleDestination = TryResolveDestinationTargetDomain(destination, out Rect resolvedTargetDomain); + Rect? targetDomain = hasInvertibleDestination ? resolvedTargetDomain : null; + RenderTargetLeaseSession targets = _targetRegistry.BeginSession(effectiveRequest.Intent, destination._renderTarget); + CompiledRenderRequest? request = null; + RenderRequestOwner? owner = null; + RenderNodeCacheLifecycle? cacheLifecycle = null; + ExceptionDispatchInfo? primary = null; + try + { + cacheLifecycle = RenderNodeCacheHelper.BeginLifecycle(Root); + request = RecordAndCompile( + effectiveRequest.Purpose, + destination.Density, + maxWorkingScale, + targetDomain, + targets, + effectiveRequest, + DeviceGridAlignment.ResolveLogicalOffset(destination)); + owner = request.Request.Options.Owner; + var executor = new RenderRequestExecutor( + targets, + _programCache, + spirvProgramCache: _spirvProgramCache); + if (!hasInvertibleDestination && !request.Measurement.HasTargetEffects) + { + executor.CompleteNoOp(request); + } + else if (hasExplicitEmptySelection) + { + executor.CompleteEmptySelection(request); + } + else if (request.ExecutionTargetBounds == request.SelectedOutputBounds) + { + executor.Execute(request, destination); + } + else + { + if (destination.HasActiveSaveLayer) + { + throw new InvalidOperationException( + "Expanded render execution cannot copy a destination while an ImmediateCanvas SaveLayer scope is active. Close the layer before rendering the expanded request."); + } + + ExecuteWithExpandedTarget( + request, + destination, + targets, + executor, + maxWorkingScale); + } + LastExecutionStatistics = executor.Statistics; + } + catch (Exception ex) + { + primary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + DisposeAndCapture(request, ref primary); + DisposeAndCapture(targets, ref primary); + } + + ThrowAfterCleanup(primary, owner, targets); + cacheLifecycle?.CompleteSuccessfully( + effectiveRequest.Purpose is RenderRequestPurpose.Frame or RenderRequestPurpose.CacheWarmup); + } + + private static void ExecuteWithExpandedTarget( + CompiledRenderRequest request, + ImmediateCanvas destination, + RenderTargetLeaseSession targets, + RenderRequestExecutor executor, + float maxWorkingScale) + { + RenderTargetLease? executionLease = null; + ImmediateCanvas? executionCanvas = null; + IDisposable? destinationClip = null; + ExceptionDispatchInfo? primary = null; + + void FinalizeExternalResources() + { + IDisposable? clipToDispose = destinationClip; + destinationClip = null; + ImmediateCanvas? canvasToDispose = executionCanvas; + executionCanvas = null; + RenderTargetLease? leaseToDispose = executionLease; + executionLease = null; + DisposeExecutionResources(clipToDispose, canvasToDispose, leaseToDispose); + } + + try + { + executionLease = targets.TryAcquire(destination.DeviceSize); + if (executionLease is null) + return; + + SKRectI destinationDeviceClip = CaptureExpandedDestinationClip(destination); + var executionLogicalSize = new Size( + destination.DeviceSize.Width / destination.Density, + destination.DeviceSize.Height / destination.Density); + executionCanvas = ImmediateCanvas.CreateExecutorManaged( + executionLease.Target, + destination.Density, + maxWorkingScale, + executionLogicalSize, + request.Request.Options.Intent, + destination.DeviceOrigin); + executionCanvas.Transform = destination.Transform; + using (executionCanvas.PushDeviceSpace()) + using (SKImage priorTarget = destination._renderTarget.Value.Snapshot()) + using (var copyPaint = new SKPaint { BlendMode = SKBlendMode.Src }) + { + executionCanvas.Canvas.DrawImage(priorTarget, 0, 0, copyPaint); + } + + destinationClip = PushExpandedDestinationClip(executionCanvas, destinationDeviceClip); + executionCanvas.Opacity = destination.Opacity; + executionCanvas.BlendMode = destination.BlendMode; + executor.Execute( + request, + executionCanvas, + () => CommitExpandedTarget( + executionCanvas, + destination, + request.SelectedOutputBounds), + request.ExecutionTargetBounds, + FinalizeExternalResources); + } + catch (Exception ex) + { + primary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + DisposeExecutionResourcesAndCapture( + request.Request.Options.Owner, + ref primary, + destinationClip, + executionCanvas, + executionLease); + } + + primary?.Throw(); + } + + private static SKRectI CaptureExpandedDestinationClip(ImmediateCanvas destination) + => destination.Canvas.DeviceClipBounds; + + private static IDisposable PushExpandedDestinationClip( + ImmediateCanvas executionCanvas, + SKRectI destinationDeviceClip) + { + Matrix transform = executionCanvas.Transform; + executionCanvas.Transform = Matrix.Identity; + try + { + return executionCanvas.PushClip(new Rect( + destinationDeviceClip.Left, + destinationDeviceClip.Top, + destinationDeviceClip.Width, + destinationDeviceClip.Height)); + } + finally + { + executionCanvas.Transform = transform; + } + } + + private static void CommitExpandedTarget( + ImmediateCanvas executionCanvas, + ImmediateCanvas destination, + Rect selectedOutputBounds) + { + if (selectedOutputBounds.Width == 0 || selectedOutputBounds.Height == 0) + return; + + using SKImage completedTarget = executionCanvas._renderTarget.Value.Snapshot(); + using (destination.PushClip(selectedOutputBounds)) + using (destination.PushDeviceSpace()) + using (var commitPaint = new SKPaint { BlendMode = SKBlendMode.Src }) + { + destination.Canvas.DrawImage(completedTarget, 0, 0, commitPaint); + } + } + + /// Synchronously rasterizes the selected output into a new caller-owned result. + /// + /// A non-null disposable result. Its bitmap is null only for a successful empty selection and remains valid + /// after this renderer is disposed. + /// + /// + /// The result exclusively owns its bitmap; callers dispose the result rather than the bitmap. A non-empty + /// result reports the device-pixel-aligned cover of the selected output, so its bounds scaled by + /// are exactly the returned bitmap's pixel extent and + /// origin. + /// + /// + /// A complete request, or to use . + /// + /// This renderer is disposed. + /// + /// The request purpose is reserved for metadata-only measurement or hit testing. + /// + public RenderNodeRasterization Rasterize(RenderNodeRenderRequest? requestOptions = null) + { + RenderExecutionCallbackGuard.ThrowIfRendererLaunchForbidden(); + ThrowIfDisposed(); + RenderNodeRenderRequest effectiveRequest = ResolveRequest(requestOptions); + ThrowIfInvalidExecutionPurpose(effectiveRequest.Purpose); + CompiledRenderRequest? request = null; + RenderRequestOwner? owner = null; + RenderNodeCacheLifecycle? cacheLifecycle = null; + RenderTargetLeaseSession? targets = null; + RenderTargetLease? rootLease = null; + ImmediateCanvas? canvas = null; + Bitmap? bitmap = null; + Rect selectedBounds = default; + ExceptionDispatchInfo? primary = null; + try + { + targets = _targetRegistry.BeginSession(effectiveRequest.Intent); + cacheLifecycle = RenderNodeCacheHelper.BeginLifecycle(Root); + request = RecordAndCompile( + effectiveRequest.Purpose, + effectiveRequest.OutputScale, + effectiveRequest.MaxWorkingScale, + effectiveRequest.TargetDomain, + targets, + effectiveRequest); + owner = request.Request.Options.Owner; + selectedBounds = request.SelectedOutputBounds; + if (selectedBounds.Width != 0 && selectedBounds.Height != 0) + { + PixelRect deviceBounds = PixelRect.FromRect( + request.ExecutionTargetBounds, + effectiveRequest.OutputScale); + PixelRect selectedDeviceBounds = PixelRect.FromRect(selectedBounds, effectiveRequest.OutputScale); + selectedBounds = selectedDeviceBounds.ToRect(effectiveRequest.OutputScale); + Rect rasterBounds = deviceBounds.ToRect(effectiveRequest.OutputScale); + rootLease = targets.Acquire(deviceBounds.Size); + canvas = ImmediateCanvas.CreateExecutorManaged( + rootLease.Target, + effectiveRequest.OutputScale, + effectiveRequest.MaxWorkingScale, + rasterBounds.Size, + effectiveRequest.Intent, + deviceBounds.Position); + canvas.Clear(); + + IDisposable? transform = canvas.PushTransform( + Matrix.CreateTranslation(-rasterBounds.X, -rasterBounds.Y)); + + IDisposable?[] TakeExternalResources() + { + IDisposable? transformToDispose = transform; + transform = null; + ImmediateCanvas? canvasToDispose = canvas; + canvas = null; + RenderTargetLease? leaseToDispose = rootLease; + rootLease = null; + return [transformToDispose, canvasToDispose, leaseToDispose]; + } + + void FinalizeExternalResources() + => DisposeExecutionResources(TakeExternalResources()); + + void FinalizeExternalResourcesAndCapture(ref ExceptionDispatchInfo? failure) + => DisposeExecutionResourcesAndCapture( + owner!, + ref failure, + TakeExternalResources()); + + ExceptionDispatchInfo? executionPrimary = null; + try + { + var executor = new RenderRequestExecutor( + targets, + _programCache, + spirvProgramCache: _spirvProgramCache); + executor.Execute( + request, + canvas, + () => + { + IDisposable? transformToDispose = transform; + transform = null; + transformToDispose?.Dispose(); + var selectedSubset = new PixelRect( + selectedDeviceBounds.X - deviceBounds.X, + selectedDeviceBounds.Y - deviceBounds.Y, + selectedDeviceBounds.Width, + selectedDeviceBounds.Height); + Bitmap complete = rootLease.Target.Snapshot(); + bitmap = TakeRasterizationBitmap(complete, selectedSubset); + }, + request.ExecutionTargetBounds, + FinalizeExternalResources); + LastExecutionStatistics = executor.Statistics; + } + catch (Exception ex) + { + executionPrimary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + FinalizeExternalResourcesAndCapture(ref executionPrimary); + } + + executionPrimary?.Throw(); + } + else + { + var executor = new RenderRequestExecutor( + targets, + _programCache, + spirvProgramCache: _spirvProgramCache); + executor.CompleteEmptySelection(request); + LastExecutionStatistics = executor.Statistics; + } + } + catch (Exception ex) + { + primary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + if (owner is not null) + { + DisposeExecutionResourcesAndCapture(owner, ref primary, canvas, rootLease); + } + else + { + DisposeAndCapture(canvas, ref primary); + DisposeAndCapture(rootLease, ref primary); + } + DisposeAndCapture(request, ref primary); + DisposeAndCapture(targets, ref primary); + } + + try + { + ThrowAfterCleanup(primary, owner, targets); + cacheLifecycle?.CompleteSuccessfully( + effectiveRequest.Purpose is RenderRequestPurpose.Frame or RenderRequestPurpose.CacheWarmup); + } + catch + { + DisposeBestEffort(bitmap); + throw; + } + + return new RenderNodeRasterization(selectedBounds, effectiveRequest.OutputScale, bitmap); + } + + internal static Bitmap TakeRasterizationBitmap(Bitmap complete, PixelRect selectedSubset) + { + ArgumentNullException.ThrowIfNull(complete); + complete.ThrowIfDisposed(); + + if (selectedSubset == new PixelRect(0, 0, complete.Width, complete.Height)) + return complete; + + try + { + return complete.ExtractSubset(selectedSubset); + } + finally + { + complete.Dispose(); + } + } + + /// Resolves request-wide output and query metadata without executing deferred work. + /// The resolved measurement. + /// This call performs no pixel callback, target allocation, readback, or cache publication. + /// + /// A complete request, or to use . + /// + /// This renderer is disposed. + public RenderNodeMeasurement Measure(RenderNodeRenderRequest? requestOptions = null) + { + RenderExecutionCallbackGuard.ThrowIfRendererLaunchForbidden(); + ThrowIfDisposed(); + RenderNodeRenderRequest effectiveRequest = ResolveRequest(requestOptions); + RenderRequest request = CreateRequest( + RenderRequestPurpose.Bounds, + effectiveRequest.OutputScale, + effectiveRequest.MaxWorkingScale, + effectiveRequest.TargetDomain, + effectiveRequest); + RenderRequestOwner owner = request.Options.Owner; + RenderNodeMeasurement measurement = default; + ExceptionDispatchInfo? primary = null; + try + { + var recorder = new RenderRequestRecorder(request); + RecordedRenderGraph graph = recorder.Record(Root); + measurement = new RenderRequestCompiler().ResolveMetadata(request, graph); + request.CompleteMetadataOnly(); + } + catch (Exception ex) + { + primary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + DisposeAndCapture(request, ref primary); + } + + ThrowAfterCleanup(primary, owner, targets: null); + return measurement; + } + + /// Tests the root at a logical point using recorded CPU-only metadata. + /// The point in root request coordinates. + /// + /// A complete request, or to use . + /// + /// when a published fragment is hit. + /// This call performs no pixel callback, target allocation, or readback. + /// This renderer is disposed. + public bool HitTest(Point point, RenderNodeRenderRequest? requestOptions = null) + { + RenderExecutionCallbackGuard.ThrowIfRendererLaunchForbidden(); + ThrowIfDisposed(); + RenderNodeRenderRequest effectiveRequest = ResolveRequest(requestOptions); + // Both bound what the request can actually put on screen: a finite TargetDomain clips the resolved + // output the same way it clips rasterization, so a point outside either one names content this + // request cannot render. + bool pointIsRenderable = (effectiveRequest.RequestedRegion is not { } requested + || (requested.Width > 0 + && requested.Height > 0 + && requested.Contains(point))) + && (effectiveRequest.TargetDomain is not { } domain + || domain.Contains(point)); + + RenderRequest request = CreateRequest( + RenderRequestPurpose.HitTest, + effectiveRequest.OutputScale, + effectiveRequest.MaxWorkingScale, + effectiveRequest.TargetDomain, + effectiveRequest); + RenderRequestOwner owner = request.Options.Owner; + bool result = false; + ExceptionDispatchInfo? primary = null; + try + { + var recorder = new RenderRequestRecorder(request); + RecordedRenderGraph graph = recorder.Record(Root); + var compiler = new RenderRequestCompiler(); + _ = compiler.ResolveMetadata(request, graph); + if (pointIsRenderable) + { + var roots = RenderRequestCompiler.ResolveRoots(graph); + for (int index = roots.Length - 1; index >= 0; index--) + { + if (roots[index].HitTest(point)) + { + result = true; + break; + } + } + } + + request.CompleteMetadataOnly(); + } + catch (Exception ex) + { + primary = ExceptionDispatchInfo.Capture(ex); + } + finally + { + DisposeAndCapture(request, ref primary); + } + + ThrowAfterCleanup(primary, owner, targets: null); + return result; + } + + /// Releases renderer-owned plans, programs, and pooled targets. + /// + /// Disposal is idempotent and attempts every owned cleanup while preserving the first failure. It does not + /// dispose the root, root cache, target factory, destinations, or previously returned rasterizations. + /// + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + Exception? primary = null; + try + { + _targetRegistry.Dispose(); + } + catch (Exception ex) + { + primary = ex; + } + + try + { + _programCache.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + + try + { + _spirvProgramCache.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + + try + { + _structuralPlanCache.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + + if (primary is not null) + ExceptionDispatchInfo.Capture(primary).Throw(); + } + + private CompiledRenderRequest RecordAndCompile( + RenderRequestPurpose purpose, + float outputScale, + float maxWorkingScale, + Rect? targetDomain, + RenderTargetLeaseSession targets, + RenderNodeRenderRequest renderRequest, + Vector deviceGridOffset = default) + { + ArgumentNullException.ThrowIfNull(targets); + RenderRequest request = CreateRequest( + purpose, + outputScale, + maxWorkingScale, + targetDomain, + renderRequest); + try + { + SynchronizeProgramCacheContext(targets); + var recorder = new RenderRequestRecorder(request); + RecordedRenderGraph graph = recorder.Record(Root); + bool allowPersistentLookup = renderRequest.CacheOptions.IsEnabled + && purpose is not (RenderRequestPurpose.Bounds or RenderRequestPurpose.HitTest); + bool allowCapturePublication = allowPersistentLookup + && purpose is RenderRequestPurpose.Frame or RenderRequestPurpose.CacheWarmup; + var cacheContext = new RenderCacheResolutionContext( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + targets.CacheDeviceContextIdentity, + allowPersistentLookup, + allowCapturePublication, + deviceGridOffset); + SkslBackendBudget shaderBudget = SkslBackendBudgetResolver.Resolve( + targets.ExternalTarget?.RawValue.Context?.Backend); + return new RenderRequestCompiler( + _structuralPlanCache, + cacheContext, + allowPersistentLookup ? RenderNodeCacheLookup.Instance : null) + .Compile(request, graph, shaderBudget); + } + catch (Exception ex) + { + ExceptionDispatchInfo? primary = ExceptionDispatchInfo.Capture(ex); + DisposeAndCapture(request, ref primary); + primary!.Throw(); + throw; + } + } + + private void SynchronizeProgramCacheContext(RenderTargetLeaseSession targets) + { + RenderCacheDeviceContextIdentity current = targets.CacheDeviceContextIdentity; + if (_programCacheContext is { } previous && previous != current) + { + _programCache.EvictContext( + previous.DeviceIdentity, + previous.ContextIdentity); + _spirvProgramCache.EvictContext( + previous.DeviceIdentity, + previous.ContextIdentity); + } + + _programCacheContext = current; + } + + private static ProgramCacheStatistics CombineProgramCacheStatistics( + ProgramCacheStatistics sksl, + ProgramCacheStatistics spirv) + => new( + sksl.Hits + spirv.Hits, + sksl.Misses + spirv.Misses, + sksl.Creations + spirv.Creations, + sksl.Evictions + spirv.Evictions, + sksl.RetainedPrograms + spirv.RetainedPrograms, + sksl.RetainedBytes + spirv.RetainedBytes); + + private RenderRequest CreateRequest( + RenderRequestPurpose purpose, + float outputScale, + float maxWorkingScale, + Rect? targetDomain, + RenderNodeRenderRequest renderRequest) + => new(new RenderRequestOptions( + renderRequest.Intent, + purpose, + targetDomain, + renderRequest.RequestedRegion, + outputScale, + maxWorkingScale, + renderRequest.CacheOptions, + renderRequest.FusionMode)); + + private RenderNodeRenderRequest ResolveRequest(RenderNodeRenderRequest? request) + => request is null + ? Options.DefaultRequest + : CopyAndSanitizeRequest(request); + + private static RenderNodeRenderRequest CopyAndSanitizeRequest(RenderNodeRenderRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.CacheOptions); + if (!Enum.IsDefined(request.Intent)) + { + throw new ArgumentOutOfRangeException( + nameof(request), + request.Intent, + "The render intent is not defined."); + } + if (!Enum.IsDefined(request.Purpose)) + { + throw new ArgumentOutOfRangeException( + nameof(request), + request.Purpose, + "The render request purpose is not defined."); + } + + ValidateTargetDomain(request.TargetDomain); + ValidateRequestedRegion(request.RequestedRegion); + return request with + { + OutputScale = SanitizeOutputScale(request.OutputScale), + MaxWorkingScale = RenderScaleUtilities.SanitizeMaxWorkingScale(request.MaxWorkingScale), + }; + } + + private static void ThrowIfInvalidExecutionPurpose(RenderRequestPurpose purpose) + { + if (purpose is not (RenderRequestPurpose.Frame + or RenderRequestPurpose.CacheWarmup + or RenderRequestPurpose.Auxiliary)) + { + throw new ArgumentOutOfRangeException( + nameof(purpose), + purpose, + "Render and Rasterize require Frame, CacheWarmup, or Auxiliary purpose."); + } + } + + private static bool TryResolveDestinationTargetDomain(ImmediateCanvas destination, out Rect domain) + { + Matrix rootToViewport = destination.Transform.Append( + Matrix.CreateScale(1 / destination.Density, 1 / destination.Density)); + if (!rootToViewport.TryInvert(out Matrix inverse)) + { + domain = default; + return false; + } + + Size viewportSize = destination.Density == 1f && destination.SurfaceDensity != 1f + ? destination.DeviceSize.ToSize(1) + : destination.LogicalSize; + domain = new Rect(default, viewportSize).TransformToAABB(inverse); + if (!RenderRectValidation.IsFiniteNonNegative(domain) + || domain.Width == 0 + || domain.Height == 0) + { + throw new InvalidOperationException( + "The destination's active transform did not produce a finite non-empty root target domain."); + } + + return true; + } + + private static void DisposeAndCapture(IDisposable? disposable, ref ExceptionDispatchInfo? primary) + { + try + { + disposable?.Dispose(); + } + catch (Exception ex) + { + primary ??= ExceptionDispatchInfo.Capture(ex); + } + } + + internal static void DisposeExecutionResourcesAndCapture( + RenderRequestOwner owner, + ref ExceptionDispatchInfo? primary, + params IDisposable?[] resources) + { + ArgumentNullException.ThrowIfNull(owner); + if (primary is not null) + owner.RecordPrimaryFailure(primary.SourceException); + + try + { + DisposeExecutionResources(resources); + } + catch (Exception ex) + { + IEnumerable cleanupFailures = ex is AggregateException aggregate + ? aggregate.Flatten().InnerExceptions + : [ex]; + foreach (Exception failure in cleanupFailures) + { + owner.RecordCleanupFailure(failure); + primary ??= ExceptionDispatchInfo.Capture(failure); + } + } + } + + private static void DisposeExecutionResources(params IDisposable?[] resources) + { + List? failures = null; + foreach (IDisposable? resource in resources) + { + try + { + resource?.Dispose(); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is [var failure]) + ExceptionDispatchInfo.Capture(failure).Throw(); + if (failures is { Count: > 1 }) + throw new AggregateException(failures); + } + + private static IEnumerable EnumerateFamilyDepthFirst( + CompiledRenderRequest request) + { + foreach (CompiledRenderRequest nested in request.NestedRequests) + { + foreach (CompiledRenderRequest member in EnumerateFamilyDepthFirst(nested)) + yield return member; + } + + yield return request; + } + + private static void ThrowAfterCleanup( + ExceptionDispatchInfo? primary, + RenderRequestOwner? owner, + RenderTargetLeaseSession? targets) + { + primary?.Throw(); + owner?.ThrowIfFailed(); + targets?.ThrowIfCleanupFailed(); + } + + private static float SanitizeOutputScale(float outputScale) + => float.IsFinite(outputScale) && outputScale > 0 ? outputScale : 1; + + private static void ValidateTargetDomain(Rect? domain) + { + if (domain is not { } value) + return; + + if (!RenderRectValidation.IsFiniteNonNegative(value) + || value.Width == 0 + || value.Height == 0) + { + throw new ArgumentException( + "A target domain must be finite and non-empty.", + nameof(domain)); + } + } + + private static void ValidateRequestedRegion(Rect? region) + { + if (region is { } value && !RenderRectValidation.IsFiniteNonNegative(value)) + { + throw new ArgumentException( + "A requested region must be finite and have non-negative dimensions.", + nameof(region)); + } + } + + private static void DisposeBestEffort(IDisposable? disposable) + { + try + { + disposable?.Dispose(); + } + catch + { + // A teardown fault must not replace an in-flight render or allocation failure. + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderRequestClassification.cs b/src/Beutl.Engine/Graphics/Rendering/RenderRequestClassification.cs new file mode 100644 index 0000000000..93951c13cf --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderRequestClassification.cs @@ -0,0 +1,16 @@ +namespace Beutl.Graphics.Rendering; + +public enum RenderIntent +{ + Preview, + Delivery, +} + +public enum RenderRequestPurpose +{ + Frame, + HitTest, + Bounds, + CacheWarmup, + Auxiliary, +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs b/src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs new file mode 100644 index 0000000000..58826ebbdf --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderScaleUtilities.cs @@ -0,0 +1,200 @@ +using Beutl.Media; + +namespace Beutl.Graphics.Rendering; + +/// +/// Pure working-density calculations shared by recording, planning, 3D, brushes, and export policy. +/// +public static class RenderScaleUtilities +{ + public const int MaxBufferDimension = 16384; + + private const int RasterApronPixels = 2; + + public static float SanitizeMaxWorkingScale(float maxWorkingScale) + => float.IsNaN(maxWorkingScale) || maxWorkingScale <= 0f + ? float.PositiveInfinity + : maxWorkingScale; + + internal static bool IsExactIntegerReduction(float scale) + { + if (!float.IsFinite(scale) || scale <= 0f || scale >= 1f) + return false; + + float reduction = 1f / scale; + return MathF.Abs(reduction - MathF.Round(reduction)) <= 0.0001f; + } + + public static float ResolveWorkingScale( + ReadOnlySpan inputs, + float outputScale, + float maxWorkingScale = float.PositiveInfinity) + { + if (!float.IsFinite(outputScale) || outputScale <= 0f) + outputScale = 1f; + + float supply = outputScale; + foreach (EffectiveScale input in inputs) + { + if (!input.IsUnbounded && input.Value > supply) + supply = input.Value; + } + + return MathF.Min(supply, SanitizeMaxWorkingScale(maxWorkingScale)); + } + + /// + /// Reduces until the device footprint + /// would allocate for + /// fits on both axes. The scale is never raised, and the logical + /// extents alone are also kept within the budget so the result stays independent of where the + /// caller finally places the buffer. + /// + public static float ClampWorkingScaleToBufferBudget( + Rect logicalBounds, + float workingScale, + int maxDimension = MaxBufferDimension) + { + ValidateMaxDimension(maxDimension); + + if (!float.IsFinite(workingScale) || workingScale <= 0f) + return workingScale; + + return FitScaleToDeviceFootprint(logicalBounds, workingScale, maxDimension, apronPixels: 0); + } + + internal static float ClampWorkingScaleToExactBufferBudget( + Rect logicalBounds, + float workingScale, + int maxDimension = MaxBufferDimension) + => ClampWorkingScaleToExactFootprintBudget( + logicalBounds, + workingScale, + maxDimension, + apronPixels: 0); + + internal static PixelRect AddRasterApron(PixelRect bounds) + => new( + checked(bounds.X - 1), + checked(bounds.Y - 1), + checked(bounds.Width + 2), + checked(bounds.Height + 2)); + + internal static float ClampWorkingScaleToRasterApronBudget( + Rect logicalBounds, + float workingScale, + int maxDimension = MaxBufferDimension) + => ClampWorkingScaleToExactFootprintBudget( + logicalBounds, + workingScale, + maxDimension, + RasterApronPixels); + + private static float ClampWorkingScaleToExactFootprintBudget( + Rect logicalBounds, + float workingScale, + int maxDimension, + int apronPixels) + { + ValidateMaxDimension(maxDimension); + + if (!float.IsFinite(workingScale) || workingScale <= 0f) + return workingScale; + + if (HasFiniteBounds(logicalBounds) + && FitsDeviceFootprint(logicalBounds, workingScale, maxDimension, apronPixels)) + { + return workingScale; + } + + return FitScaleToDeviceFootprint(logicalBounds, workingScale, maxDimension, apronPixels); + } + + private static float FitScaleToDeviceFootprint( + Rect logicalBounds, + float workingScale, + int maxDimension, + int apronPixels) + { + double maxAxis = MaxLogicalAxis(logicalBounds); + + // A fractional origin can push the footprint one device pixel past ceil(extent * scale), so the + // extent estimate is only a seed: give a pixel back until the footprint itself fits. + for (int budget = maxDimension - apronPixels; budget > 0; budget--) + { + float candidate = FitScaleToLogicalExtent(maxAxis, workingScale, budget); + if (candidate <= 0f) + break; + + if (FitsDeviceFootprint(logicalBounds, candidate, maxDimension, apronPixels)) + return candidate; + + // Without a finite positive extent, a lower scale cannot shrink the footprint any further. + if (!double.IsFinite(maxAxis) || maxAxis <= 0) + return workingScale; + } + + // No candidate footprint fit, which a degenerate rectangle can produce at every scale. Zero is not a + // density any caller can use - the working-scale policy rejects it - so a clamp that cannot clamp + // hands back what it was given. An unallocatable buffer is then reported by the allocation itself, + // which already degrades a preview and fails a delivery render. + return workingScale; + } + + private static float FitScaleToLogicalExtent(double maxAxis, float workingScale, int budget) + { + if (!double.IsFinite(maxAxis) || maxAxis <= 0) + return workingScale; + + double largestAxisPixels = Math.Ceiling(maxAxis * workingScale); + if (largestAxisPixels <= budget || largestAxisPixels <= 0) + return workingScale; + + float fit = (float)(workingScale * (budget / largestAxisPixels)); + while (fit > 0f && Math.Ceiling(maxAxis * fit) > budget) + fit = MathF.BitDecrement(fit); + + return MathF.Max(MathF.Min(workingScale, fit), 0f); + } + + private static bool FitsDeviceFootprint( + Rect logicalBounds, + float workingScale, + int maxDimension, + int apronPixels) + { + int budget = maxDimension - apronPixels; + double left = Math.Floor((double)logicalBounds.Left * workingScale); + double top = Math.Floor((double)logicalBounds.Top * workingScale); + double right = Math.Ceiling((double)logicalBounds.Right * workingScale); + double bottom = Math.Ceiling((double)logicalBounds.Bottom * workingScale); + double width = right - left; + double height = bottom - top; + + return double.IsFinite(width) + && double.IsFinite(height) + && width >= 0 + && height >= 0 + && width <= budget + && height <= budget; + } + + private static double MaxLogicalAxis(Rect bounds) + => Math.Max(Math.Abs((double)bounds.Width), Math.Abs((double)bounds.Height)); + + private static bool HasFiniteBounds(Rect bounds) + => !bounds.IsInvalid + && float.IsFinite(bounds.X) + && float.IsFinite(bounds.Y) + && float.IsFinite(bounds.Width) + && float.IsFinite(bounds.Height); + + private static void ValidateMaxDimension(int maxDimension) + { + if (maxDimension <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxDimension), maxDimension, "The maximum buffer dimension must be positive."); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderTarget.cs b/src/Beutl.Engine/Graphics/Rendering/RenderTarget.cs index e64e918061..7a098787e0 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RenderTarget.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RenderTarget.cs @@ -1,16 +1,57 @@ using Beutl.Graphics.Backend; -using Beutl.Graphics.Backend.Vulkan; using Beutl.Media; using Beutl.Threading; using SkiaSharp; namespace Beutl.Graphics.Rendering; +internal readonly struct RenderTargetSamplingIntent +{ + private readonly RenderTargetSamplingIntentKind _kind; + private readonly GRRecordingContext? _consumerContext; + + private RenderTargetSamplingIntent( + RenderTargetSamplingIntentKind kind, + GRRecordingContext? consumerContext = null) + { + _kind = kind; + _consumerContext = consumerContext; + } + + public static RenderTargetSamplingIntent CpuReadback => default; + + public static RenderTargetSamplingIntent BackendInterop { get; } + = new(RenderTargetSamplingIntentKind.BackendInterop); + + public static RenderTargetSamplingIntent SameContextTextureSampling(GRRecordingContext? consumerContext) + => new(RenderTargetSamplingIntentKind.SameContextTextureSampling, consumerContext); + + internal bool RequiresBackendInterop => _kind == RenderTargetSamplingIntentKind.BackendInterop; + + internal bool CanSubmitWithoutCompletion(GRRecordingContext? producerContext) + { + if (_kind != RenderTargetSamplingIntentKind.SameContextTextureSampling) + return false; + + return producerContext is null + ? _consumerContext is null + : _consumerContext is not null && producerContext.Handle == _consumerContext.Handle; + } +} + +internal enum RenderTargetSamplingIntentKind : byte +{ + CpuReadback, + BackendInterop, + SameContextTextureSampling, +} + public class RenderTarget : IDisposable { private readonly SKSurfaceCounter _surface; private readonly SKSurfaceCounter? _texture; private readonly Dispatcher? _dispatcher = Dispatcher.Current; + private bool _hasTransparentContents; private RenderTarget(SKSurfaceCounter surface, int width, int height, SKSurfaceCounter? texture = null) @@ -36,7 +77,24 @@ protected RenderTarget(SKSurface surface, int width, int height) Dispose(disposing: false); } - internal SKSurface Value => + internal SKSurface Value + { + get + { + SKSurface surface = RawValue; + ITexture2D? texture = _texture?.Value; + if (texture is ITransparentClearableTexture { HasTransparentContents: true }) + { + // Value exposes the mutable Skia surface directly. Submit a pending transparent + // initialization before an unwrapped Canvas operation can overtake that clear. + texture.PrepareForSkiaRendering(); + } + _hasTransparentContents = false; + return surface; + } + } + + internal SKSurface RawValue => !IsDisposed ? _surface.Value! : throw new ObjectDisposedException(nameof(RenderTarget)); public int Width { get; } @@ -64,8 +122,7 @@ protected RenderTarget(SKSurface surface, int width, int height) if (context != null) { - sharedTexture = context.CreateTexture2D(width, height, TextureFormat.RGBA16Float); - surface = sharedTexture.CreateSkiaSurface(); + surface = CreateSharedSurface(context, width, height, out sharedTexture); } else { @@ -74,10 +131,26 @@ protected RenderTarget(SKSurface surface, int width, int height) } } - var textureRef = sharedTexture != null ? new SKSurfaceCounter(sharedTexture) : null; - return surface == null - ? null - : new RenderTarget(new SKSurfaceCounter(surface), width, height, textureRef); + if (surface == null) + return null; + + // Skia refcounts the surface itself and only borrows the image behind it, so the + // backend texture is the one resource that can outlive its last managed reference. + var textureRef = sharedTexture != null + ? new SKSurfaceCounter( + sharedTexture, + deferRelease: true, + approximateBytes: (long)width * height * 8) + : null; + + var result = new RenderTarget( + new SKSurfaceCounter(surface), + width, + height, + textureRef); + if (!result.HasTransparentContents) + result.ClearToTransparent(); + return result; } catch { @@ -85,6 +158,62 @@ protected RenderTarget(SKSurface surface, int width, int height) } } + /// + /// Creates the backend texture and its Skia surface, releasing both when initialization fails. + /// + /// + /// The backend texture has no finalizer, so escaping this method before the texture reaches a + /// strands its image, view and device memory for the life of the process. + /// + /// + /// The surface wrapping a new backend texture, or when the backend declined to + /// wrap it, in which case the texture has already been released. + /// + internal static SKSurface? CreateSharedSurface( + IGraphicsContext context, + int width, + int height, + out ITexture2D? texture) + { + ITexture2D createdTexture = context.CreateTexture2D(width, height, TextureFormat.RGBA16Float); + texture = createdTexture; + SKSurface? surface = null; + try + { + surface = createdTexture.CreateSkiaSurface(); + if (surface is null) + { + // The backend texture is the one resource here that outlives its last managed reference, so + // a wrap the driver declined has to release it rather than leave it to a finalizer. + createdTexture.Dispose(); + texture = null; + return null; + } + + // Surface wrapping marks Skia access. Record initialization afterwards so an + // untouched snapshot still observes and submits the backend clear. + if (createdTexture is ITransparentClearableTexture clearableTexture) + clearableTexture.ClearToTransparent(); + + return surface; + } + catch + { + // The surface only borrows the backend image, so it has to go first — the same order + // Release uses. + try + { + surface?.Dispose(); + } + finally + { + createdTexture.Dispose(); + } + + throw; + } + } + public static RenderTarget CreateNull(int width, int height) { var surface = SKSurface.CreateNull(width, height); @@ -100,12 +229,34 @@ public static RenderTarget GetRenderTarget(ImmediateCanvas canvas) public Bitmap Snapshot() { VerifyAccess(); - PrepareForSampling(); + PrepareForSampling(RenderTargetSamplingIntent.CpuReadback); var result = CreateSnapshotBitmap(); ReadPixelsInto(result); return result; } + /// + /// Reads the current surface directly into a one-byte-per-pixel alpha bitmap. + /// + /// + /// The GPU backend converts the render target's RgbaF16 pixels to Alpha8 during readback, so + /// callers that inspect only coverage avoid transferring and converting the color channels. + /// This is a synchronous CPU readback and waits for submitted rendering to complete. + /// + public Bitmap SnapshotAlpha() + { + VerifyAccess(); + PrepareForSampling(RenderTargetSamplingIntent.CpuReadback); + var result = new Bitmap( + Width, + Height, + BitmapColorType.Alpha8, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + ReadPixelsInto(result); + return result; + } + /// /// Allocates a bitmap in the exact format produces /// (RgbaF16/Premul/LinearSrgb at the render target size). The single source of truth for that @@ -132,8 +283,8 @@ public void SnapshotInto(Bitmap destination) nameof(destination)); } - // ReadPixels does not convert formats or color spaces, so require the exact format that - // Snapshot() allocates (RgbaF16 / Premul / LinearSrgb). + // Keep the reusable full-color snapshot contract exact even though Skia can convert during + // ReadPixels; alpha-only callers use SnapshotAlpha instead. if (destination.ColorType != BitmapColorType.RgbaF16 || destination.AlphaType != BitmapAlphaType.Premul || !destination.ColorSpace.Equals(BitmapColorSpace.LinearSrgb)) @@ -143,7 +294,7 @@ public void SnapshotInto(Bitmap destination) nameof(destination)); } - PrepareForSampling(); + PrepareForSampling(RenderTargetSamplingIntent.CpuReadback); ReadPixelsInto(destination); } @@ -163,7 +314,10 @@ public RenderTarget ShallowCopy() { _surface.AddRef(); _texture?.AddRef(); - return new RenderTarget(_surface, Width, Height, _texture); + return new RenderTarget(_surface, Width, Height, _texture) + { + _hasTransparentContents = _hasTransparentContents, + }; } public void VerifyAccess() @@ -197,10 +351,9 @@ protected virtual void Dispose(bool disposing) SKSurfaceCounter surface = _surface; SKSurfaceCounter? texture = _texture; - if (!disposing && _dispatcher is { HasShutdownFinished: false } dispatcher && !dispatcher.CheckAccess()) + if (!disposing) { - // A finalizer must not block on another thread, so it cannot use the bounded wait below. - dispatcher.Dispatch(() => Release(surface, texture)); + GpuResourceRelease.DispatchFinalizer(_dispatcher, () => Release(surface, texture)); return; } @@ -223,18 +376,100 @@ internal void BeginDraw() { VerifyAccess(); - _texture?.Value?.PrepareForRender(); + _hasTransparentContents = false; + _texture?.Value?.PrepareForSkiaRendering(); } - internal void PrepareForSampling() + internal void PrepareBackendForSkiaSampling() { VerifyAccess(); + _texture?.Value?.PrepareForSkiaSampling(requireCompletion: false); + } - _surface.Value!.Flush(true, true); - _texture?.Value?.PrepareForSampling(); + internal bool HasTransparentContents + => _texture?.Value is ITransparentClearableTexture clearableTexture + ? clearableTexture.HasTransparentContents + : _hasTransparentContents; + + internal void ClearToTransparent() + { + VerifyAccess(); + BeginDraw(); + _surface.Value!.Canvas.Clear(SKColors.Transparent); + + // A canvas clear is a deferred Skia draw, and a custom effect that writes this image through + // the Vulkan backend does so outside Skia's task graph. PrepareForSampling only covers a + // target used as a source, so a write destination would keep the clear pending until after + // the native writer had already filled it. Submitting it here, without the sampling + // bookkeeping, keeps the clear ahead of any such writer. + _surface.Value.Flush(true, false); + _hasTransparentContents = true; + + // HasTransparentContents prefers the backend's record when there is one, and the clear above went + // through Skia, which the backend cannot observe. Telling it here is what keeps a reused pooled + // target from being cleared a second time by the next caller that wants a blank one. + if (_texture?.Value is ITransparentClearableTexture clearableTexture) + clearableTexture.MarkContentsTransparent(); } - private sealed class SKSurfaceCounter(T value) + internal void PrepareForSampling(RenderTargetSamplingIntent intent) + { + VerifyAccess(); + + bool waitForCompletion = !intent.CanSubmitWithoutCompletion(_surface.Value!.Context); + ITexture2D? texture = _texture?.Value; + + if (intent.RequiresBackendInterop + && texture is { RequiresSkiaFlushForBackendInterop: false }) + { + // A backend-produced target can remain in the same recording batch. There is no Skia + // work to submit between consecutive native passes. + texture.PrepareForSampling(); + + if (texture is ITransparentClearableTexture { HasTransparentContents: true }) + { + // A clear-only target can be exposed directly, with no following native pass to + // submit its initialization. Preserve the completion boundary for that exposure, + // then restore Vulkan ownership without consuming a second recording batch. + texture.PrepareForSkiaSampling(requireCompletion: true); + texture.PrepareForSampling(); + ImmediateCanvas.RecordFlush(ImmediateCanvasFlushKind.PrepareForSampling); + } + return; + } + + if (!intent.RequiresBackendInterop) + { + // Submit backend writes before Skia records a dependent read. CPU readback waits here; + // same-context GPU sampling relies on queue order and does not stall the CPU. + texture?.PrepareForSkiaSampling(waitForCompletion); + } + + // A context-wide flush is a superset of this surface's, so reclaiming deferred targets here + // replaces the surface flush instead of adding a second submit - but only when it flushed this + // surface's own context. A target from a caller-supplied factory can live on another one. + if (GpuResourceReclaimQueue.FlushAndDrain(_surface.Value!.Context)) + { + waitForCompletion = true; + } + else + { + _surface.Value.Flush(true, waitForCompletion); + } + + ImmediateCanvas.RecordFlush(waitForCompletion + ? ImmediateCanvasFlushKind.PrepareForSampling + : ImmediateCanvasFlushKind.PrepareForSamplingSubmit); + if (intent.RequiresBackendInterop) + { + // The caller is about to touch the texture through the backend, which does not route + // through BeginDraw, so the transparency tracking cannot survive it. + _hasTransparentContents = false; + texture?.PrepareForSampling(); + } + } + + private sealed class SKSurfaceCounter(T value, bool deferRelease = false, long approximateBytes = 0) where T : class, IDisposable { private readonly Dispatcher? _dispatcher = Dispatcher.Current; @@ -282,11 +517,11 @@ public void Release() if (_dispatcher is { HasShutdownFinished: false } dispatcher && !dispatcher.CheckAccess()) { - dispatcher.Dispatch(value.Dispose); + dispatcher.Dispatch(() => ReleaseValue(value)); } else { - value.Dispose(); + ReleaseValue(value); } } } @@ -297,5 +532,15 @@ public void Release() old = current; } } + + private void ReleaseValue(T value) + { + if (deferRelease && GpuResourceReclaimQueue.TryDefer(value, approximateBytes)) + { + return; + } + + value.Dispose(); + } } } diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderTargetDomainRequiredException.cs b/src/Beutl.Engine/Graphics/Rendering/RenderTargetDomainRequiredException.cs new file mode 100644 index 0000000000..736992f1d8 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderTargetDomainRequiredException.cs @@ -0,0 +1,13 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// The recorded render graph requires a finite owning target domain that the current request did not provide. +/// +public sealed class RenderTargetDomainRequiredException : InvalidOperationException +{ + /// Initializes the exception with the domain requirement that could not be satisfied. + public RenderTargetDomainRequiredException(string message) + : base(message) + { + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderValueCardinality.cs b/src/Beutl.Engine/Graphics/Rendering/RenderValueCardinality.cs new file mode 100644 index 0000000000..7b01b78923 --- /dev/null +++ b/src/Beutl.Engine/Graphics/Rendering/RenderValueCardinality.cs @@ -0,0 +1,82 @@ +namespace Beutl.Graphics.Rendering; + +/// +/// Declares the number of materializable values represented by a recorded render fragment. +/// +public readonly struct RenderValueCardinality : IEquatable +{ + private readonly bool _isInitialized; + + private RenderValueCardinality(int minimum, int? maximum) + { + Minimum = minimum; + Maximum = maximum; + _isInitialized = true; + } + + public int Minimum { get; } + + public int? Maximum { get; } + + public static RenderValueCardinality None { get; } = new(0, 0); + + public static RenderValueCardinality Single { get; } = new(1, 1); + + public static RenderValueCardinality ZeroOrOne { get; } = new(0, 1); + + public static RenderValueCardinality Dynamic { get; } = new(0, null); + + public static RenderValueCardinality Exactly(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "The value count cannot be negative."); + + return new RenderValueCardinality(count, count); + } + + public static RenderValueCardinality Range(int minimum, int? maximum) + { + if (minimum < 0) + { + throw new ArgumentOutOfRangeException( + nameof(minimum), minimum, "The minimum value count cannot be negative."); + } + + if (maximum is < 0) + { + throw new ArgumentOutOfRangeException( + nameof(maximum), maximum, "The maximum value count cannot be negative."); + } + + if (maximum < minimum) + { + throw new ArgumentOutOfRangeException( + nameof(maximum), maximum, "The maximum value count cannot be smaller than the minimum."); + } + + return new RenderValueCardinality(minimum, maximum); + } + + public bool Equals(RenderValueCardinality other) + => _isInitialized == other._isInitialized + && Minimum == other.Minimum + && Maximum == other.Maximum; + + public override bool Equals(object? obj) + => obj is RenderValueCardinality other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine(_isInitialized, Minimum, Maximum); + + internal bool IsInitialized => _isInitialized; + + internal void ThrowIfUninitialized(string parameterName) + { + if (!_isInitialized) + { + throw new ArgumentException( + "default(RenderValueCardinality) is uninitialized; use a named value, Exactly, or Range.", + parameterName); + } + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/Renderer.cs b/src/Beutl.Engine/Graphics/Rendering/Renderer.cs index 0e9af97575..5f3a7335d9 100644 --- a/src/Beutl.Engine/Graphics/Rendering/Renderer.cs +++ b/src/Beutl.Engine/Graphics/Rendering/Renderer.cs @@ -1,8 +1,10 @@ using System.Runtime.CompilerServices; using Beutl.Composition; +using Beutl.Graphics.Backend; using Beutl.Graphics.Rendering.Cache; using Beutl.Logging; using Beutl.Media; +using Beutl.Threading; using Microsoft.Extensions.Logging; namespace Beutl.Graphics.Rendering; @@ -13,104 +15,232 @@ public class Renderer : IRenderer private readonly ImmediateCanvas _immediateCanvas; private readonly RenderTarget _surface; + private readonly Dispatcher _dispatcher; private readonly ConditionalWeakTable _nodeCache = new(); private readonly List _allCurrentEntries = []; + + private readonly ClearRenderNode _frameClear; + private readonly CompleteTargetRenderNode _completeTarget; + private RenderNodeRenderer _frameRenderer; private RenderCacheOptions _cacheOptions = RenderCacheOptions.CreateFromGlobalConfiguration(); - private class Entry(DrawableRenderNode node) : IDisposable + private class Entry(DrawableRenderNode node, RenderNodeRenderer renderer, Dispatcher dispatcher) : IDisposable { + private Rect _bounds; + ~Entry() { - Dispose(); + GpuResourceRelease.DispatchFinalizer( + dispatcher, + () => + { + Dispose(); + }); } public DrawableRenderNode Node { get; } = node; - public Rect Bounds { get; set; } + public RenderNodeRenderer Renderer { get; } = renderer; public bool IsDisposed { get; private set; } + public void InvalidateBounds() + { + _bounds = default; + HasValidBounds = false; + } + + public Rect GetBounds() + { + if (!HasValidBounds) + { + _bounds = Renderer.Measure().QueryBounds; + HasValidBounds = true; + } + + return _bounds; + } + + public Rect RecalculateBounds() + { + _bounds = Renderer.Measure().QueryBounds; + HasValidBounds = true; + return _bounds; + } + + private bool HasValidBounds { get; set; } + public void Dispose() { + VerifyCleanupAccess(dispatcher); if (!IsDisposed) { - Node.Dispose(); IsDisposed = true; + Exception? primary = null; + try + { + Renderer.Dispose(); + } + catch (Exception ex) + { + primary = ex; + } + + try + { + Node.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + GC.SuppressFinalize(this); + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); } } } - public Renderer(int width, int height, float renderScale = 1f, float maxWorkingScale = float.PositiveInfinity) + /// Creates a renderer for one declared purpose. + /// The output width in logical units. + /// The output height in logical units. + /// + /// What the output is for. This is not a hint: it decides whether an intermediate allocation failure + /// drops the contribution or fails the render, so it has no safe default - a delivery host that let it + /// default would export a frame missing whatever could not be allocated. + /// + /// The output scale applied to the logical size. + /// The global upper bound on any intermediate's working density. + public Renderer( + int width, + int height, + RenderIntent intent, + float renderScale = 1f, + float maxWorkingScale = float.PositiveInfinity) + : this( + width, + height, + intent, + renderScale, + maxWorkingScale, + surface: null) { + } + + internal Renderer( + int width, + int height, + RenderIntent intent, + float renderScale, + float maxWorkingScale, + RenderTarget? surface, + Dispatcher? dispatcher = null) + { + static void DisposePreservingPrimaryFailure(IDisposable? value) + { + try + { + value?.Dispose(); + } + catch + { + // Constructor cleanup must not replace the failure that triggered it. + } + } + + _dispatcher = dispatcher ?? RenderThread.Dispatcher; + + if (!Enum.IsDefined(intent)) + { + // This constructor owns `surface` from entry. + DisposePreservingPrimaryFailure(surface); + throw new ArgumentOutOfRangeException(nameof(intent), intent, "Unknown render intent."); + } + float outputScale = float.IsFinite(renderScale) && renderScale > 0f ? renderScale : 1f; - float maxScale = RenderNodeContext.SanitizeMaxWorkingScale(maxWorkingScale); + float maxScale = RenderScaleUtilities.SanitizeMaxWorkingScale(maxWorkingScale); FrameSize = new PixelSize(width, height); OutputScale = outputScale; MaxWorkingScale = maxScale; + Intent = intent; DeviceSize = new PixelSize( (int)MathF.Ceiling(width * outputScale), (int)MathF.Ceiling(height * outputScale)); - (_immediateCanvas, _surface) = RenderThread.Dispatcher.Invoke(() => + _frameClear = new ClearRenderNode(default); + _completeTarget = new CompleteTargetRenderNode(_frameClear, []); + _frameRenderer = CreateEntryRenderer( + _completeTarget, + RenderRequestPurpose.Frame); + try { - RenderTarget surface = RenderTarget.Create(DeviceSize.Width, DeviceSize.Height) - ?? throw new InvalidOperationException( - $"Could not create a canvas of this size. (width: {DeviceSize.Width}, height: {DeviceSize.Height})"); + (_immediateCanvas, _surface) = _dispatcher.Invoke(() => + { + RenderTarget? actualSurface = null; + try + { + actualSurface = surface + ?? RenderTarget.Create(DeviceSize.Width, DeviceSize.Height) + ?? throw new InvalidOperationException( + $"Could not create a canvas of this size. (width: {DeviceSize.Width}, height: {DeviceSize.Height})"); + if (actualSurface.Width != DeviceSize.Width || actualSurface.Height != DeviceSize.Height) + { + throw new ArgumentException( + "The injected render target must match the renderer device size.", + nameof(surface)); + } + + var canvas = new ImmediateCanvas(actualSurface, outputScale, maxScale, + logicalSize: FrameSize.ToSize(1), intent: intent); + return (canvas, actualSurface); + } + catch + { + DisposePreservingPrimaryFailure(actualSurface); + throw; + } + }); + } + catch + { + // Construction transferred ownership of these helpers before the surface was created. + // Release all of them, but never replace the constructor's primary failure. + DisposePreservingPrimaryFailure(_frameRenderer); + DisposePreservingPrimaryFailure(_completeTarget); + DisposePreservingPrimaryFailure(_frameClear); - var canvas = new ImmediateCanvas(surface, outputScale, maxScale, - logicalSize: FrameSize.ToSize(1)); - return (canvas, surface); - }); + throw; + } } ~Renderer() { - // A finalizer must never throw. Each step is guarded independently so a failure cannot - // skip releasing the GPU surface. - if (IsDisposed) + // A finalizer must never throw or release render-owned resources from the finalizer thread. + if (Interlocked.CompareExchange(ref _disposeClaimed, 1, 0) != 0) return; - static void SafeStep(string step, Action action) + try { - try - { - action(); - } - catch (Exception ex) - { - s_logger.LogDebug(ex, "Renderer finalizer: {Step} threw during last-resort disposal", step); - } + OnDispose(false); } - - void ReleaseGpuResources() + catch (Exception ex) { - SafeStep(nameof(_immediateCanvas), () => _immediateCanvas?.Dispose()); - SafeStep(nameof(_surface), () => _surface?.Dispose()); - // Core, not the public wrapper: this already runs on the render thread, or in place - // because it is gone — either way the wrapper's bounded wait must not re-enter here. - SafeStep(nameof(ClearAllCachesCore), ClearAllCachesCore); - SafeStep(nameof(DisposeAllEntries), DisposeAllEntries); + s_logger.LogDebug(ex, "Renderer finalizer: OnDispose threw during last-resort disposal"); } - _isDisposed = true; - SafeStep(nameof(OnDispose), () => OnDispose(false)); - - // The finalizer thread does not own these GPU resources and must not block waiting for the - // thread that does, so hand the release over unless that thread is already gone. Finished, - // not Started: the latter is set before the operation already running has returned. - if (RenderThread.Dispatcher.HasShutdownFinished) + try { - ReleaseGpuResources(); + DispatchFinalizerRenderResourceCleanup(); } - else + catch (Exception ex) { - SafeStep(nameof(ReleaseGpuResources), () => RenderThread.Dispatcher.Dispatch(ReleaseGpuResources)); + s_logger.LogDebug(ex, "Renderer finalizer: cleanup dispatch threw during last-resort disposal"); } } - private volatile bool _isDisposed; + private int _disposeClaimed; - public bool IsDisposed => _isDisposed; + public bool IsDisposed => Volatile.Read(ref _disposeClaimed) != 0; public bool IsGraphicsRendering { get; private set; } @@ -120,8 +250,8 @@ public RenderCacheOptions CacheOptions set { ArgumentNullException.ThrowIfNull(value); - ClearAllCaches(); - _cacheOptions = value; + ObjectDisposedException.ThrowIf(IsDisposed, this); + GpuResourceRelease.RunRequired(_dispatcher, () => SetCacheOptionsCore(value)); } } @@ -135,39 +265,113 @@ public RenderCacheOptions CacheOptions /// Working-scale ceiling. Preview: 2 * s_out; export: +Inf. public float MaxWorkingScale { get; } + /// + /// Intent applied to every request this renderer issues. drops a + /// contribution whose intermediate target cannot be allocated; + /// fails the render instead, so a delivery-grade output never silently loses content. + /// + public RenderIntent Intent { get; } + /// /// The physical backing-surface size, ceil(FrameSize × OutputScale). /// Ceiling preserves fractional edge pixels; only place OutputScale sizes a surface. /// public PixelSize DeviceSize { get; } + internal RenderIntent FrameRequestIntent + => _frameRenderer.Options.DefaultRequest.Intent; + + internal StructuralPlanCacheStatistics FrameStructuralPlanCacheStatistics + => _frameRenderer.StructuralPlanCacheStatistics; + + internal ProgramCacheStatistics FrameProgramCacheStatistics + => _frameRenderer.ProgramCacheStatistics; + + internal RenderTargetPoolStatistics FrameTargetPoolStatistics + => _frameRenderer.TargetPoolStatistics; + + internal long RetainedRenderTargetBytes + => _frameRenderer.TargetPoolStatistics.RetainedBytes + + _nodeCache.Sum(static pair => pair.Value.Renderer.TargetPoolStatistics.RetainedBytes); + public void Dispose() { - if (!IsDisposed) - { - _isDisposed = true; - OnDispose(true); - // The canvas, the surface and every cached node hold GPU resources owned by the render - // thread, so tear them down there. - GpuResourceRelease.Run(RenderThread.Dispatcher, () => + if (Interlocked.CompareExchange(ref _disposeClaimed, 1, 0) != 0) + return; + + Exception? primary = null; + + CaptureCleanupFailure(() => OnDispose(true), ref primary); + CaptureCleanupFailure( + () => GpuResourceRelease.Run(_dispatcher, () => { - _immediateCanvas.Dispose(); - _surface.Dispose(); - ClearAllCachesCore(); - DisposeAllEntries(); - }); - GC.SuppressFinalize(this); + Exception? renderResourceFailure = DisposeRenderResourcesCore(); + if (renderResourceFailure is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(renderResourceFailure) + .Throw(); + } + }), + ref primary); + GC.SuppressFinalize(this); + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + } + + private static void CaptureCleanupFailure(Action action, ref Exception? primary) + { + try + { + action(); + } + catch (Exception ex) + { + primary ??= ex; + } + } + + private static void VerifyCleanupAccess(Dispatcher dispatcher) + { + if (!dispatcher.CheckAccess() && !dispatcher.HasShutdownFinished) + { + dispatcher.VerifyAccess(); } } - /// is already true when this method is called. + private Exception? DisposeRenderResourcesCore() + { + VerifyCleanupAccess(_dispatcher); + Exception? primary = null; + CaptureCleanupFailure(() => _completeTarget?.UpdateRoots([]), ref primary); + CaptureCleanupFailure(() => _frameRenderer?.Dispose(), ref primary); + CaptureCleanupFailure(() => _completeTarget?.Dispose(), ref primary); + CaptureCleanupFailure(() => _frameClear?.Dispose(), ref primary); + CaptureCleanupFailure(() => _immediateCanvas?.Dispose(), ref primary); + CaptureCleanupFailure(() => _surface?.Dispose(), ref primary); + CaptureCleanupFailure(ClearEntryCachesCore, ref primary); + CaptureCleanupFailure(DisposeAllEntriesCore, ref primary); + return primary; + } + + /// Releases resources owned by a derived renderer. + /// + /// true when called synchronously by ; false when called by the finalizer. + /// + /// + /// is already true when this method is called. The true path runs + /// inline and synchronously on the thread calling . The false path runs inline + /// on the finalizer thread before render-resource cleanup is dispatched, and must not access + /// render-thread-affine resources. + /// protected virtual void OnDispose(bool disposing) { } public void Render(CompositionFrame frame) { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); if (IsGraphicsRendering) return; @@ -175,12 +379,8 @@ public void Render(CompositionFrame frame) { IsGraphicsRendering = true; Time = frame.Time.Start; - ClearFrame(); - using (_immediateCanvas.Push()) { - _immediateCanvas.Clear(); - RenderObjects(frame); } } @@ -191,137 +391,159 @@ public void Render(CompositionFrame frame) } private void RenderObjects(CompositionFrame frame) + { + var pendingEntries = new List(); + try + { + PrepareEntries(frame, pendingEntries); + + _completeTarget.UpdateRoots(pendingEntries.Select(static entry => (RenderNode)entry.Node)); + _frameRenderer.Render(_immediateCanvas); + } + finally + { + ClearFrame(); + } + + InvalidateEntryBounds(pendingEntries); + _allCurrentEntries.AddRange(pendingEntries); + } + + private void PrepareEntries(CompositionFrame frame, List destination) { foreach (var obj in frame.Objects) { if (obj is not Drawable.Resource drawableResource) continue; - var entry = RenderDrawable(drawableResource); - _allCurrentEntries.Add(entry); + + destination.Add(PrepareDrawable(drawableResource)); } } - private Entry RenderDrawable(Drawable.Resource resource) + private static void InvalidateEntryBounds(List entries) { - var drawable = resource.GetOriginal(); + foreach (Entry entry in entries) + { + entry.InvalidateBounds(); + } + } + + private Entry PrepareDrawable(Drawable.Resource resource) + { + Drawable drawable = resource.GetOriginal()!; Entry entry; bool shouldRender; if (!_nodeCache.TryGetValue(drawable, out entry!)) { AddDetachedHandler(drawable); - entry = new Entry(new DrawableRenderNode(resource)); + entry = CreateEntry(resource); _nodeCache.Add(drawable, entry); shouldRender = true; } else { - shouldRender = entry.Node.Update(resource); + shouldRender = entry.Node.Update(resource) || entry.Node.HasChanges; } if (shouldRender) { - using var ctx = new GraphicsContext2D(entry.Node, FrameSize.ToSize(1), OutputScale); - drawable.Render(ctx, resource); + try + { + using var ctx = new GraphicsContext2D(entry.Node, FrameSize.ToSize(1), OutputScale); + drawable.Render(ctx, resource); + } + catch + { + entry.Node.HasChanges = true; + throw; + } } - RevalidateAll(entry.Node); - entry.Node.PrepareForProcess(_immediateCanvas); - var processor = new RenderNodeProcessor(entry.Node, CacheOptions.IsEnabled, OutputScale, MaxWorkingScale); - var ops = processor.PullToRoot(); - Rect bounds = Rect.Empty; - int consumed = 0; + return entry; + } + + private Entry CreateEntry(Drawable.Resource resource) + { + var node = new DrawableRenderNode(resource); try { - foreach (var op in ops) - { - op.Render(_immediateCanvas); - bounds = bounds.Union(op.Bounds); - // consumed++ trails op.Bounds (a throw site) so a throw before op.Dispose leaves - // this op in the cleanup sweep below. - consumed++; - op.Dispose(); - } + return new Entry(node, CreateEntryRenderer(node), _dispatcher); } catch { - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); + node.Dispose(); throw; } - - entry.Bounds = bounds; - RenderNodeCacheHelper.MakeCache(entry.Node, CacheOptions, OutputScale, MaxWorkingScale); - return entry; } + private RenderNodeRenderer CreateEntryRenderer( + RenderNode node, + RenderRequestPurpose purpose = RenderRequestPurpose.Auxiliary, + RenderCacheOptions? cacheOptions = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = Intent, + TargetDomain = new Rect(default, FrameSize.ToSize(1)), + OutputScale = OutputScale, + MaxWorkingScale = MaxWorkingScale, + CacheOptions = cacheOptions ?? CacheOptions, + Purpose = purpose, + }, + }); + private void AddDetachedHandler(Drawable drawable) { var weakRef = new WeakReference(this); + Dispatcher dispatcher = _dispatcher; void Handler(object? sender, HierarchyAttachmentEventArgs e) { if (sender is not Drawable senderDrawable) return; senderDrawable.DetachedFromHierarchy -= Handler; + if (dispatcher.HasShutdownStarted) + { + return; + } + + var drawableRef = new WeakReference(senderDrawable); // Detaching happens on the edit thread, but the entry's cache is GPU state owned by the // render thread. Queued rather than awaited so an edit never blocks behind a frame. - RenderThread.Dispatcher.Dispatch(() => + dispatcher.Dispatch(() => { - // Nothing awaits this and the dispatcher has no UnhandledException handler, so an - // escaping exception would rethrow out of its loop and kill the render thread. if (!weakRef.TryGetTarget(out Renderer? renderer) - || !renderer._nodeCache.TryGetValue(senderDrawable, out Entry? entry)) + || !drawableRef.TryGetTarget(out Drawable? detachedDrawable)) { return; } - // Independently guarded: a failed cache clear must not skip the entry's own - // disposal, which is the only thing that releases its node. - try - { - RenderNodeCacheHelper.ClearCache(entry.Node); - } - catch (Exception ex) - { - s_logger.LogWarning(ex, "Failed to clear the render cache of a detached drawable"); - } - try { - entry.Dispose(); + renderer.EvictEntryCore(detachedDrawable); } catch (Exception ex) { - s_logger.LogWarning(ex, "Failed to dispose the render entry of a detached drawable"); + s_logger.LogWarning(ex, "Failed to dispose a detached drawable's render entry"); } - - // The handler is already unsubscribed, so an entry left behind would be reused by a - // reattached drawable and never retried. - renderer._nodeCache.Remove(senderDrawable); }); } drawable.DetachedFromHierarchy += Handler; } - private static void RevalidateAll(RenderNode current) + private void EvictEntryCore(Drawable drawable) { - RenderNodeCache cache = current.Cache; - - if (current is ContainerRenderNode c) - { - foreach (RenderNode item in c.Children) - { - RevalidateAll(item); - } - } - - cache.IncrementRenderCount(); - current.HasChanges = false; - if (cache.IsCached && !RenderNodeCacheHelper.CanCacheRecursive(current)) + _dispatcher.VerifyAccess(); + if (_nodeCache.TryGetValue(drawable, out Entry? entry)) { - cache.Invalidate(); + _nodeCache.Remove(drawable); + DisposeEntryCore(entry, clearCache: true); } } @@ -332,66 +554,28 @@ private void ClearFrame() public void UpdateFrame(CompositionFrame frame) { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); Time = frame.Time.Start; ClearFrame(); + var pendingEntries = new List(); - foreach (var obj in frame.Objects) - { - if (obj is not Drawable.Resource drawableResource) - continue; - - var drawable = drawableResource.GetOriginal(); - Entry entry; - bool shouldRender; - - if (!_nodeCache.TryGetValue(drawable, out entry!)) - { - AddDetachedHandler(drawable); - entry = new Entry(new DrawableRenderNode(drawableResource)); - _nodeCache.Add(drawable, entry); - shouldRender = true; - } - else - { - shouldRender = entry.Node.Update(drawableResource); - } - - if (shouldRender) - { - using var ctx = new GraphicsContext2D(entry.Node, FrameSize.ToSize(1), OutputScale); - drawable.Render(ctx, drawableResource); - } - - RevalidateAll(entry.Node); - _allCurrentEntries.Add(entry); - } + PrepareEntries(frame, pendingEntries); + InvalidateEntryBounds(pendingEntries); + _allCurrentEntries.AddRange(pendingEntries); } public Drawable? HitTest(CompositionFrame frame, Point point) { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); UpdateFrame(frame); for (int i = _allCurrentEntries.Count - 1; i >= 0; i--) { Entry entry = _allCurrentEntries[i]; // Same scale pair as the render pass to avoid thrashing scale-stateful nodes. - var processor = new RenderNodeProcessor(entry.Node, CacheOptions.IsEnabled, OutputScale, MaxWorkingScale); - var arr = processor.PullToRoot(); - try + if (entry.Renderer.HitTest(point)) { - if (arr.Any(op => op.HitTest(point))) - { - return entry.Node.Drawable?.Resource.GetOriginal(); - } - } - finally - { - foreach (var op in arr) - { - op.Dispose(); - } + return entry.Node.Drawable?.Resource.GetOriginal(); } } @@ -400,17 +584,20 @@ public void UpdateFrame(CompositionFrame frame) public Rect[] GetBoundaries(int zIndex) { - return [.. _allCurrentEntries.Where(e => e.Node.Drawable?.Resource.GetOriginal().ZIndex == zIndex).Select(e => e.Bounds)]; + _dispatcher.VerifyAccess(); + return [.. _allCurrentEntries + .Where(e => e.Node.Drawable?.Resource.GetOriginal()!.ZIndex == zIndex) + .Select(e => e.GetBounds())]; } public Rect? GetBoundary(Drawable drawable) { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); if (_nodeCache.TryGetValue(drawable, out Entry? entry)) { if (_allCurrentEntries.Contains(entry)) { - return entry.Bounds; + return entry.GetBounds(); } // An entry exists but is not included in the current frame (stale). Suggests a draw-lifecycle mismatch. if (s_logger.IsEnabled(LogLevel.Debug)) @@ -432,31 +619,15 @@ public Rect[] GetBoundaries(int zIndex) return null; } + /// Recalculates and caches current-frame bounds for drawables at the specified z-index. + /// This method must be called on the render thread. + /// The caller does not have render-thread access. public Rect[] RecalculateBoundaries(int zIndex) { - return [.. _allCurrentEntries.Where(e => e.Node.Drawable?.Resource.GetOriginal().ZIndex == zIndex).Select(e => - { - var processor = new RenderNodeProcessor(e.Node, CacheOptions.IsEnabled, OutputScale, MaxWorkingScale); - var ops = processor.PullToRoot(); - Rect bounds = Rect.Empty; - int consumed = 0; - try - { - foreach (var op in ops) - { - bounds = bounds.Union(op.Bounds); - consumed++; - op.Dispose(); - } - } - catch - { - RenderNodeOperation.DisposeAll(ops.AsSpan(consumed)); - throw; - } - e.Bounds = bounds; - return bounds; - })]; + _dispatcher.VerifyAccess(); + return [.. _allCurrentEntries + .Where(e => e.Node.Drawable?.Resource.GetOriginal()!.ZIndex == zIndex) + .Select(e => e.RecalculateBounds())]; } public DrawableRenderNode? FindRenderNode(Drawable drawable) @@ -501,7 +672,7 @@ public Rect[] RecalculateBoundaries(int zIndex) public Bitmap Snapshot() { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); return _surface.Snapshot(); } @@ -511,7 +682,7 @@ public Bitmap Snapshot() /// public void SnapshotInto(Bitmap destination) { - RenderThread.Dispatcher.VerifyAccess(); + _dispatcher.VerifyAccess(); _surface.SnapshotInto(destination); } @@ -521,32 +692,181 @@ public void SnapshotInto(Bitmap destination) /// public Bitmap CreateSnapshotBitmap() => _surface.CreateSnapshotBitmap(); - // Callers reach this from the UI thread (a cache-option change, an export teardown), but the - // cached nodes hold GPU resources the render thread owns. + /// + /// Releases reusable intermediate render targets retained by this renderer. + /// + /// The number of pooled target bytes released. + /// + /// Call this between frames on long-running delivery renders to bound backend memory. The current + /// output surface and compiled render plans remain intact, so the next frame only recreates the + /// intermediate targets it needs. + /// + public long ReleaseRetainedRenderTargets() + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + return GpuResourceRelease.RunRequired(_dispatcher, ReleaseRetainedRenderTargetsCore); + } + + private long ReleaseRetainedRenderTargetsCore() + { + _dispatcher.VerifyAccess(); + long released = _frameRenderer.ReleaseRetainedTargets(); + foreach (KeyValuePair pair in _nodeCache) + { + released = checked(released + pair.Value.Renderer.ReleaseRetainedTargets()); + } + + if (released > 0 && GraphicsContextFactory.SharedContext is { } context) + { + // Disposing an SKSurface only unlocks its backing allocation; Ganesh may retain that allocation + // in the shared resource cache. Submit completed work before purging the released scratch bytes + // so Metal/Vulkan can actually return them instead of growing once per delivery frame. + context.SkiaContext.Flush(submit: true, synchronous: true); + GpuResourceReclaimQueue.DrainAfterContextSync(); + context.SkiaContext.PurgeUnlockedResources(released, preferScratchResources: true); + } + + return released; + } + public void ClearAllCaches() { - GpuResourceRelease.Run(RenderThread.Dispatcher, ClearAllCachesCore); + ObjectDisposedException.ThrowIf(IsDisposed, this); + GpuResourceRelease.RunRequired(_dispatcher, ClearAllCachesCore); + } + + private void SetCacheOptionsCore(RenderCacheOptions value) + { + ResetAllCachesCore(value, updateCacheOptions: true); } private void ClearAllCachesCore() { - var entries = _nodeCache.ToArray(); - _nodeCache.Clear(); + ResetAllCachesCore(_cacheOptions, updateCacheOptions: false); + } + + private void ResetAllCachesCore(RenderCacheOptions cacheOptions, bool updateCacheOptions) + { + _dispatcher.VerifyAccess(); + ObjectDisposedException.ThrowIf(IsDisposed, this); + Exception? primary = null; + CaptureCleanupFailure(() => _completeTarget.UpdateRoots([]), ref primary); + + RenderNodeRenderer? replacement = null; + try + { + replacement = CreateEntryRenderer( + _completeTarget, + RenderRequestPurpose.Frame, + cacheOptions); + } + catch (Exception ex) + { + primary ??= ex; + } + + if (replacement is not null) + { + RenderNodeRenderer previous = _frameRenderer; + _frameRenderer = replacement; + if (updateCacheOptions) + _cacheOptions = cacheOptions; + CaptureCleanupFailure(previous.Dispose, ref primary); + } + + CaptureCleanupFailure(ClearEntryCachesCore, ref primary); + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + } + + private void ClearEntryCachesCore() + { + VerifyCleanupAccess(_dispatcher); + var entries = _nodeCache?.ToArray() ?? []; + _nodeCache?.Clear(); + _allCurrentEntries?.Clear(); + Exception? primary = null; foreach (var item in entries) { - RenderNodeCacheHelper.ClearCache(item.Value.Node); - item.Value.Dispose(); + try + { + DisposeEntryCore(item.Value, clearCache: true); + } + catch (Exception ex) + { + primary ??= ex; + } } + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); } - private void DisposeAllEntries() + private void DisposeAllEntriesCore() { - foreach (var item in _nodeCache) + VerifyCleanupAccess(_dispatcher); + var entries = _nodeCache?.ToArray() ?? []; + _nodeCache?.Clear(); + Exception? primary = null; + foreach (var item in entries) { // Compositor側でDisposeされるのでResourceはDisposeせず、NodeだけがDisposeされるようにする - item.Value.Dispose(); + try + { + DisposeEntryCore(item.Value, clearCache: false); + } + catch (Exception ex) + { + primary ??= ex; + } } - _nodeCache.Clear(); + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + } + + private void DisposeEntryCore(Entry entry, bool clearCache) + { + VerifyCleanupAccess(_dispatcher); + Exception? primary = null; + if (clearCache) + { + try + { + RenderNodeCacheHelper.ClearOwnedCaches(entry.Node); + } + catch (Exception ex) + { + primary = ex; + } + } + + try + { + entry.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + } + + private void DispatchFinalizerRenderResourceCleanup() + { + GpuResourceRelease.DispatchFinalizer(_dispatcher, () => + { + Exception? primary = DisposeRenderResourcesCore(); + if (primary is not null) + { + s_logger.LogDebug( + primary, + "Renderer finalizer: render resource cleanup threw during last-resort disposal"); + } + }); } public static ImmediateCanvas GetInternalCanvas(Renderer renderer) @@ -559,3 +879,42 @@ public static RenderTarget GetInternalRenderTarget(Renderer renderer) return renderer._surface; } } + +/// +/// Records the complete ordered set of roots for one target before any of them execute. The roots remain +/// externally owned; this request-local facade never retains fragment handles or disposes render nodes. +/// +internal sealed class CompleteTargetRenderNode : RenderNode +{ + private readonly RenderNode _first; + + // Replaced rather than mutated, so a span handed out by ChildNodes survives an UpdateRoots mid-traversal. + private RenderNode[] _roots; + + public CompleteTargetRenderNode(RenderNode first, IEnumerable remaining) + { + ArgumentNullException.ThrowIfNull(first); + ArgumentNullException.ThrowIfNull(remaining); + _first = first; + _roots = [first, .. remaining]; + if (_roots.Any(static root => root is null)) + throw new ArgumentException("A complete-target root sequence cannot contain null nodes.", nameof(remaining)); + } + + public void UpdateRoots(IEnumerable remaining) + { + ArgumentNullException.ThrowIfNull(remaining); + RenderNode[] roots = [_first, .. remaining]; + if (roots.Any(static root => root is null)) + throw new ArgumentException("A complete-target root sequence cannot contain null nodes.", nameof(remaining)); + _roots = roots; + } + + public override ReadOnlySpan ChildNodes => _roots; + + public override void Process(RenderNodeContext context) + { + foreach (RenderNode root in _roots) + context.PublishRange(context.RecordSubtree(root)); + } +} diff --git a/src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs index c0b447ac2d..736afe1a9f 100644 --- a/src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs @@ -2,76 +2,74 @@ namespace Beutl.Graphics.Rendering; -public class SnapshotBackdropRenderNode : RenderNode, IBackdrop +public class SnapshotBackdropRenderNode : RenderNode, IBackdrop, IBuiltInBackdropCaptureSink { - private Bitmap? _bitmap; - private float _captureScale = 1f; - private ImmediateCanvas? _pendingCanvas; - private bool _capturedThisPass; + private BackdropCapture? _fallback; - public override void PrepareForProcess(ImmediateCanvas canvas) + public override void Process(RenderNodeContext context) { - // Only the fallback for a consumer rasterized during processing; the operation below is the - // capture that lands in the right place in the stream. - _pendingCanvas = canvas; - _capturedThisPass = false; + context.DisableRenderCache(); + context.Publish(context.BuiltInBackdropCapture(this)); } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public void Draw(ImmediateCanvas canvas) { - context.IsRenderCacheEnabled = false; - // A backdrop that follows a sibling inside the same group has to see what that sibling drew, - // which the prepass cannot know: it runs before any operation of the tree has rendered. - return - [ - RenderNodeOperation.CreateLambda(default, canvas => + BackdropCapture? fallback = Volatile.Read(ref _fallback); + if (fallback is not null) + { + // Un-scale by the capture's density, not the replay canvas's density. + if (fallback.Density == 1f) + { + canvas.DrawBitmap(fallback.Bitmap, Brushes.Resource.White, null); + } + else { - // A second full-surface readback when the fallback already captured this pass. - if (!_capturedThisPass) - { - Capture(canvas); - } - }) - ]; + var dest = new Rect( + 0, + 0, + fallback.Bitmap.Width / fallback.Density, + fallback.Bitmap.Height / fallback.Density); + canvas.DrawBitmapScaled(fallback.Bitmap, dest, Brushes.Resource.White); + } + } } - private void Capture(ImmediateCanvas canvas) - { - _capturedThisPass = true; - _bitmap?.Dispose(); - using var renderTarget = RenderTarget.GetRenderTarget(canvas); - _bitmap = renderTarget.Snapshot(); - // Record the surface density (not current Density, which PushDeviceSpace resets to 1). - _captureScale = canvas.SurfaceDensity; - } + bool IBuiltInBackdropCaptureSink.TryCommitBackdropCapture(Bitmap bitmap, float density) + => CommitBackdropCapture(bitmap, density, throwIfDisposed: false); - public void Draw(ImmediateCanvas canvas) + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + => CommitBackdropCapture(bitmap, density, throwIfDisposed: true); + + private bool CommitBackdropCapture(Bitmap bitmap, float density, bool throwIfDisposed) { - if (!_capturedThisPass && _pendingCanvas != null) + ArgumentNullException.ThrowIfNull(bitmap); + if (IsDisposed) { - Capture(_pendingCanvas); + if (throwIfDisposed) + throw new ObjectDisposedException(nameof(SnapshotBackdropRenderNode)); + return false; } - if (_bitmap != null) + if (!float.IsFinite(density) || density <= 0f) { - // Un-scale by the capture's density, not the replay canvas's density. - if (_captureScale == 1f) - { - canvas.DrawBitmap(_bitmap, Brushes.Resource.White, null); - } - else - { - var dest = new Rect(0, 0, _bitmap.Width / _captureScale, _bitmap.Height / _captureScale); - canvas.DrawBitmapScaled(_bitmap, dest, Brushes.Resource.White); - } + throw new ArgumentOutOfRangeException( + nameof(density), + density, + "Capture density must be positive and finite."); } + + var next = new BackdropCapture(bitmap, density); + BackdropCapture? previous = Interlocked.Exchange(ref _fallback, next); + previous?.Bitmap.Dispose(); + return true; } protected override void OnDispose(bool disposing) { base.OnDispose(disposing); - _bitmap?.Dispose(); - _bitmap = null; - _pendingCanvas = null; + BackdropCapture? previous = Interlocked.Exchange(ref _fallback, null); + previous?.Bitmap.Dispose(); } + + private sealed record BackdropCapture(Bitmap Bitmap, float Density); } diff --git a/src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs index d350943fa8..84b0de0828 100644 --- a/src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs @@ -1,4 +1,5 @@ -using Beutl.Media; +using Beutl.Engine; +using Beutl.Media; using Beutl.Media.TextFormatting; using SkiaSharp; @@ -23,23 +24,71 @@ public bool Update(FormattedText text, Brush.Resource? fill, Pen.Resource? pen) return false; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - return - [ - RenderNodeOperation.CreateLambda(Text.ActualBounds, canvas => canvas.DrawText(Text, Fill?.Resource, Pen?.Resource), HitTest) - ]; + FormattedText text = Text; + Rect actualBounds = text.ActualBounds; + // The mask decides emptiness: a glyph can have a degenerate outline and still rasterize something. + Rect rasterBounds = text.GetRasterBounds(context.OutputScale).Union(actualBounds); + if (rasterBounds.Width == 0 || rasterBounds.Height == 0) + return; + + // The bounds a fragment publishes are what place it, so they have to be the text's own. Hinting moves + // the glyph masks off that rectangle by a couple of logical units, and by a different amount at every + // density, so the room the masks need is declared as buffer-only room instead: publishing it would + // shift the composition whenever the preview scale or the export scale changed. A degenerate outline + // leaves no scale-independent rectangle to place by, so the mask's own footprint is all there is. + Rect bounds = actualBounds.Width > 0 && actualBounds.Height > 0 ? actualBounds : rasterBounds; + var rasterOutset = new Thickness( + (float)(bounds.Left - rasterBounds.Left), + (float)(bounds.Top - rasterBounds.Top), + (float)(rasterBounds.Right - bounds.Right), + (float)(rasterBounds.Bottom - bounds.Bottom)); + + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + Brush.Resource? textBrush = text.Brush; + Pen.Resource? textPen = text.Pen; + RenderResource textResource = context.Borrow(text); + RenderResource? textBrushResource = textBrush is null + ? null + : context.Borrow(textBrush); + RenderResource? textPenResource = textPen is null + ? null + : context.Borrow(textPen); + bool hasFill = fill is not null; + + context.Publish(context.PaintedSource( + state: text, + draw: static (canvas, fill, pen, state) => + canvas.DrawText(state, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.FromResource( + textResource, + (currentText, point) => HitTest(currentText, hasFill, point)), + scale: RenderScaleContract.Vector, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent, + resources: DeferredOpaqueSource.Resources( + textResource, + textBrushResource, + textPenResource), + rasterOutset: rasterOutset)); } - private bool HitTest(Point point) + private static bool HitTest(FormattedText text, bool hasFill, Point point) { - SKPath fill = Text.GetFillPath(); - if (Fill != null && fill.Contains(point.X, point.Y)) + SKPath fill = text.GetFillPath(); + if (hasFill && fill.Contains(point.X, point.Y)) { return true; } - SKPath? stroke = Text.GetStrokePath(); + SKPath? stroke = text.GetStrokePath(); return stroke?.Contains(point.X, point.Y) == true; } + } diff --git a/src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs index 828ee4f067..b6c3bb475c 100644 --- a/src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs @@ -21,36 +21,90 @@ public bool Update(Matrix transform, TransformOperator transformOperator) changed = true; } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) + { + Matrix transform = Transform; + TransformOperator transformOperator = TransformOperator; + Matrix inverse = transform.HasInverse ? transform.Invert() : default; + var metadataState = new TransformMetadataState( + transform, + transform.HasInverse, + inverse, + context.TargetDomain); + RenderBoundsContract bounds = transform.HasInverse + ? RenderBoundsContract.Create( + metadataState.TransformBounds, + metadataState.GetRequiredInputBounds) + : RenderBoundsContract.CreateFullInput( + metadataState.TransformBounds); + RenderHitTestContract hitTest = RenderHitTestContract.Custom(metadataState.HitTest); + var scaleMapper = new TransformScaleMapper(transform); + RenderScaleContract scale = RenderScaleContract.MapInputSupply( + scaleMapper.MapSupply, + scaleMapper.MapDemand); + // Set discards the ambient transform for the canvas base transform, so it moves the input even when + // the matrix is identity. + RenderDeviceGridMapping gridMapping = + transform.IsIdentity && transformOperator != TransformOperator.Set + ? RenderDeviceGridMapping.Preserved + : RenderDeviceGridMapping.Remapped; + + // Only Prepend places its matrix in the input's own logical space. Append and Set are defined + // against the ambient target transform, which the value graph has no representation of. + if (transformOperator == TransformOperator.Prepend) + { + TargetScopeDescription description = TargetScopeDescription.CreateValueReplayMap( + session => ExecuteTransform(session, (transform, transformOperator)), + bounds, + hitTest, + scale, + RenderDeviceGridSensitivity.Insensitive, + gridMapping, + builtInBackdropCapturesBackingTarget: false); + context.PublishMappedInputs( + description, + static (context, input, value) => context.TargetScope(input, value)); + return; + } + + TargetScopeDefinition<(Matrix Transform, TransformOperator Operator)> definition = + TargetScopeDefinition<(Matrix Transform, TransformOperator Operator)>.Create( + ExecuteTransform, + bounds, + hitTest, + scale, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: gridMapping); + context.PublishMappedInputs( + definition.Call((transform, transformOperator)), + static (context, input, value) => context.TargetScope(input, value)); + } + + private static void ExecuteTransform( + TargetScopeSession session, + (Matrix Transform, TransformOperator Operator) state) { - return context.Input.Select(r => - RenderNodeOperation.CreateLambda( - r.Bounds.TransformToAABB(Transform), - canvas => - { - using (canvas.PushTransform(Transform, TransformOperator)) - { - r.Render(canvas); - } - }, - hitTest: point => - { - if (Transform.HasInverse) - point *= Transform.Invert(); - return r.HitTest(point); - }, - onDispose: r.Dispose, - effectiveScale: RescaleDensity(r.EffectiveScale, Transform))) - .ToArray(); + session.Canvas.Use(canvas => + { + using (canvas.PushTransform(state.Transform, state.Operator)) + { + session.ReplayInput(); + } + }); } /// /// Re-scales a bitmap supply density across . Enlarging lowers density; - /// shrinking raises it. Vector (Unbounded) inputs pass through unchanged. + /// shrinking raises it. Vector (Unbounded) inputs pass through unchanged. An anisotropic transform is + /// reported through its least-scaled axis, so the result is the density of the best-preserved direction. /// public static EffectiveScale RescaleDensity(EffectiveScale input, Matrix transform) { @@ -72,4 +126,54 @@ public static EffectiveScale RescaleDensity(EffectiveScale input, Matrix transfo return EffectiveScale.At(d); } + + /// + /// Re-scales an output demand back across into the input demand that satisfies + /// it. Enlarging raises the demand; shrinking lowers it. An anisotropic transform is answered through its + /// operator norm, so the demand covers the most-stretched direction. A perspective transform has no single + /// scalar density, so its demand passes through unchanged. + /// + /// + /// This is the backward half of the density relationship maps forward, not its + /// inverse: each half errs toward more detail through a different axis, so under an anisotropic or sheared + /// transform a forward-then-backward round trip does not return its input. + /// + public static EffectiveScale RescaleDemand(EffectiveScale outputDemand, Matrix transform) + { + if (DeviceGridAlignment.IsPerspective(transform)) + return outputDemand; + + float factor = DeviceGridAlignment.ResolveAffineDensity(transform, 1f); + float density = outputDemand.Value * factor; + return float.IsFinite(density) && density > 0f + ? EffectiveScale.At(density) + : outputDemand; + } + + private readonly record struct TransformMetadataState( + Matrix Transform, + bool HasInverse, + Matrix Inverse, + Rect? DeliveredTo) + { + public Rect TransformBounds(Rect value) => value.TransformToDeliveredAABB(Transform, DeliveredTo); + + public Rect GetRequiredInputBounds(Rect value) => value.TransformToAABB(Inverse); + + public bool HitTest(RenderHitTestContext metadata, Point point) + { + if (HasInverse) + point *= Inverse; + return metadata.Inputs[0].HitTest(point); + } + } + + private readonly record struct TransformScaleMapper(Matrix Transform) + { + public EffectiveScale MapSupply(EffectiveScale inputSupply) + => RescaleDensity(inputSupply, Transform); + + public EffectiveScale MapDemand(EffectiveScale outputDemand) + => RescaleDemand(outputDemand, Transform); + } } diff --git a/src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs b/src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs index 782ae1a9f7..7c2ebc33ca 100644 --- a/src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs +++ b/src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs @@ -37,58 +37,75 @@ public bool Update(VideoSource.Resource source, int frame, Brush.Resource? fill, changed = true; } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - if (!Source.HasValue) return []; - - return - [ - RenderNodeOperation.CreateLambda( - bounds: Bounds, - render: canvas => - { - if (Source.Value.Resource.Read(Frame, out var bitmapRef)) - { - using (bitmapRef) - { - if (Source.Value.Resource.ProxyResolution == null) - { - canvas.DrawBitmap(bitmapRef.Value, Fill?.Resource, Pen?.Resource); - } - else - { - var dest = new Rect(default, Source.Value.Resource.LogicalFrameSize.ToSize(1)); - canvas.DrawBitmapScaled(bitmapRef.Value, dest, Fill?.Resource); - } - } - } - }, - hitTest: HitTest, - effectiveScale: EffectiveScale.At(Source.Value.Resource.SupplyDensity) - ) - ]; + if (Source is not { } sourceSnapshot) + return; + + Rect bounds = Bounds; + if (bounds.Width == 0 || bounds.Height == 0) + return; + + int frame = Frame; + (Brush.Resource Resource, int Version)? fillSnapshot = Fill; + (Pen.Resource Resource, int Version)? penSnapshot = Pen; + VideoSource.Resource source = sourceSnapshot.Resource; + Brush.Resource? fill = fillSnapshot?.Resource; + Pen.Resource? pen = penSnapshot?.Resource; + float supplyDensity = source.SupplyDensity; + RenderResource sourceResource = context.Borrow(source); + var hitTestState = new VideoHitTestState( + bounds, + fill is not null, + pen?.StrokeAlignment ?? StrokeAlignment.Inside, + pen?.Thickness ?? 0); + + context.Publish(context.PaintedSource( + state: (source, frame), + draw: static (canvas, fill, pen, state) => + canvas.DrawVideoSource(state.source, state.frame, fill, pen), + fill: fill, + pen: pen, + outputBounds: bounds, + hitTest: RenderHitTestContract.Custom(hitTestState.Evaluate), + scale: RenderScaleContract.Custom(new VideoScaleResolver(supplyDensity).Resolve), + resources: [sourceResource])); } - private bool HitTest(Point point) + private readonly record struct VideoHitTestState( + Rect Bounds, + bool HasFill, + StrokeAlignment StrokeAlignment, + float Thickness) { - StrokeAlignment alignment = Pen?.Resource.StrokeAlignment ?? StrokeAlignment.Inside; - float thickness = Pen?.Resource.Thickness ?? 0; - thickness = PenHelper.GetRealThickness(alignment, thickness); - - if (Fill != null) + public bool HitTest(Point point) { - Rect rect = Bounds.Inflate(thickness); - return rect.ContainsExclusive(point); - } - else - { - Rect borderRect = Bounds.Inflate(thickness); - Rect emptyRect = Bounds.Deflate(thickness); + float realThickness = PenHelper.GetRealThickness(StrokeAlignment, Thickness); + + if (HasFill) + { + Rect rect = Bounds.Inflate(realThickness); + return rect.ContainsExclusive(point); + } + + Rect borderRect = Bounds.Inflate(realThickness); + Rect emptyRect = Bounds.Deflate(realThickness); return borderRect.ContainsExclusive(point) && !emptyRect.ContainsExclusive(point); } + + public bool Evaluate(RenderHitTestContext _, Point point) => HitTest(point); + } + + private readonly record struct VideoScaleResolver(float SupplyDensity) + { + public float Resolve(RenderScaleContext _) => SupplyDensity; } } diff --git a/src/Beutl.Engine/Graphics/SourceVideo.Thumbnails.cs b/src/Beutl.Engine/Graphics/SourceVideo.Thumbnails.cs index 06bef15a1b..e056ad19da 100644 --- a/src/Beutl.Engine/Graphics/SourceVideo.Thumbnails.cs +++ b/src/Beutl.Engine/Graphics/SourceVideo.Thumbnails.cs @@ -125,6 +125,7 @@ protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs { Resource? resource = null; DrawableRenderNode? node = null; + RenderNodeRenderer? renderer = null; try { resource = ToResource(preferProxy @@ -168,7 +169,18 @@ protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs int effectiveEnd = endIndex < 0 ? count - 1 : Math.Min(endIndex, count - 1); node = new DrawableRenderNode(resource); - var processor = new RenderNodeProcessor(node, false); + renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = new Rect(0, 0, thumbWidth, maxHeight), + OutputScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); for (int i = effectiveStart; i <= effectiveEnd; i++) { @@ -205,7 +217,8 @@ protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs DrawInternal(gctx, resource); } - return processor.RasterizeAndConcat(); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + return rasterization.Bitmap?.Clone(); }, DispatchPriority.Medium, cancellationToken); if (thumbnail != null) @@ -219,11 +232,60 @@ protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs } finally { - RenderThread.Dispatcher.Dispatch(() => + await DisposeThumbnailRenderResourcesAsync(renderer, node, resource); + } + } + + internal static Task DisposeThumbnailRenderResourcesAsync(params IDisposable?[] resources) + => DisposeThumbnailRenderResourcesAsync(RenderThread.Dispatcher, resources); + + internal static async Task DisposeThumbnailRenderResourcesAsync( + Dispatcher dispatcher, + params IDisposable?[] resources) + { + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(resources); + if (dispatcher.HasShutdownStarted) + return; + + using var shutdownCancellation = new ShutdownCancellationRegistration(); + void OnShutdownStarted(object? _, EventArgs __) => shutdownCancellation.Cancel(); + dispatcher.ShutdownStarted += OnShutdownStarted; + try + { + if (dispatcher.HasShutdownStarted) + return; + + try + { + await dispatcher.InvokeAsync(() => + { + Exception? primary = null; + foreach (IDisposable? resource in resources) + { + try + { + resource?.Dispose(); + } + catch (Exception ex) + { + primary ??= ex; + } + } + + if (primary is not null) + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(primary).Throw(); + }, ct: shutdownCancellation.Token); + } + catch (OperationCanceledException) when (dispatcher.HasShutdownStarted) { - node?.Dispose(); - resource?.Dispose(); - }, ct: CancellationToken.None); + // Shutdown is terminal. A queued render-thread cleanup can no longer execute, so do + // not await it forever or dispose thread-affine resources from the caller thread. + } + } + finally + { + dispatcher.ShutdownStarted -= OnShutdownStarted; } } @@ -253,4 +315,75 @@ internal static int GetVideoThumbnailCount(PixelSize frameSize, int maxWidth, in return (int)raw; } + + private sealed class ShutdownCancellationRegistration : IDisposable + { + private readonly object _gate = new(); + private CancellationTokenSource? _source = new(); + private int _activeCancellations; + private bool _disposeRequested; + + public CancellationToken Token + { + get + { + lock (_gate) + { + return _source?.Token + ?? throw new ObjectDisposedException(nameof(ShutdownCancellationRegistration)); + } + } + } + + public void Cancel() + { + CancellationTokenSource? source; + lock (_gate) + { + source = _source; + if (source is null) + return; + _activeCancellations++; + } + + try + { + source.Cancel(); + } + finally + { + CancellationTokenSource? dispose = null; + lock (_gate) + { + _activeCancellations--; + if (_disposeRequested && _activeCancellations == 0) + { + dispose = _source; + _source = null; + } + } + + dispose?.Dispose(); + } + } + + public void Dispose() + { + CancellationTokenSource? dispose = null; + lock (_gate) + { + if (_disposeRequested) + return; + + _disposeRequested = true; + if (_activeCancellations == 0) + { + dispose = _source; + _source = null; + } + } + + dispose?.Dispose(); + } + } } diff --git a/src/Beutl.Engine/Graphics/Transformation/TransformHandleMath.cs b/src/Beutl.Engine/Graphics/Transformation/TransformHandleMath.cs index 1bb10b35ee..8dbce79216 100644 --- a/src/Beutl.Engine/Graphics/Transformation/TransformHandleMath.cs +++ b/src/Beutl.Engine/Graphics/Transformation/TransformHandleMath.cs @@ -62,9 +62,21 @@ public static (float dx, float dy) ComputePivotTranslationDelta( /// computes the correction to post-translate onto userMatrix. /// Returns the matrix unchanged when the center difference satisfies |dx|, |dy| < 0.5px (absorbs numerical noise). /// + /// + /// The reference box has to be clipped the same way the renderer clips it, or a perspective transform + /// makes the two centres describe different things: past the camera plane the mapped-corner box is + /// point-reflected through the origin, so its centre lands on the far side of the image and the + /// difference becomes a translation of thousands of pixels rather than an effect's offset. + /// public static Matrix AlignUserMatrixToRenderedBounds(Matrix userMatrix, Size localSize, Rect renderedBounds) { - Point transformCenter = new Rect(localSize).TransformToAABB(userMatrix).Center; + Rect transformBounds = new Rect(localSize).TransformToAABB(userMatrix); + if (transformBounds.IsEmpty) + { + return userMatrix; + } + + Point transformCenter = transformBounds.Center; Point renderedCenter = renderedBounds.Center; float dx = renderedCenter.X - transformCenter.X; float dy = renderedCenter.Y - transformCenter.Y; diff --git a/src/Beutl.Engine/Graphics3D/Materials/BasicMaterial.cs b/src/Beutl.Engine/Graphics3D/Materials/BasicMaterial.cs index b03ea04b47..d84374ea21 100644 --- a/src/Beutl.Engine/Graphics3D/Materials/BasicMaterial.cs +++ b/src/Beutl.Engine/Graphics3D/Materials/BasicMaterial.cs @@ -64,7 +64,13 @@ public partial class Resource // Default texture (1x1 pixel) private ITexture2D? _defaultWhiteTexture; - internal override IPipeline3D? Pipeline => _pipeline; + protected internal override IPipeline3D? Pipeline => _pipeline; + + protected internal override IEnumerable EnumerateTextureSources() + { + if (DiffuseMap is not null) + yield return DiffuseMap; + } public override void EnsurePipeline(RenderContext3D context) { diff --git a/src/Beutl.Engine/Graphics3D/Materials/Material3D.cs b/src/Beutl.Engine/Graphics3D/Materials/Material3D.cs index 3cb09394f3..07457a54be 100644 --- a/src/Beutl.Engine/Graphics3D/Materials/Material3D.cs +++ b/src/Beutl.Engine/Graphics3D/Materials/Material3D.cs @@ -1,6 +1,7 @@ using System.Numerics; using Beutl.Engine; using Beutl.Graphics.Backend; +using Beutl.Graphics3D.Textures; namespace Beutl.Graphics3D.Materials; @@ -24,7 +25,8 @@ public abstract partial class Resource /// /// Gets the pipeline for this material, or null if not yet created. /// - internal abstract IPipeline3D? Pipeline { get; } + /// Custom material resources override this member to expose their backend pipeline. + protected internal abstract IPipeline3D? Pipeline { get; } /// /// Gets whether this material is transparent and should use forward rendering. @@ -32,6 +34,16 @@ public abstract partial class Resource /// public virtual bool IsTransparent => false; + /// + /// Enumerates the texture resources that must be available while this material is rendered. + /// + /// The texture resources referenced by this material. + /// The default implementation declares no texture dependencies. + protected internal virtual IEnumerable EnumerateTextureSources() + { + return []; + } + /// /// Ensures the pipeline is created for this material. /// This method is called once before rendering. diff --git a/src/Beutl.Engine/Graphics3D/Materials/PBRMaterial.cs b/src/Beutl.Engine/Graphics3D/Materials/PBRMaterial.cs index 2fea69ee01..e06e08e64f 100644 --- a/src/Beutl.Engine/Graphics3D/Materials/PBRMaterial.cs +++ b/src/Beutl.Engine/Graphics3D/Materials/PBRMaterial.cs @@ -111,7 +111,21 @@ public partial class Resource private ITexture2D? _defaultNormalTexture; private ITexture2D? _defaultBlackTexture; - internal override IPipeline3D? Pipeline => _pipeline; + protected internal override IPipeline3D? Pipeline => _pipeline; + + protected internal override IEnumerable EnumerateTextureSources() + { + if (AlbedoMap is not null) + yield return AlbedoMap; + if (NormalMap is not null) + yield return NormalMap; + if (MetallicRoughnessMap is not null) + yield return MetallicRoughnessMap; + if (EmissiveMap is not null) + yield return EmissiveMap; + if (AOMap is not null) + yield return AOMap; + } public override void EnsurePipeline(RenderContext3D context) { diff --git a/src/Beutl.Engine/Graphics3D/Materials/TransparentMaterial.cs b/src/Beutl.Engine/Graphics3D/Materials/TransparentMaterial.cs index e590993d26..7c96c47de9 100644 --- a/src/Beutl.Engine/Graphics3D/Materials/TransparentMaterial.cs +++ b/src/Beutl.Engine/Graphics3D/Materials/TransparentMaterial.cs @@ -64,7 +64,13 @@ public partial class Resource private ISampler? _sampler; private ITexture2D? _defaultWhiteTexture; - internal override IPipeline3D? Pipeline => _pipeline; + protected internal override IPipeline3D? Pipeline => _pipeline; + + protected internal override IEnumerable EnumerateTextureSources() + { + if (ColorMap is not null) + yield return ColorMap; + } /// /// Gets whether this material is transparent and requires forward rendering. diff --git a/src/Beutl.Engine/Graphics3D/Meshes/CubeMesh.cs b/src/Beutl.Engine/Graphics3D/Meshes/CubeMesh.cs index 3e203478ae..b6e83a8cbf 100644 --- a/src/Beutl.Engine/Graphics3D/Meshes/CubeMesh.cs +++ b/src/Beutl.Engine/Graphics3D/Meshes/CubeMesh.cs @@ -37,11 +37,13 @@ public CubeMesh() [Range(0.001f, float.MaxValue), NumberStep(0.1, 0.01)] public IProperty Depth { get; } = Property.CreateAnimatable(1f); - /// - public override void ApplyTo(Mesh.Resource resource, out Vertex3D[] vertices, out uint[] indices) + public partial class Resource { - var r = (Resource)resource; - GenerateCube(r.Width, r.Height, r.Depth, out vertices, out indices); + /// + public override void ApplyTo(out Vertex3D[] vertices, out uint[] indices) + { + GenerateCube(Width, Height, Depth, out vertices, out indices); + } } /// diff --git a/src/Beutl.Engine/Graphics3D/Meshes/Mesh.cs b/src/Beutl.Engine/Graphics3D/Meshes/Mesh.cs index 5a0498288a..2282cd9b41 100644 --- a/src/Beutl.Engine/Graphics3D/Meshes/Mesh.cs +++ b/src/Beutl.Engine/Graphics3D/Meshes/Mesh.cs @@ -10,14 +10,6 @@ namespace Beutl.Graphics3D.Meshes; /// public abstract partial class Mesh : EngineObject { - /// - /// Applies the mesh geometry to the resource. - /// - /// The resource to apply to. - /// Output array of vertices. - /// Output array of indices. - public abstract void ApplyTo(Resource resource, out Vertex3D[] vertices, out uint[] indices); - public partial class Resource { private int? _capturedVersion; @@ -39,6 +31,27 @@ public partial class Resource /// internal bool BuffersDirty { get; set; } = true; + /// + /// The index count the buffers currently on the device were built from. + /// + /// + /// answers for the mesh as it is now, which is not what the device holds + /// until an upload has run for it. Drawing the live count against last upload's buffers reads past + /// their end whenever the topology grew. + /// + internal int UploadedIndexCount { get; set; } + + /// + /// Generates this mesh's geometry. + /// + /// Output array of vertices. + /// Output array of indices. + /// + /// An override reads every parameter it needs from this resource, so it must not reach for + /// . + /// + public abstract void ApplyTo(out Vertex3D[] vertices, out uint[] indices); + /// /// Gets the cached vertices, regenerating if needed. /// @@ -109,9 +122,11 @@ private void EnsureCached() if (_capturedVersion != Version || _cachedVertices == null) { + ApplyTo(out Vertex3D[] vertices, out uint[] indices); + _cachedVertices = vertices; + _cachedIndices = indices; _capturedVersion = Version; BuffersDirty = true; - GetOriginal().ApplyTo(this, out _cachedVertices!, out _cachedIndices!); } } @@ -121,6 +136,7 @@ partial void PostDispose(bool disposing) VertexBuffer = null; IndexBuffer?.Dispose(); IndexBuffer = null; + UploadedIndexCount = 0; _cachedVertices = null; _cachedIndices = null; } diff --git a/src/Beutl.Engine/Graphics3D/Meshes/PlaneMesh.cs b/src/Beutl.Engine/Graphics3D/Meshes/PlaneMesh.cs index 3877a7e06c..78b23ba619 100644 --- a/src/Beutl.Engine/Graphics3D/Meshes/PlaneMesh.cs +++ b/src/Beutl.Engine/Graphics3D/Meshes/PlaneMesh.cs @@ -44,11 +44,13 @@ public PlaneMesh() [Range(1, int.MaxValue), NumberStep(1, 1)] public IProperty HeightSegments { get; } = Property.CreateAnimatable(1); - /// - public override void ApplyTo(Mesh.Resource resource, out Vertex3D[] vertices, out uint[] indices) + public partial class Resource { - var r = (Resource)resource; - GeneratePlane(r.Width, r.Height, r.WidthSegments, r.HeightSegments, out vertices, out indices); + /// + public override void ApplyTo(out Vertex3D[] vertices, out uint[] indices) + { + GeneratePlane(Width, Height, WidthSegments, HeightSegments, out vertices, out indices); + } } /// diff --git a/src/Beutl.Engine/Graphics3D/Meshes/SphereMesh.cs b/src/Beutl.Engine/Graphics3D/Meshes/SphereMesh.cs index f82ee07ae3..b69ec83119 100644 --- a/src/Beutl.Engine/Graphics3D/Meshes/SphereMesh.cs +++ b/src/Beutl.Engine/Graphics3D/Meshes/SphereMesh.cs @@ -39,11 +39,13 @@ public SphereMesh() [Range(2, 128), NumberStep(1, 1)] public IProperty Rings { get; } = Property.CreateAnimatable(16); - /// - public override void ApplyTo(Mesh.Resource resource, out Vertex3D[] vertices, out uint[] indices) + public partial class Resource { - var r = (Resource)resource; - GenerateSphere(r.Radius, r.Segments, r.Rings, out vertices, out indices); + /// + public override void ApplyTo(out Vertex3D[] vertices, out uint[] indices) + { + GenerateSphere(Radius, Segments, Rings, out vertices, out indices); + } } /// diff --git a/src/Beutl.Engine/Graphics3D/Models/ModelMesh.cs b/src/Beutl.Engine/Graphics3D/Models/ModelMesh.cs index 8043a7e9bd..c06d48c7b4 100644 --- a/src/Beutl.Engine/Graphics3D/Models/ModelMesh.cs +++ b/src/Beutl.Engine/Graphics3D/Models/ModelMesh.cs @@ -23,11 +23,13 @@ public ModelMesh() [Display(Name = nameof(GraphicsStrings.ModelMesh_Indices), ResourceType = typeof(GraphicsStrings))] public IProperty> Indices { get; } = Property.Create>([]); - /// - public override void ApplyTo(Mesh.Resource resource, out Vertex3D[] vertices, out uint[] indices) + public partial class Resource { - var r = (Resource)resource; - vertices = [.. r.Vertices]; - indices = [.. r.Indices]; + /// + public override void ApplyTo(out Vertex3D[] vertices, out uint[] indices) + { + vertices = [.. Vertices]; + indices = [.. Indices]; + } } } diff --git a/src/Beutl.Engine/Graphics3D/Nodes/FlipPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/FlipPass.cs index ca66cd7d6a..2340529247 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/FlipPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/FlipPass.cs @@ -118,16 +118,15 @@ public void Execute() // Begin flip pass Span clearColors = [new Color(0, 0, 0, 255)]; - BeginPass(clearColors); - - // Bind pipeline and descriptor set - RenderPass.BindPipeline(_pipeline); - RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - - // Draw fullscreen triangle - RenderPass.Draw(3); + using (UsePass(clearColors)) + { + // Bind pipeline and descriptor set + RenderPass.BindPipeline(_pipeline); + RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - EndPass(); + // Draw fullscreen triangle + RenderPass.Draw(3); + } } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/GeometryPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/GeometryPass.cs index 5f958eee99..6ecabb75ed 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/GeometryPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/GeometryPass.cs @@ -132,15 +132,14 @@ public void Execute( ]; // Begin geometry pass - BeginPass(clearColors); - - // Render each object - foreach (var obj in objects) + using (UsePass(clearColors)) { - RenderObject(renderContext3D, obj, Matrix4x4.Identity); + // Render each object + foreach (var obj in objects) + { + RenderObject(renderContext3D, obj, Matrix4x4.Identity); + } } - - EndPass(); } private void RenderObject(RenderContext3D renderContext3D, Object3D.Resource obj, Matrix4x4 parentMatrix) @@ -191,7 +190,7 @@ private void RenderMesh(RenderContext3D renderContext3D, Object3D.Resource obj, RenderPass.BindIndexBuffer(meshResource.IndexBuffer); // Draw the mesh - RenderPass.DrawIndexed((uint)meshResource.IndexCount); + RenderPass.DrawIndexed((uint)meshResource.UploadedIndexCount); } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/GizmoPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/GizmoPass.cs index b50c0a387e..2b3cd2e9fc 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/GizmoPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/GizmoPass.cs @@ -245,20 +245,19 @@ public void Execute( // Begin pass (loadOp is set to Load in CreateGizmoResources, so content is preserved) Span clearColors = [Colors.Transparent]; - BeginPass(clearColors, 1.0f); - - // Bind pipeline and descriptor set - RenderPass.BindPipeline(_pipeline); - RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - - // Bind vertex and index buffers - RenderPass.BindVertexBuffer(vertexBuffer); - RenderPass.BindIndexBuffer(indexBuffer); + using (UsePass(clearColors, 1.0f)) + { + // Bind pipeline and descriptor set + RenderPass.BindPipeline(_pipeline); + RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - // Draw gizmo - RenderPass.DrawIndexed((uint)indexCount); + // Bind vertex and index buffers + RenderPass.BindVertexBuffer(vertexBuffer); + RenderPass.BindIndexBuffer(indexBuffer); - EndPass(); + // Draw gizmo + RenderPass.DrawIndexed((uint)indexCount); + } } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/GraphicsNode3D.cs b/src/Beutl.Engine/Graphics3D/Nodes/GraphicsNode3D.cs index f6fede1da1..17b811b0e0 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/GraphicsNode3D.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/GraphicsNode3D.cs @@ -43,6 +43,26 @@ protected void EndPass() RenderPass?.End(); } + /// + /// Begins the render pass and returns a scope that ends it on every path out of the body. + /// + /// + /// The pass records into the context-wide batch and holds a render-pass scope on it, so a body that + /// throws would otherwise leave the batch with an unterminated render pass and divert every later + /// transfer in the process to its own submission. + /// + protected PassScope UsePass(scoped Span clearColors, float clearDepth = 1.0f) + { + BeginPass(clearColors, clearDepth); + return new PassScope(this); + } + + /// Ends the render pass begun by . + protected readonly ref struct PassScope(GraphicsNode3D owner) + { + public void Dispose() => owner.EndPass(); + } + /// /// Prepares the framebuffer for sampling by other passes. /// diff --git a/src/Beutl.Engine/Graphics3D/Nodes/LightingPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/LightingPass.cs index 305f677eae..b728a39b21 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/LightingPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/LightingPass.cs @@ -246,16 +246,15 @@ public void Execute( // Begin lighting pass Span clearColors = [backgroundColor]; - BeginPass(clearColors, 1.0f); - - // Bind lighting pipeline and descriptor set - RenderPass.BindPipeline(_pipeline); - RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - - // Draw fullscreen triangle - RenderPass.Draw(3); + using (UsePass(clearColors, 1.0f)) + { + // Bind lighting pipeline and descriptor set + RenderPass.BindPipeline(_pipeline); + RenderPass.BindDescriptorSet(_pipeline, _descriptorSet); - EndPass(); + // Draw fullscreen triangle + RenderPass.Draw(3); + } } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/MeshBufferUploadHelper.cs b/src/Beutl.Engine/Graphics3D/Nodes/MeshBufferUploadHelper.cs index bacb701a24..9fbe1f5452 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/MeshBufferUploadHelper.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/MeshBufferUploadHelper.cs @@ -15,7 +15,17 @@ public static void Ensure(IGraphicsContext context, Mesh.Resource meshResource) var indices = meshResource.GetIndices(); if (vertices.Length == 0 || indices.Length == 0) + { + // Leaving the previous topology's buffers in place would let a later draw bind them for a mesh + // that no longer has them. + meshResource.VertexBuffer?.Dispose(); + meshResource.VertexBuffer = null; + meshResource.IndexBuffer?.Dispose(); + meshResource.IndexBuffer = null; + meshResource.UploadedIndexCount = 0; + meshResource.BuffersDirty = false; return; + } ulong vertexSize = (ulong)(vertices.Length * Marshal.SizeOf()); ulong indexSize = (ulong)(indices.Length * sizeof(uint)); @@ -51,6 +61,7 @@ public static void Ensure(IGraphicsContext context, Mesh.Resource meshResource) meshResource.VertexBuffer = vertexBuffer; meshResource.IndexBuffer = indexBuffer; + meshResource.UploadedIndexCount = indices.Length; meshResource.BuffersDirty = false; } } diff --git a/src/Beutl.Engine/Graphics3D/Nodes/PointShadowPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/PointShadowPass.cs index d12499ee4e..e863d5e27e 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/PointShadowPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/PointShadowPass.cs @@ -330,21 +330,25 @@ private void ExecuteFace(int faceIndex, IReadOnlyList objects // Begin pass with this face's framebuffer Span clearColors = [new Color(255, 255, 255, 255)]; RenderPass!.Begin(framebuffer, clearColors); + try + { + // Bind shadow pipeline + RenderPass.BindPipeline(_shadowPipeline!); - // Bind shadow pipeline - RenderPass.BindPipeline(_shadowPipeline!); - - // Bind descriptor set with light data - RenderPass.BindDescriptorSet(_shadowPipeline!, _descriptorSet!); + // Bind descriptor set with light data + RenderPass.BindDescriptorSet(_shadowPipeline!, _descriptorSet!); - // Render each object - foreach (var obj in objects) + // Render each object + foreach (var obj in objects) + { + RenderObject(obj, lightVP, Matrix4x4.Identity); + } + } + finally { - RenderObject(obj, lightVP, Matrix4x4.Identity); + RenderPass.End(); } - RenderPass.End(); - // Copy the rendered depth to the cube map face Context.CopyTextureToCubeFace(depthTexture, ShadowCubeTexture, faceIndex); } @@ -392,7 +396,7 @@ private void RenderMesh(Object3D.Resource obj, Matrix4x4 lightVP, Matrix4x4 worl RenderPass.BindIndexBuffer(meshResource.IndexBuffer); // Draw the mesh - RenderPass.DrawIndexed((uint)meshResource.IndexCount); + RenderPass.DrawIndexed((uint)meshResource.UploadedIndexCount); } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/ShadowPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/ShadowPass.cs index d599168e49..0196c03a06 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/ShadowPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/ShadowPass.cs @@ -231,20 +231,19 @@ public void Execute(IReadOnlyList objects) // Begin shadow pass with clear (clear to max depth) Span clearColors = [new Color(255, 255, 255, 255)]; // Dummy color - BeginPass(clearColors); - - // Bind shadow pipeline - RenderPass.BindPipeline(_shadowPipeline); + using (UsePass(clearColors)) + { + // Bind shadow pipeline + RenderPass.BindPipeline(_shadowPipeline); - var lightVP = LightViewProjection; + var lightVP = LightViewProjection; - // Render each object - foreach (var obj in objects) - { - RenderObject(obj, lightVP, Matrix4x4.Identity); + // Render each object + foreach (var obj in objects) + { + RenderObject(obj, lightVP, Matrix4x4.Identity); + } } - - EndPass(); } private void RenderObject(Object3D.Resource obj, Matrix4x4 lightVP, Matrix4x4 parentMatrix) @@ -290,7 +289,7 @@ private void RenderMesh(Object3D.Resource obj, Matrix4x4 lightVP, Matrix4x4 worl RenderPass.BindIndexBuffer(meshResource.IndexBuffer); // Draw the mesh - RenderPass.DrawIndexed((uint)meshResource.IndexCount); + RenderPass.DrawIndexed((uint)meshResource.UploadedIndexCount); } protected override void OnDispose() diff --git a/src/Beutl.Engine/Graphics3D/Nodes/TransparentPass.cs b/src/Beutl.Engine/Graphics3D/Nodes/TransparentPass.cs index 9514dbc372..f30b99a8a7 100644 --- a/src/Beutl.Engine/Graphics3D/Nodes/TransparentPass.cs +++ b/src/Beutl.Engine/Graphics3D/Nodes/TransparentPass.cs @@ -128,15 +128,14 @@ public void Execute( // Begin transparent pass with load (preserves copied content and depth buffer) Span clearColors = [Colors.Transparent]; - BeginPass(clearColors); - - // Render transparent objects (already sorted far to near) - foreach (var entry in transparentObjects) + using (UsePass(clearColors)) { - RenderTransparentObject(context3D, entry.Object, entry.WorldMatrix); + // Render transparent objects (already sorted far to near) + foreach (var entry in transparentObjects) + { + RenderTransparentObject(context3D, entry.Object, entry.WorldMatrix); + } } - - EndPass(); } private void RenderTransparentObject(RenderContext3D context, Object3D.Resource obj, Matrix4x4 worldMatrix) @@ -147,7 +146,7 @@ private void RenderTransparentObject(RenderContext3D context, Object3D.Resource return; // Ensure GPU buffers are created/updated - EnsureMeshBuffers(meshResource); + MeshBufferUploadHelper.Ensure(Context, meshResource); if (meshResource.VertexBuffer == null || meshResource.IndexBuffer == null) return; @@ -168,61 +167,10 @@ private void RenderTransparentObject(RenderContext3D context, Object3D.Resource RenderPass.BindIndexBuffer(meshResource.IndexBuffer); // Draw the mesh - RenderPass.DrawIndexed((uint)meshResource.IndexCount); + RenderPass.DrawIndexed((uint)meshResource.UploadedIndexCount); } - private void EnsureMeshBuffers(Mesh.Resource meshResource) - { - if (!meshResource.BuffersDirty) - return; - - var vertices = meshResource.GetVertices(); - var indices = meshResource.GetIndices(); - - if (vertices.Length == 0 || indices.Length == 0) - return; - ulong vertexSize = (ulong)(vertices.Length * System.Runtime.InteropServices.Marshal.SizeOf()); - ulong indexSize = (ulong)(indices.Length * sizeof(uint)); - - // Dispose old buffers if they exist - meshResource.VertexBuffer?.Dispose(); - meshResource.IndexBuffer?.Dispose(); - - // Create new device-local buffers - var vertexBuffer = Context.CreateBuffer( - vertexSize, - BufferUsage.VertexBuffer | BufferUsage.TransferDestination, - MemoryProperty.DeviceLocal); - - var indexBuffer = Context.CreateBuffer( - indexSize, - BufferUsage.IndexBuffer | BufferUsage.TransferDestination, - MemoryProperty.DeviceLocal); - - // Create staging buffers and upload - using var vertexStaging = Context.CreateBuffer( - vertexSize, - BufferUsage.TransferSource, - MemoryProperty.HostVisible | MemoryProperty.HostCoherent); - - using var indexStaging = Context.CreateBuffer( - indexSize, - BufferUsage.TransferSource, - MemoryProperty.HostVisible | MemoryProperty.HostCoherent); - - vertexStaging.Upload(vertices); - indexStaging.Upload(indices); - - // Copy from staging to device local - Context.CopyBuffer(vertexStaging, vertexBuffer, vertexSize); - Context.CopyBuffer(indexStaging, indexBuffer, indexSize); - - // Store in mesh resource - meshResource.VertexBuffer = vertexBuffer; - meshResource.IndexBuffer = indexBuffer; - meshResource.BuffersDirty = false; - } protected override void OnDispose() { diff --git a/src/Beutl.Engine/Graphics3D/Renderer3D.cs b/src/Beutl.Engine/Graphics3D/Renderer3D.cs index 1e2370d7cf..0053eb642a 100644 --- a/src/Beutl.Engine/Graphics3D/Renderer3D.cs +++ b/src/Beutl.Engine/Graphics3D/Renderer3D.cs @@ -360,6 +360,7 @@ private void CopyToOutputTexture() public SKSurface? CreateSkiaSurface() { + _outputTexture?.PrepareForSkiaSampling(requireCompletion: false); return _outputTexture?.CreateSkiaSurface(); } diff --git a/src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs b/src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs index 8424b980f8..2974e1e147 100644 --- a/src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs +++ b/src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs @@ -1,11 +1,18 @@ -using Beutl.Composition; +using System.Runtime.ExceptionServices; +using Beutl.Composition; using Beutl.Engine; using Beutl.Graphics; using Beutl.Graphics.Backend; using Beutl.Graphics.Rendering; +using Beutl.Graphics3D.Camera; +using Beutl.Graphics3D.Gizmo; using Beutl.Graphics3D.Lighting; +using Beutl.Graphics3D.Materials; +using Beutl.Graphics3D.Textures; using Beutl.Logging; +using Beutl.Media; using Microsoft.Extensions.Logging; +using SkiaSharp; namespace Beutl.Graphics3D; @@ -16,7 +23,7 @@ internal sealed class Scene3DRenderNode(Scene3D.Resource scene) : RenderNode { private static readonly ILogger s_logger = Log.CreateLogger(); - public Rect Bounds { get; private set; } + public Rect Bounds { get; private set; } = new(0, 0, scene.RenderWidth, scene.RenderHeight); public (Scene3D.Resource Resource, int Version)? Scene { get; private set; } = scene.Capture(); @@ -31,106 +38,129 @@ public bool Update(Scene3D.Resource scene) Bounds = new Rect(0, 0, scene.RenderWidth, scene.RenderHeight); } - HasChanges = changed; + if (changed) + { + HasChanges = true; + } + return changed; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - var scene = Scene?.Resource; - if (scene == null) - return []; - - var graphicsContext = GraphicsContextFactory.SharedContext; - if (graphicsContext == null || !graphicsContext.Supports3DRendering) - return []; - - // Camera is already a Resource from the source generator - var cameraResource = scene.Camera; - if (cameraResource == null) - return []; - - int width = (int)scene.RenderWidth; - int height = (int)scene.RenderHeight; + if (Scene is not { } sceneSnapshot) + return; - if (width <= 0 || height <= 0) - return []; + Scene3D.Resource scene = sceneSnapshot.Resource; + Camera3D.Resource? camera = scene.Camera; + float width = scene.RenderWidth; + float height = scene.RenderHeight; + if (camera is null + || !float.IsFinite(width) + || !float.IsFinite(height) + || width <= 0 + || height <= 0) + { + return; + } - // Render the 3D scene at the resolved output density. The 3D projection matrix is adjusted to - // compensate so that logical coordinates remain unchanged despite the dense surface. - float resolved = RenderNodeContext.ResolveWorkingScale([], context.OutputScale, context.MaxWorkingScale); - float w = RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, width, height), resolved); - int dw = w == 1f ? width : (int)MathF.Ceiling(width * w); - int dh = w == 1f ? height : (int)MathF.Ceiling(height * w); + Rect bounds = new(0, 0, width, height); + float workingScale = RenderScaleContract.MaterializeAtWorkingScale.Resolve( + [], + bounds, + context.OutputScale, + context.MaxWorkingScale).Value; + Object3D.Resource[] objects = scene.Objects.Where(static item => item.IsEnabled).ToArray(); + Light3D.Resource[] lights = scene.Lights.Where(static item => item.IsEnabled).ToArray(); + Object3D.Resource? gizmoTarget = scene.GizmoTarget is { } targetId + ? FindObjectById(objects, targetId) + : null; + SceneTextureBinding[] textureBindings = RecordDrawableTextures( + context, + objects, + workingScale); + var execution = new SceneExecutionSnapshot( + scene, + camera, + objects, + lights, + bounds, + scene.Time, + scene.DisableResourceShare, + scene.BackgroundColor, + scene.AmbientColor, + scene.AmbientIntensity, + gizmoTarget, + scene.GizmoMode, + textureBindings); + RenderResource sceneToken = context.Borrow(execution); - var renderer = scene.Renderer ??= new Renderer3D(graphicsContext); + RenderResource[] resources = + [ + sceneToken, + .. textureBindings.Select(static item => item.Binding), + ]; + OpaqueRenderDescription description = OpaqueRenderDescription.CreateBackendBoundary( + RenderBackendBoundary.Graphics3D, + execute: session => session.UseResource( + sceneToken, + current => Render(session, current)), + bounds: OpaqueRenderBoundsContract.Source(bounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.ZeroOrOne, + scale: RenderScaleContract.MaterializeAtWorkingScale, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + resources: resources); + context.Publish(context.OpaqueSource(description)); + } - // Catch allocation failures (e.g. vkCreateImage past GPU limit) and drop the 3D op. - if (renderer.Width != dw || renderer.Height != dh) + private static SceneTextureBinding[] RecordDrawableTextures( + RenderNodeContext context, + IEnumerable objects, + float outputScale) + { + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var result = new List(); + foreach (Object3D.Resource obj in EnumerateObjects(objects)) { - try - { - if (renderer.Width == 0 || renderer.Height == 0) - { - renderer.Initialize(dw, dh); - } + Material3D.Resource? material = obj.Material; + if (material is null) + continue; - renderer.Resize(dw, dh); - } - catch (Exception ex) + foreach (DrawableTextureSource.Resource source in material + .EnumerateTextureSources() + .OfType()) { - s_logger.LogWarning(ex, - "3D render surface allocation failed ({Width}x{Height} px, density {Scale}); dropping the 3D op for this frame.", - dw, dh, w); - // Failed resize may leave the renderer inconsistent; discard so next frame rebuilds. - scene.Renderer?.Dispose(); - scene.Renderer = null; - return []; + if (!seen.Add(source)) + continue; + float textureDensity = source.ResolveDensity(outputScale); + DrawableRenderNode? root = source.RecordDrawable(textureDensity); + if (root is null) + continue; + + RecordedNestedRenderTarget nested = context.RecordNestedTargetAtScale( + root, + source.TextureDomain, + textureDensity); + result.Add(new SceneTextureBinding(source, nested.Binding)); } } - renderer.SurfaceDensity = w; - - var objectResources = new List(); - var lightResources = new List(); - objectResources.AddRange(scene.Objects.Where(obj => obj.IsEnabled)); - lightResources.AddRange(scene.Lights.Where(light => light.IsEnabled)); + return [.. result]; + } - // Find gizmo target object - Object3D.Resource? gizmoTarget = null; - if (scene.GizmoTarget.HasValue) + private static IEnumerable EnumerateObjects( + IEnumerable objects) + { + foreach (Object3D.Resource obj in objects) { - gizmoTarget = FindObjectById(objectResources, scene.GizmoTarget.Value); - } - - // Render - renderer.Render( - new CompositionContext(scene.Time) - { - DisableResourceShare = scene.DisableResourceShare, - }, - cameraResource, - objectResources, - lightResources, - scene.BackgroundColor, - scene.AmbientColor, - scene.AmbientIntensity, - gizmoTarget, - scene.GizmoMode); - - // Get the rendered surface - var surface = renderer.CreateSkiaSurface(); - if (surface == null) - return []; - - // Tag the concrete bitmap surface at its rendered density At(w). - var operation = RenderNodeOperation.CreateFromSurface( - Bounds, - new Point(0, 0), - surface, - EffectiveScale.At(w)); + if (!obj.IsEnabled) + continue; - return [operation]; + yield return obj; + foreach (Object3D.Resource child in EnumerateObjects(obj.GetChildResources())) + yield return child; + } } private static Object3D.Resource? FindObjectById(IEnumerable objects, Guid targetId) @@ -155,4 +185,139 @@ protected override void OnDispose(bool disposing) base.OnDispose(disposing); Scene = null; } + + private sealed record SceneExecutionSnapshot( + Scene3D.Resource Scene, + Camera3D.Resource Camera, + Object3D.Resource[] Objects, + Light3D.Resource[] Lights, + Rect Bounds, + TimeSpan Time, + bool DisableResourceShare, + Color BackgroundColor, + Color AmbientColor, + float AmbientIntensity, + Object3D.Resource? GizmoTarget, + GizmoMode GizmoMode, + SceneTextureBinding[] TextureBindings); + + private sealed record SceneTextureBinding( + DrawableTextureSource.Resource Source, + RenderResource Binding); + + private static void Render(OpaqueRenderSession session, SceneExecutionSnapshot snapshot) + { + UseTextureBindings(session, snapshot, index: 0, () => RenderCore(session, snapshot)); + } + + private static void UseTextureBindings( + OpaqueRenderSession session, + SceneExecutionSnapshot snapshot, + int index, + Action render) + { + if (index == snapshot.TextureBindings.Length) + { + render(); + return; + } + + SceneTextureBinding current = snapshot.TextureBindings[index]; + session.UseResource( + current.Binding, + binding => NestedRenderTargetBindingScope.Use( + current.Source, + binding, + () => UseTextureBindings(session, snapshot, index + 1, render))); + } + + private static void RenderCore(OpaqueRenderSession session, SceneExecutionSnapshot snapshot) + { + IGraphicsContext? graphicsContext = GraphicsContextFactory.SharedContext; + if (graphicsContext is null || !graphicsContext.Supports3DRendering) + return; + + float density = session.WorkingScale; + int deviceWidth = (int)MathF.Ceiling((float)snapshot.Bounds.Width * density); + int deviceHeight = (int)MathF.Ceiling((float)snapshot.Bounds.Height * density); + Renderer3D renderer = snapshot.Scene.Renderer ??= new Renderer3D(graphicsContext); + + if (renderer.Width != deviceWidth || renderer.Height != deviceHeight) + { + try + { + if (renderer.Width == 0 || renderer.Height == 0) + renderer.Initialize(deviceWidth, deviceHeight); + else + renderer.Resize(deviceWidth, deviceHeight); + } + catch (Exception ex) + { + if (session.Intent == RenderIntent.Delivery) + { + s_logger.LogError( + ex, + "3D render surface allocation failed ({Width}x{Height} px, density {Scale}); delivery cannot omit the 3D value.", + deviceWidth, + deviceHeight, + density); + } + else + { + s_logger.LogWarning( + ex, + "3D render surface allocation failed ({Width}x{Height} px, density {Scale}); dropping the 3D value for this preview frame.", + deviceWidth, + deviceHeight, + density); + } + + snapshot.Scene.Renderer?.Dispose(); + snapshot.Scene.Renderer = null; + ThrowIfDeliveryAllocationFailure(session.Intent, ex); + return; + } + } + + renderer.SurfaceDensity = density; + renderer.Render( + new CompositionContext(snapshot.Time) + { + DisableResourceShare = snapshot.DisableResourceShare, + }, + snapshot.Camera, + snapshot.Objects, + snapshot.Lights, + snapshot.BackgroundColor, + snapshot.AmbientColor, + snapshot.AmbientIntensity, + snapshot.GizmoTarget, + snapshot.GizmoMode); + + using SKSurface? surface = renderer.CreateSkiaSurface(); + if (surface is null) + return; + + using OpaqueRenderOutput output = session.CreateOutput(snapshot.Bounds); + output.Canvas.Use(canvas => + { + // This is the one deliberate backend hand-off: both surfaces have the same device + // footprint, so copy in device space without exposing a raw target to public callbacks. + using (canvas.PushDeviceSpace()) + { + canvas.Canvas.DrawSurface(surface, 0, 0); + } + + surface.Flush(true, true); + }); + session.Publish(output); + } + + internal static void ThrowIfDeliveryAllocationFailure(RenderIntent intent, Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + if (intent == RenderIntent.Delivery) + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } diff --git a/src/Beutl.Engine/Graphics3D/ShadowManager.cs b/src/Beutl.Engine/Graphics3D/ShadowManager.cs index 02d0423df7..9379babb26 100644 --- a/src/Beutl.Engine/Graphics3D/ShadowManager.cs +++ b/src/Beutl.Engine/Graphics3D/ShadowManager.cs @@ -309,10 +309,10 @@ public void PrepareForSampling() _shadowPasses2D[i].PrepareForSampling(); } - // Note: CopyTextureToArrayLayer and CopyTextureToCubeArrayFace already transition - // each layer/face to ShaderReadOnlyOptimal, so we don't need to call - // TransitionAllToSampled here. Doing so would actually be harmful because it would - // transition from Undefined (stale internal state) and potentially discard the data. + // CopyTextureToArrayLayer and CopyTextureToCubeArrayFace already leave each slot they wrote in + // ShaderReadOnlyOptimal, and a slot no light filled starts there, so nothing here has to sweep the + // whole array. Sweeping would be harmless - each transition reads the slot's tracked layout and + // skips a slot already in the target - but it would also be pointless work every frame. // Transition the cube array to sampled state if (_activeShadowCountCube > 0) diff --git a/src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs b/src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs index 5bcb663f44..34f54ae274 100644 --- a/src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs +++ b/src/Beutl.Engine/Graphics3D/Textures/DrawableTextureSource.cs @@ -36,20 +36,54 @@ public partial class Resource private int _lastHeight; private float _lastDensity = -1f; + internal Rect TextureDomain + => new(0, 0, TextureWidth, TextureHeight); + + internal float ResolveDensity(float density) + { + float sanitizedDensity = float.IsFinite(density) && density > 0f ? density : 1f; + return RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + TextureDomain, + sanitizedDensity); + } + + internal DrawableRenderNode? RecordDrawable(float density) + { + if (Drawable is null || TextureWidth <= 0 || TextureHeight <= 0) + return null; + + float sanitizedDensity = ResolveDensity(density); + _drawableNode ??= new DrawableRenderNode(Drawable); + _drawableNode.Update(Drawable); + using var context = new GraphicsContext2D( + _drawableNode, + new Size(TextureWidth, TextureHeight), + sanitizedDensity); + Drawable.GetOriginal()!.Render(context, Drawable); + return _drawableNode; + } + public override ITexture2D? GetTexture(IGraphicsContext graphicsContext, float surfaceDensity = 1f) { - if (Drawable == null) + ArgumentNullException.ThrowIfNull(graphicsContext); + if (Drawable is null) { DisposeRenderTarget(); return null; } + if (NestedRenderTargetBindingScope.TryGet(this, out NestedRenderTargetBinding nestedBinding)) + return nestedBinding.GetTexture(TextureDomain, ResolveDensity(surfaceDensity)); + if (RenderExecutionCallbackGuard.IsActive) + { + throw new InvalidOperationException( + "A drawable texture used by a deferred render callback has no prepared nested target."); + } + // Rasterize at surfaceDensity so vector content stays crisp. int textureWidth = TextureWidth; int textureHeight = TextureHeight; - float density = float.IsFinite(surfaceDensity) && surfaceDensity > 0f ? surfaceDensity : 1f; - density = RenderNodeContext.ClampWorkingScaleToBufferBudget( - new Rect(0, 0, textureWidth, textureHeight), density); + float density = ResolveDensity(surfaceDensity); int deviceWidth = Math.Max(1, (int)Math.Ceiling(textureWidth * (double)density)); int deviceHeight = Math.Max(1, (int)Math.Ceiling(textureHeight * (double)density)); @@ -69,23 +103,30 @@ public partial class Resource if (_renderTargetVersion != Version || _lastDensity != density) { _lastDensity = density; - _drawableNode ??= new DrawableRenderNode(Drawable); - _drawableNode.Update(Drawable); - using (var context = new GraphicsContext2D( - _drawableNode, new Size(textureWidth, textureHeight), density)) - { - Drawable.GetOriginal().Render(context, Drawable); - } - - var processor = new RenderNodeProcessor(_drawableNode, true, density, density); + DrawableRenderNode drawableNode = RecordDrawable(density) + ?? throw new InvalidOperationException("The drawable texture source became empty while rendering."); + + using var renderer = new RenderNodeRenderer( + drawableNode, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = new Rect(0, 0, textureWidth, textureHeight), + OutputScale = density, + MaxWorkingScale = density, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + }, + }); using (var canvas = new ImmediateCanvas(_renderTarget, density, density)) { canvas.Clear(); - processor.Render(canvas); + renderer.Render(canvas); } // Prepare for sampling (flush the surface) - _renderTarget.PrepareForSampling(); + _renderTarget.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); _renderTargetVersion = Version; } @@ -101,6 +142,8 @@ private void DisposeRenderTarget() partial void PostDispose(bool disposing) { DisposeRenderTarget(); + _drawableNode?.Dispose(); + _drawableNode = null; } } } diff --git a/src/Beutl.Engine/Media/Geometry/EllipseGeometry.cs b/src/Beutl.Engine/Media/Geometry/EllipseGeometry.cs index cc9fb0d690..85b94e3d07 100644 --- a/src/Beutl.Engine/Media/Geometry/EllipseGeometry.cs +++ b/src/Beutl.Engine/Media/Geometry/EllipseGeometry.cs @@ -19,25 +19,27 @@ public EllipseGeometry() [Display(Name = nameof(GraphicsStrings.Height), ResourceType = typeof(GraphicsStrings))] public IProperty Height { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) + public partial class Resource { - base.ApplyTo(context, resource); - var r = (Resource)resource; - float width = r.Width; - float height = r.Height; - if (float.IsInfinity(width)) - width = 0; - - if (float.IsInfinity(height)) - height = 0; - - float radiusX = width / 2; - float radiusY = height / 2; - var radius = new Size(radiusX, radiusY); - - context.MoveTo(new Point(radiusX, 0)); - context.ArcTo(radius, 0, true, false, new Point(radiusX, height)); - context.ArcTo(radius, 0, true, false, new Point(radiusX, 0)); - context.Close(); + public override void ApplyTo(IGeometryContext context) + { + base.ApplyTo(context); + float width = Width; + float height = Height; + if (float.IsInfinity(width)) + width = 0; + + if (float.IsInfinity(height)) + height = 0; + + float radiusX = width / 2; + float radiusY = height / 2; + var radius = new Size(radiusX, radiusY); + + context.MoveTo(new Point(radiusX, 0)); + context.ArcTo(radius, 0, true, false, new Point(radiusX, height)); + context.ArcTo(radius, 0, true, false, new Point(radiusX, 0)); + context.Close(); + } } } diff --git a/src/Beutl.Engine/Media/Geometry/Geometry.cs b/src/Beutl.Engine/Media/Geometry/Geometry.cs index 93b86c0c0c..d889180177 100644 --- a/src/Beutl.Engine/Media/Geometry/Geometry.cs +++ b/src/Beutl.Engine/Media/Geometry/Geometry.cs @@ -24,36 +24,53 @@ public Geometry() [Display(Name = nameof(GraphicsStrings.Transform), ResourceType = typeof(GraphicsStrings))] public IProperty Transform { get; } = Property.Create(null); - public virtual void ApplyTo(IGeometryContext context, Resource resource) - { - } - public partial class Resource { private int? _capturedVersion; private GeometryContext? _cachedPath; - private (Pen.Resource Resource, int Version)? _cachedPen; + private (Guid Identity, int Version)? _cachedPen; private SKPath? _cachedStrokePath; public Rect Bounds => GetCachedPath().TightBounds.ToGraphicsRect(); + /// + /// Appends this geometry's outline to . + /// + /// + /// An override reads every parameter it needs from this resource, so it must not reach for + /// . + /// + public virtual void ApplyTo(IGeometryContext context) + { + } + internal SKPath GetCachedPath() { ObjectDisposedException.ThrowIf(IsDisposed, this); if (_capturedVersion != Version || _cachedPath == null) { - _capturedVersion = Version; + // A throwing ApplyTo must not leave a half-built path behind the version guard, so the + // fields are replaced only once the new path is complete. + var built = new GeometryContext { FillType = FillType }; + try + { + ApplyTo(built); + if (Transform != null) + { + built.Transform(Transform.Matrix); + } + } + catch + { + built.Dispose(); + throw; + } + _cachedStrokePath?.Dispose(); _cachedStrokePath = null; _cachedPath?.Dispose(); - var geometry = GetOriginal(); - - _cachedPath = new GeometryContext { FillType = FillType }; - geometry.ApplyTo(_cachedPath, this); - if (Transform != null) - { - _cachedPath.Transform(Transform.Matrix); - } + _cachedPath = built; + _capturedVersion = Version; } return _cachedPath.NativeObject; @@ -62,15 +79,17 @@ internal SKPath GetCachedPath() internal SKPath GetCachedStrokePath(Pen.Resource pen) { ObjectDisposedException.ThrowIf(IsDisposed, this); + // GetOriginal() is null for every detached pen, so keying on it makes any two of them compare equal. + Guid penIdentity = EngineResourceIdentity.Of(pen); if (_capturedVersion != Version || _cachedPath == null || _cachedStrokePath == null || _cachedPen == null - || _cachedPen?.Resource.GetOriginal() != pen.GetOriginal() + || _cachedPen?.Identity != penIdentity || _cachedPen?.Version != pen.Version) { _cachedStrokePath?.Dispose(); - _cachedPen = (pen, pen.Version); + _cachedPen = (penIdentity, pen.Version); _cachedStrokePath = PenHelper.CreateStrokePath(GetCachedPath(), pen, Bounds); } diff --git a/src/Beutl.Engine/Media/Geometry/Operations/ArcSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/ArcSegment.cs index bf1c812df0..ef7d1275a0 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/ArcSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/ArcSegment.cs @@ -28,12 +28,6 @@ public ArcSegment() [Display(Name = nameof(GraphicsStrings.ArcSegment_Point), ResourceType = typeof(GraphicsStrings))] public IProperty Point { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, PathSegment.Resource resource) - { - var r = (Resource)resource; - context.ArcTo(r.Radius, r.RotationAngle, r.IsLargeArc, r.SweepClockwise, r.Point); - } - public override IProperty GetEndPoint() { return Point; @@ -41,6 +35,11 @@ public override IProperty GetEndPoint() public partial class Resource { + public override void ApplyTo(IGeometryContext context) + { + context.ArcTo(Radius, RotationAngle, IsLargeArc, SweepClockwise, Point); + } + public override Point? GetEndPoint() { return Point; diff --git a/src/Beutl.Engine/Media/Geometry/Operations/ConicSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/ConicSegment.cs index a4c8e45fd1..2501126456 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/ConicSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/ConicSegment.cs @@ -29,12 +29,6 @@ public ConicSegment(Point controlPoint, Point endPoint, float weight) : this() [Display(Name = nameof(GraphicsStrings.ConicSegment_Weight), ResourceType = typeof(GraphicsStrings))] public IProperty Weight { get; } = Property.CreateAnimatable(1); - public override void ApplyTo(IGeometryContext context, PathSegment.Resource resource) - { - var r = (Resource)resource; - context.ConicTo(r.ControlPoint, r.EndPoint, r.Weight); - } - public override IProperty GetEndPoint() { return EndPoint; @@ -42,6 +36,11 @@ public override IProperty GetEndPoint() public partial class Resource { + public override void ApplyTo(IGeometryContext context) + { + context.ConicTo(ControlPoint, EndPoint, Weight); + } + public override Point? GetEndPoint() { return EndPoint; diff --git a/src/Beutl.Engine/Media/Geometry/Operations/CubicBezierSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/CubicBezierSegment.cs index ebcfc58193..7ace31cb4d 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/CubicBezierSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/CubicBezierSegment.cs @@ -29,12 +29,6 @@ public CubicBezierSegment(Point controlPoint1, Point controlPoint2, Point endPoi [Display(Name = nameof(GraphicsStrings.EndPoint), ResourceType = typeof(GraphicsStrings))] public IProperty EndPoint { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, PathSegment.Resource resource) - { - var r = (Resource)resource; - context.CubicTo(r.ControlPoint1, r.ControlPoint2, r.EndPoint); - } - public override IProperty GetEndPoint() { return EndPoint; @@ -42,6 +36,11 @@ public override IProperty GetEndPoint() public partial class Resource { + public override void ApplyTo(IGeometryContext context) + { + context.CubicTo(ControlPoint1, ControlPoint2, EndPoint); + } + public override Point? GetEndPoint() { return EndPoint; diff --git a/src/Beutl.Engine/Media/Geometry/Operations/LineSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/LineSegment.cs index 73d9589a6e..18bb82122d 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/LineSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/LineSegment.cs @@ -26,12 +26,6 @@ public LineSegment(float x, float y) [Display(Name = nameof(GraphicsStrings.LineSegment_Point), ResourceType = typeof(GraphicsStrings))] public IProperty Point { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, PathSegment.Resource resource) - { - var r = (Resource)resource; - context.LineTo(r.Point); - } - public override IProperty GetEndPoint() { return Point; @@ -39,6 +33,11 @@ public override IProperty GetEndPoint() public partial class Resource { + public override void ApplyTo(IGeometryContext context) + { + context.LineTo(Point); + } + public override Point? GetEndPoint() { return Point; diff --git a/src/Beutl.Engine/Media/Geometry/Operations/PathSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/PathSegment.cs index ef120df381..9861938a9f 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/PathSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/PathSegment.cs @@ -9,12 +9,19 @@ public sealed partial class FallbackPathSegment : PathSegment, IFallback; [FallbackType(typeof(FallbackPathSegment))] public abstract partial class PathSegment : EngineObject { - public abstract void ApplyTo(IGeometryContext context, Resource resource); - public abstract IProperty GetEndPoint(); public partial class Resource { + /// + /// Appends this segment to . + /// + /// + /// An override reads every parameter it needs from this resource, so it must not reach for + /// . + /// + public abstract void ApplyTo(IGeometryContext context); + public virtual Point? GetEndPoint() { return null; diff --git a/src/Beutl.Engine/Media/Geometry/Operations/QuadraticBezierSegment.cs b/src/Beutl.Engine/Media/Geometry/Operations/QuadraticBezierSegment.cs index ab17ac2385..30008c5464 100644 --- a/src/Beutl.Engine/Media/Geometry/Operations/QuadraticBezierSegment.cs +++ b/src/Beutl.Engine/Media/Geometry/Operations/QuadraticBezierSegment.cs @@ -25,12 +25,6 @@ public QuadraticBezierSegment(Point controlPoint, Point endPoint) : this() [Display(Name = nameof(GraphicsStrings.EndPoint), ResourceType = typeof(GraphicsStrings))] public IProperty EndPoint { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, PathSegment.Resource resource) - { - var r = (Resource)resource; - context.QuadraticTo(r.ControlPoint, r.EndPoint); - } - public override IProperty GetEndPoint() { return EndPoint; @@ -38,6 +32,11 @@ public override IProperty GetEndPoint() public partial class Resource { + public override void ApplyTo(IGeometryContext context) + { + context.QuadraticTo(ControlPoint, EndPoint); + } + public override Point? GetEndPoint() { return EndPoint; diff --git a/src/Beutl.Engine/Media/Geometry/PathFigure.cs b/src/Beutl.Engine/Media/Geometry/PathFigure.cs index a32585c220..c9e1885e2f 100644 --- a/src/Beutl.Engine/Media/Geometry/PathFigure.cs +++ b/src/Beutl.Engine/Media/Geometry/PathFigure.cs @@ -23,46 +23,49 @@ public PathFigure() public IListProperty Segments { get; } = Property.CreateList(); - public void ApplyTo(IGeometryContext context, Resource resource) + public partial class Resource { - bool skipFirst = false; - if (!resource.StartPoint.IsInvalid) + public void ApplyTo(IGeometryContext context) { - context.MoveTo(resource.StartPoint); - } - else if (resource.Segments.Count > 0) - { - if (resource.IsClosed) + bool skipFirst = false; + if (!StartPoint.IsInvalid) { - var endPoint = resource.Segments[^1].GetEndPoint(); - if (endPoint.HasValue) - { - context.MoveTo(endPoint.Value); - } + context.MoveTo(StartPoint); } - else + else if (Segments.Count > 0) { - var endPoint = resource.Segments[0].GetEndPoint(); - if (endPoint.HasValue) + if (IsClosed) + { + var endPoint = Segments[^1].GetEndPoint(); + if (endPoint.HasValue) + { + context.MoveTo(endPoint.Value); + } + } + else { - context.MoveTo(endPoint.Value); - skipFirst = true; + var endPoint = Segments[0].GetEndPoint(); + if (endPoint.HasValue) + { + context.MoveTo(endPoint.Value); + skipFirst = true; + } } } - } - foreach (PathSegment.Resource item in resource.Segments) - { - if (skipFirst) + foreach (PathSegment.Resource item in Segments) { - skipFirst = false; - continue; + if (skipFirst) + { + skipFirst = false; + continue; + } + + item.ApplyTo(context); } - item.GetOriginal().ApplyTo(context, item); + if (IsClosed) + context.Close(); } - - if (resource.IsClosed) - context.Close(); } } diff --git a/src/Beutl.Engine/Media/Geometry/PathGeometry.cs b/src/Beutl.Engine/Media/Geometry/PathGeometry.cs index bf2f8dfc64..a26614b703 100644 --- a/src/Beutl.Engine/Media/Geometry/PathGeometry.cs +++ b/src/Beutl.Engine/Media/Geometry/PathGeometry.cs @@ -150,50 +150,51 @@ public void Close() } } - public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) + public partial class Resource { - base.ApplyTo(context, resource); - var r = (Resource)resource; - - foreach (PathFigure.Resource item in r.Figures) + public override void ApplyTo(IGeometryContext context) { - item.GetOriginal().ApplyTo(context, item); - } - } + base.ApplyTo(context); - public PathFigure.Resource? HitTestFigure(Point point, Pen.Resource? pen, Geometry.Resource resource) - { - var r = (Resource)resource; - Rect bounds = resource.Bounds; + foreach (PathFigure.Resource item in Figures) + { + item.ApplyTo(context); + } + } - foreach (PathFigure.Resource item in r.Figures) + public PathFigure.Resource? HitTestFigure(Point point, Pen.Resource? pen) { - using (var context = new GeometryContext()) - { - context.FillType = r.FillType; - item.GetOriginal().ApplyTo(context, item); - if (r.Transform != null) - { - context.Transform(r.Transform.Matrix); - } + Rect bounds = Bounds; - if (context.NativeObject.Contains(point.X, point.Y)) + foreach (PathFigure.Resource item in Figures) + { + using (var context = new GeometryContext()) { - return item; - } + context.FillType = FillType; + item.ApplyTo(context); + if (Transform != null) + { + context.Transform(Transform.Matrix); + } - if (pen != null) - { - using SKPath strokePath = PenHelper.CreateStrokePath(context.NativeObject, pen, bounds); - if (strokePath.Contains(point.X, point.Y)) + if (context.NativeObject.Contains(point.X, point.Y)) { return item; } - } + if (pen != null) + { + using SKPath strokePath = PenHelper.CreateStrokePath(context.NativeObject, pen, bounds); + if (strokePath.Contains(point.X, point.Y)) + { + return item; + } + } + + } } - } - return null; + return null; + } } } diff --git a/src/Beutl.Engine/Media/Geometry/RectGeometry.cs b/src/Beutl.Engine/Media/Geometry/RectGeometry.cs index 665d3fda88..3b88196deb 100644 --- a/src/Beutl.Engine/Media/Geometry/RectGeometry.cs +++ b/src/Beutl.Engine/Media/Geometry/RectGeometry.cs @@ -19,23 +19,25 @@ public RectGeometry() [Display(Name = nameof(GraphicsStrings.Height), ResourceType = typeof(GraphicsStrings))] public IProperty Height { get; } = Property.CreateAnimatable(); - public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) + public partial class Resource { - base.ApplyTo(context, resource); - var r = (Resource)resource; - float width = r.Width; - float height = r.Height; - if (float.IsInfinity(width)) - width = 0; + public override void ApplyTo(IGeometryContext context) + { + base.ApplyTo(context); + float width = Width; + float height = Height; + if (float.IsInfinity(width)) + width = 0; - if (float.IsInfinity(height)) - height = 0; + if (float.IsInfinity(height)) + height = 0; - context.MoveTo(new Point(0, 0)); - context.LineTo(new Point(width, 0)); - context.LineTo(new Point(width, height)); - context.LineTo(new Point(0, height)); - context.LineTo(new Point(0, 0)); - context.Close(); + context.MoveTo(new Point(0, 0)); + context.LineTo(new Point(width, 0)); + context.LineTo(new Point(width, height)); + context.LineTo(new Point(0, height)); + context.LineTo(new Point(0, 0)); + context.Close(); + } } } diff --git a/src/Beutl.Engine/Media/Geometry/RoundedRectGeometry.cs b/src/Beutl.Engine/Media/Geometry/RoundedRectGeometry.cs index 9cf46e977a..420e7961bd 100644 --- a/src/Beutl.Engine/Media/Geometry/RoundedRectGeometry.cs +++ b/src/Beutl.Engine/Media/Geometry/RoundedRectGeometry.cs @@ -63,7 +63,7 @@ private static void GetPathParams( a = 2 * b; } - private void ApplyTopRightCorner(float width, float height, + private static void ApplyTopRightCorner(float width, float height, float cornerRadius, float smoothing, IGeometryContext context) { if (cornerRadius != 0) @@ -96,7 +96,7 @@ private void ApplyTopRightCorner(float width, float height, } } - private void ApplyBottomRightCorner(float width, float height, + private static void ApplyBottomRightCorner(float width, float height, float cornerRadius, float smoothing, IGeometryContext context) { if (cornerRadius != 0) @@ -128,7 +128,7 @@ private void ApplyBottomRightCorner(float width, float height, } } - private void ApplyBottomLeftCorner(float width, float height, + private static void ApplyBottomLeftCorner(float width, float height, float cornerRadius, float smoothing, IGeometryContext context) { if (cornerRadius != 0) @@ -160,7 +160,7 @@ private void ApplyBottomLeftCorner(float width, float height, } } - private void ApplyTopLeftCorner(float width, float height, + private static void ApplyTopLeftCorner(float width, float height, float cornerRadius, float smoothing, IGeometryContext context) { if (cornerRadius != 0) @@ -193,34 +193,36 @@ private void ApplyTopLeftCorner(float width, float height, context.Close(); } - public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) + public partial class Resource { - base.ApplyTo(context, resource); - var r = (Resource)resource; - float width = r.Width; - float height = r.Height; - if (float.IsInfinity(width)) - width = 0; - - if (float.IsInfinity(height)) - height = 0; - - (float radiusX, float radiusY) = (width / 2, height / 2); - float maxRadius = Math.Max(radiusX, radiusY); - CornerRadius cornerRadius = r.CornerRadius; - float topLeft = Math.Clamp(cornerRadius.TopLeft, 0, maxRadius); - float topRight = Math.Clamp(cornerRadius.TopRight, 0, maxRadius); - float bottomRight = Math.Clamp(cornerRadius.BottomRight, 0, maxRadius); - float bottomLeft = Math.Clamp(cornerRadius.BottomLeft, 0, maxRadius); - float smoothing = r.Smoothing / 100; - - ApplyTopRightCorner( - width, height, topRight, smoothing, context); - ApplyBottomRightCorner( - width, height, bottomRight, smoothing, context); - ApplyBottomLeftCorner( - width, height, bottomLeft, smoothing, context); - ApplyTopLeftCorner( - width, height, topLeft, smoothing, context); + public override void ApplyTo(IGeometryContext context) + { + base.ApplyTo(context); + float width = Width; + float height = Height; + if (float.IsInfinity(width)) + width = 0; + + if (float.IsInfinity(height)) + height = 0; + + (float radiusX, float radiusY) = (width / 2, height / 2); + float maxRadius = Math.Max(radiusX, radiusY); + CornerRadius cornerRadius = CornerRadius; + float topLeft = Math.Clamp(cornerRadius.TopLeft, 0, maxRadius); + float topRight = Math.Clamp(cornerRadius.TopRight, 0, maxRadius); + float bottomRight = Math.Clamp(cornerRadius.BottomRight, 0, maxRadius); + float bottomLeft = Math.Clamp(cornerRadius.BottomLeft, 0, maxRadius); + float smoothing = Smoothing / 100; + + ApplyTopRightCorner( + width, height, topRight, smoothing, context); + ApplyBottomRightCorner( + width, height, bottomRight, smoothing, context); + ApplyBottomLeftCorner( + width, height, bottomLeft, smoothing, context); + ApplyTopLeftCorner( + width, height, topLeft, smoothing, context); + } } } diff --git a/src/Beutl.Engine/Media/Geometry/SKPathGeometry.cs b/src/Beutl.Engine/Media/Geometry/SKPathGeometry.cs index 70a2adbadf..887c383e04 100644 --- a/src/Beutl.Engine/Media/Geometry/SKPathGeometry.cs +++ b/src/Beutl.Engine/Media/Geometry/SKPathGeometry.cs @@ -4,87 +4,80 @@ namespace Beutl.Media; -internal sealed partial class SKPathGeometry : Geometry, IDisposable +internal sealed partial class SKPathGeometry : Geometry { - private SKPath? _path; - - public SKPathGeometry(SKPath path, bool clone) - { - SetSKPath(path, clone); - } - - public SKPathGeometry() + public partial class Resource { - } + private SKPath? _path; - // Test hook: exposes the owned glyph path so deterministic disposal can be asserted. - internal SKPath? Path => _path; + // Test hook: exposes the owned glyph path so deterministic disposal can be asserted. + internal SKPath? Path => _path; - // The geometry owns _path in both clone modes (clone: true copies and owns; clone: false takes - // ownership of the handed-off path), so it is always responsible for releasing it. - public void SetSKPath(SKPath? path, bool clone) - { - SKPath? newPath = path != null && clone ? new SKPath(path) : path; - if (!ReferenceEquals(_path, newPath)) + // The resource owns _path in both clone modes (clone: true copies and owns; clone: false takes + // ownership of the handed-off path), so it is always responsible for releasing it. + public void SetSKPath(SKPath? path, bool clone) { - _path?.Dispose(); - } - - _path = newPath; - RaiseEdited(); - } - - public void Dispose() - { - _path?.Dispose(); - _path = null; - } - - public override void ApplyTo(IGeometryContext context, Geometry.Resource resource) - { - base.ApplyTo(context, resource); - var r = (Resource)resource; - if (_path == null) return; + SKPath? newPath = path != null && clone ? new SKPath(path) : path; + if (!ReferenceEquals(_path, newPath)) + { + _path?.Dispose(); + } - if (context is GeometryContext typed) - { - typed.NativeObject.AddPath(_path); + _path = newPath; + InvalidateCachedPaths(); } - else + + public override void ApplyTo(IGeometryContext context) { - using SKPath.RawIterator it = _path.CreateRawIterator(); - Span points = stackalloc SKPoint[4]; - SKPathVerb pathVerb; + base.ApplyTo(context); + if (_path == null) return; - do + if (context is GeometryContext typed) { - pathVerb = it.Next(points); - switch (pathVerb) + typed.NativeObject.AddPath(_path); + } + else + { + using SKPath.RawIterator it = _path.CreateRawIterator(); + Span points = stackalloc SKPoint[4]; + SKPathVerb pathVerb; + + do { - case SKPathVerb.Move: - context.MoveTo(points[0].ToGraphicsPoint()); - break; - case SKPathVerb.Line: - context.LineTo(points[1].ToGraphicsPoint()); - break; - case SKPathVerb.Quad: - context.QuadraticTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint()); - break; - case SKPathVerb.Conic: - context.ConicTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint(), it.ConicWeight()); - break; - case SKPathVerb.Cubic: - context.CubicTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint(), points[3].ToGraphicsPoint()); - break; - case SKPathVerb.Close: - context.Close(); - break; - case SKPathVerb.Done: - default: - break; - } - } while (pathVerb != SKPathVerb.Done); + pathVerb = it.Next(points); + switch (pathVerb) + { + case SKPathVerb.Move: + context.MoveTo(points[0].ToGraphicsPoint()); + break; + case SKPathVerb.Line: + context.LineTo(points[1].ToGraphicsPoint()); + break; + case SKPathVerb.Quad: + context.QuadraticTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint()); + break; + case SKPathVerb.Conic: + context.ConicTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint(), it.ConicWeight()); + break; + case SKPathVerb.Cubic: + context.CubicTo(points[1].ToGraphicsPoint(), points[2].ToGraphicsPoint(), points[3].ToGraphicsPoint()); + break; + case SKPathVerb.Close: + context.Close(); + break; + case SKPathVerb.Done: + default: + break; + } + } while (pathVerb != SKPathVerb.Done); + + } + } + partial void PostDispose(bool disposing) + { + _path?.Dispose(); + _path = null; } } } diff --git a/src/Beutl.Engine/Media/PixelPoint.cs b/src/Beutl.Engine/Media/PixelPoint.cs index b67ee4158f..144245c574 100644 --- a/src/Beutl.Engine/Media/PixelPoint.cs +++ b/src/Beutl.Engine/Media/PixelPoint.cs @@ -219,21 +219,30 @@ public Point ToPoint(Vector scale) } /// - /// Converts a to device pixels. + /// Converts a to device pixels, truncating each co-ordinate toward zero. /// /// The point. - /// The device-independent point. + /// The device pixel point. + /// + /// Truncation is not a floor: negative co-ordinates round up. Use + /// when a conservative device-pixel cover is required. + /// public static PixelPoint FromPoint(Point point) { return new PixelPoint((int)point.X, (int)point.Y); } /// - /// Converts a to device pixels using the specified scaling factor. + /// Converts a to device pixels using the specified scaling factor, truncating each + /// co-ordinate toward zero. /// /// The point. /// The scaling factor. - /// The device-independent point. + /// The device pixel point. + /// + /// Truncation is not a floor: negative co-ordinates round up. Use + /// when a conservative device-pixel cover is required. + /// public static PixelPoint FromPoint(Point point, float scale) { return new PixelPoint( @@ -242,11 +251,16 @@ public static PixelPoint FromPoint(Point point, float scale) } /// - /// Converts a to device pixels using the specified scaling factor. + /// Converts a to device pixels using the specified scaling factor, truncating each + /// co-ordinate toward zero. /// /// The point. /// The scaling factor. - /// The device-independent point. + /// The device pixel point. + /// + /// Truncation is not a floor: negative co-ordinates round up. Use + /// when a conservative device-pixel cover is required. + /// public static PixelPoint FromPoint(Point point, Vector scale) { return new PixelPoint( diff --git a/src/Beutl.Engine/Media/PixelRect.cs b/src/Beutl.Engine/Media/PixelRect.cs index 0f4bd2b996..74a17d998f 100644 --- a/src/Beutl.Engine/Media/PixelRect.cs +++ b/src/Beutl.Engine/Media/PixelRect.cs @@ -371,41 +371,48 @@ public Rect ToRect(Vector scale) } /// - /// Converts a to device pixels. + /// Converts a to the smallest device-pixel rectangle that covers it. /// /// The rect. - /// The device-independent rect. + /// The covering device-pixel rect. public static PixelRect FromRect(Rect rect) { - return new PixelRect( - PixelPoint.FromPoint(rect.Position), - FromPointCeiling(rect.BottomRight)); + return FromRect(rect, new Vector(1, 1)); } /// - /// Converts a to device pixels using the specified scaling factor. + /// Converts a to the smallest device-pixel rectangle that covers it, using the + /// specified scaling factor. /// /// The rect. /// The scaling factor. - /// The device-independent rect. + /// The covering device-pixel rect. public static PixelRect FromRect(Rect rect, float scale) { - return new PixelRect( - PixelPoint.FromPoint(rect.Position, scale), - FromPointCeiling(rect.BottomRight, new Vector(scale, scale))); + return FromRect(rect, new Vector(scale, scale)); } /// - /// Converts a to device pixels using the specified scaling factor. + /// Converts a to the smallest device-pixel rectangle that covers it, using the + /// specified scaling factor. /// /// The rect. /// The scaling factor. - /// The device-independent point. + /// The covering device-pixel rect. public static PixelRect FromRect(Rect rect, Vector scale) { - return new PixelRect( - PixelPoint.FromPoint(rect.Position, scale), - FromPointCeiling(rect.BottomRight, scale)); + double leftEdge = (double)rect.X * scale.X; + double topEdge = (double)rect.Y * scale.Y; + double rightEdge = ((double)rect.X + rect.Width) * scale.X; + double bottomEdge = ((double)rect.Y + rect.Height) * scale.Y; + double deviceWidth = (double)rect.Width * scale.X; + double deviceHeight = (double)rect.Height * scale.Y; + + int left = (int)Math.Floor(leftEdge); + int top = (int)Math.Floor(topEdge); + int right = CoverEnd(left, (int)Math.Ceiling(rightEdge), deviceWidth); + int bottom = CoverEnd(top, (int)Math.Ceiling(bottomEdge), deviceHeight); + return new PixelRect(new PixelPoint(left, top), new PixelPoint(right, bottom)); } /// @@ -495,18 +502,11 @@ static bool ISpanParsable.TryParse([NotNullWhen(true)] ReadOnlySpan 0 ? start + 1 : end; } static void ITupleConvertible.ConvertTo(PixelRect self, Span tuple) diff --git a/src/Beutl.Engine/Media/TextFormatting/FormattedText.cs b/src/Beutl.Engine/Media/TextFormatting/FormattedText.cs index 01815ec56e..a168598009 100644 --- a/src/Beutl.Engine/Media/TextFormatting/FormattedText.cs +++ b/src/Beutl.Engine/Media/TextFormatting/FormattedText.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Buffers; +using System.Diagnostics; using System.Runtime.InteropServices; using Beutl.Composition; using Beutl.Graphics; @@ -21,6 +22,7 @@ public class FormattedText : IEquatable, IDisposable private FontMetrics _metrics = default; private Rect _bounds = default; private Rect _actualBounds; + private Rect _rasterBounds; private bool _isDirty = false; private Pen.Resource? _pen; private SKTextBlob? _textBlob; @@ -53,7 +55,7 @@ public void Dispose() (_textBlob, _fillPath, _strokePath).DisposeAll(); foreach (SKPathGeometry.Resource? resource in _pathList) { - DisposePathListEntry(resource); + resource?.Dispose(); } _pathList = []; @@ -63,14 +65,6 @@ public void Dispose() IsDisposed = true; } - // Dispose the geometry too: it owns the per-glyph SKPath (set via SetSKPath(..., clone: false)), - // which the resource's cached render path does not cover. - private static void DisposePathListEntry(SKPathGeometry.Resource? resource) - { - resource?.GetOriginal().Dispose(); - resource?.Dispose(); - } - public FontWeight Weight { get => _weight; @@ -157,6 +151,44 @@ public Rect ActualBounds } } + /// + /// Bounds of the glyph masks this text rasterizes, which contain . + /// + /// + /// Full hinting moves a mask off its unhinted outline, so a renderer must allocate this rather than + /// or it clips what it draws. Only the allocated footprint may use it: + /// brush mapping and layout stay on the semantic bounds. + /// + public Rect RasterBounds + { + get + { + MeasureAndSetField(); + return _rasterBounds; + } + } + + /// + /// widened so that its device footprint at still + /// clears the glyph masks by a whole device pixel. + /// + /// + /// measures the masks hinted for scale 1, and hinting at another scale + /// moves them by more than rescaling that rectangle accounts for, so the footprint has to come from + /// a measurement at the scale that will actually be drawn. The result never narrows + /// , so a footprint can only gain room by asking for a scale. + /// + public Rect GetRasterBounds(float scale) + { + MeasureAndSetField(); + scale = NormalizeDensity(scale); + if (scale == 1f) + return _rasterBounds; + + Rect scaled = _scaledCache.Get(scale).RasterBounds; + return scaled.IsEmpty ? _rasterBounds : _rasterBounds.Union(scaled); + } + // テスト用 internal Point AddToSKPath(SKPath path, Point point) { @@ -184,9 +216,6 @@ internal Point AddToSKPath(SKPath path, Point point) positions[i] = p; } - // build - using SKTextBlob? textBlob = builder.Build(); - for (int i = 0; i < glyphs.Length; i++) { ushort glyph = glyphs[i]; @@ -197,6 +226,7 @@ internal Point AddToSKPath(SKPath path, Point point) path.AddPath(glyphPath, p.X, p.Y); } + using SKTextBlob? textBlob = builder.Build(); return point; } @@ -267,17 +297,17 @@ internal SKFont ToSKFont(float density = 1f) private void Measure() { - (SKTextBlob? textBlob, SKPath fillPath, SKPath? strokePath, FontMetrics metrics, Rect bounds, Rect actualBounds) + (SKTextBlob? textBlob, SKPath fillPath, SKPath? strokePath, FontMetrics metrics, Rect bounds, Rect actualBounds, Rect rasterBounds) = MeasureCore(1f, updatePathList: true); - (_metrics, _bounds, _actualBounds) = (metrics, bounds, actualBounds); + (_metrics, _bounds, _actualBounds, _rasterBounds) = (metrics, bounds, actualBounds, rasterBounds); (_textBlob, _fillPath, _strokePath).DisposeAll(); (_textBlob, _fillPath, _strokePath) = (textBlob, fillPath, strokePath); _scaledCache.Clear(); } - private (SKTextBlob? TextBlob, SKPath FillPath, SKPath? StrokePath, FontMetrics Metrics, Rect Bounds, Rect ActualBounds) + private (SKTextBlob? TextBlob, SKPath FillPath, SKPath? StrokePath, FontMetrics Metrics, Rect Bounds, Rect ActualBounds, Rect RasterBounds) MeasureCore(float density, bool updatePathList) { density = NormalizeDensity(density); @@ -307,7 +337,7 @@ private void Measure() int glyphCount = result.Codepoints.Length; for (int i = glyphCount; i < _pathList.Count; i++) { - DisposePathListEntry(_pathList[i]); + _pathList[i]?.Dispose(); } CollectionsMarshal.SetCount(_pathList, glyphCount); @@ -332,18 +362,8 @@ private void Measure() tmp.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); ref SKPathGeometry.Resource? exist = ref pathList[i]!; - if (exist is null) - { - var geom = new SKPathGeometry(); - geom.SetSKPath(tmp, false); - exist = geom.ToResource(CompositionContext.Default); - } - else - { - // SetSKPath reuses the slot without bumping Version, so invalidate the caches explicitly. - exist.GetOriginal().SetSKPath(tmp, false); - exist.InvalidateCachedPaths(); - } + exist ??= new SKPathGeometry().ToResource(CompositionContext.Default); + exist.SetSKPath(tmp, false); } else { @@ -353,25 +373,17 @@ private void Measure() else if (updatePathList) { ref SKPathGeometry.Resource? exist = ref pathList[i]!; - if (exist is null) - { - var geom = new SKPathGeometry(); - geom.SetSKPath(tmp, false); - exist = geom.ToResource(CompositionContext.Default); - } - else - { - // Empty glyph: invalidate the caches so the reused slot stops serving the old path. - exist.GetOriginal().SetSKPath(tmp, false); - exist.InvalidateCachedPaths(); - } + exist ??= new SKPathGeometry().ToResource(CompositionContext.Default); + exist.SetSKPath(tmp, false); } } SKPath? strokePath = null; // 空白で開始または、終了した場合 - var bounds = new Rect(0, 0, Math.Max(0, glyphs.Length - 1) * spacing + result.Width, fillPath.TightBounds.Height); + float width = MathF.Max(0, (Math.Max(0, glyphs.Length - 1) * spacing) + result.Width); + var bounds = new Rect(0, 0, width, fillPath.TightBounds.Height); Rect actualBounds = fillPath.TightBounds.ToGraphicsRect(); + Rect rasterBounds = MeasureGlyphMaskBounds(font, glyphs, positions); SKTextBlob? textBlob = builder.Build(); if (result.Codepoints.Length > 0) @@ -383,7 +395,52 @@ private void Measure() } } - return (textBlob, fillPath, strokePath, font.Metrics.ToFontMetrics(), bounds, actualBounds); + if (strokePath is not null) + rasterBounds = rasterBounds.Union(InflateToRaster(strokePath.TightBounds).ToGraphicsRect()); + rasterBounds = rasterBounds.IsEmpty ? actualBounds : rasterBounds.Union(actualBounds); + + return (textBlob, fillPath, strokePath, font.Metrics.ToFontMetrics(), bounds, actualBounds, rasterBounds); + } + + private static Rect MeasureGlyphMaskBounds(SKFont font, ReadOnlySpan glyphs, ReadOnlySpan positions) + { + if (glyphs.Length == 0) + return default; + + float[] widths = ArrayPool.Shared.Rent(glyphs.Length); + SKRect[] glyphBounds = ArrayPool.Shared.Rent(glyphs.Length); + try + { + font.GetGlyphWidths(glyphs, widths.AsSpan(0, glyphs.Length), glyphBounds.AsSpan(0, glyphs.Length), null); + + var union = SKRect.Empty; + bool any = false; + for (int i = 0; i < glyphs.Length; i++) + { + SKRect glyph = glyphBounds[i]; + if (glyph.IsEmpty) + continue; + + glyph.Offset(positions[i]); + union = any ? SKRect.Union(union, glyph) : glyph; + any = true; + } + + return any ? InflateToRaster(union).ToGraphicsRect() : default; + } + finally + { + ArrayPool.Shared.Return(widths); + ArrayPool.Shared.Return(glyphBounds); + } + } + + // A glyph strike is measured at subpixel phase zero but drawn at the phase its position falls in, + // and antialiasing samples the pixel an edge touches, so coverage reaches one pixel past the mask. + private static SKRect InflateToRaster(SKRect bounds) + { + bounds.Inflate(1f, 1f); + return bounds; } private void SetProperty(ref T field, T value) @@ -405,12 +462,19 @@ private void MeasureAndSetField() } } - private (SKTextBlob? TextBlob, SKPath? StrokePath) MeasureScaledText(float density) + private (SKTextBlob? TextBlob, SKPath? StrokePath, Rect RasterBounds) MeasureScaledText(float density) { - (SKTextBlob? textBlob, SKPath fillPath, SKPath? strokePath, _, _, _) = + (SKTextBlob? textBlob, SKPath fillPath, SKPath? strokePath, _, _, _, Rect rasterBounds) = MeasureCore(density, updatePathList: false); fillPath.Dispose(); - return (textBlob, strokePath); + return ( + textBlob, + strokePath, + new Rect( + rasterBounds.X / density, + rasterBounds.Y / density, + rasterBounds.Width / density, + rasterBounds.Height / density)); } private static float NormalizeDensity(float density) diff --git a/src/Beutl.Engine/Media/TextFormatting/ScaledTextCache.cs b/src/Beutl.Engine/Media/TextFormatting/ScaledTextCache.cs index b387bf4f95..677e5f0932 100644 --- a/src/Beutl.Engine/Media/TextFormatting/ScaledTextCache.cs +++ b/src/Beutl.Engine/Media/TextFormatting/ScaledTextCache.cs @@ -1,4 +1,6 @@ -using SkiaSharp; +using Beutl.Graphics; + +using SkiaSharp; namespace Beutl.Media.TextFormatting; @@ -10,14 +12,14 @@ internal sealed class ScaledTextCache : IDisposable private const int MaxEntries = 8; private readonly Dictionary _cache = []; private readonly LinkedList _lru = new(); - private readonly Func _factory; + private readonly Func _factory; // Test seam (Beutl.UnitTests): fires after the density-scaled blob/stroke are produced and the // LRU node is added, but before the entry is committed to _cache, so the leak-cleanup and // LRU-rollback path can be driven deterministically. Null in production. internal Action? CommitFaultHook; - public ScaledTextCache(Func factory) + public ScaledTextCache(Func factory) { _factory = factory; } @@ -28,16 +30,16 @@ public ScaledTextCache(Func f // Returns borrowed handles owned by the cache; the caller must not dispose them or hold them // past a subsequent Get that may evict the entry. - public (SKTextBlob? TextBlob, SKPath? StrokePath) Get(float density) + public (SKTextBlob? TextBlob, SKPath? StrokePath, Rect RasterBounds) Get(float density) { if (_cache.TryGetValue(density, out Entry? cache)) { _lru.Remove(cache.LruNode); _lru.AddFirst(cache.LruNode); - return (cache.TextBlob, cache.StrokePath); + return (cache.TextBlob, cache.StrokePath, cache.RasterBounds); } - (SKTextBlob? textBlob, SKPath? strokePath) = _factory(density); + (SKTextBlob? textBlob, SKPath? strokePath, Rect rasterBounds) = _factory(density); // Nothing owns textBlob/strokePath until _cache.Add succeeds, so a throw from eviction // disposal or either cache mutation in between would leak the native handles. Dispose them @@ -56,9 +58,9 @@ public ScaledTextCache(Func f node = _lru.AddFirst(density); CommitFaultHook?.Invoke(textBlob, strokePath); - cache = new Entry(textBlob, strokePath, node); + cache = new Entry(textBlob, strokePath, rasterBounds, node); _cache.Add(density, cache); - return (textBlob, strokePath); + return (textBlob, strokePath, rasterBounds); } catch { @@ -106,10 +108,15 @@ private static void DisposeBestEffort(IDisposable? disposable) private sealed class Entry : IDisposable { - public Entry(SKTextBlob? textBlob, SKPath? strokePath, LinkedListNode lruNode) + public Entry( + SKTextBlob? textBlob, + SKPath? strokePath, + Rect rasterBounds, + LinkedListNode lruNode) { TextBlob = textBlob; StrokePath = strokePath; + RasterBounds = rasterBounds; LruNode = lruNode; } @@ -117,6 +124,8 @@ public Entry(SKTextBlob? textBlob, SKPath? strokePath, LinkedListNode lru public SKPath? StrokePath { get; } + public Rect RasterBounds { get; } + public LinkedListNode LruNode { get; } public void Dispose() diff --git a/src/Beutl.NodeGraph/Composition/GraphSnapshot.cs b/src/Beutl.NodeGraph/Composition/GraphSnapshot.cs index 447070d817..b657a7ae13 100644 --- a/src/Beutl.NodeGraph/Composition/GraphSnapshot.cs +++ b/src/Beutl.NodeGraph/Composition/GraphSnapshot.cs @@ -172,6 +172,7 @@ private Dictionary BuildResourcesAndContexts(List sor DisableResourceShare = context.DisableResourceShare, PreferProxy = context.PreferProxy, PreferredProxyPreset = context.PreferredProxyPreset, + TargetDomain = context.TargetDomain, }; } @@ -249,7 +250,7 @@ private void BuildInputConnectionMap(List connectionList) // 各 ListInputPort について、Connections の順序で登録 for (int resourceIdx = 0; resourceIdx < _resources.Length; resourceIdx++) { - var node = _resources[resourceIdx].GetOriginal(); + GraphNode node = _resources[resourceIdx].GetOriginal()!; for (int itemIdx = 0; itemIdx < node.Items.Count; itemIdx++) { var item = node.Items[itemIdx]; @@ -294,6 +295,7 @@ public void Evaluate(CompositionTarget target, CompositionContext context) ctx.DisableResourceShare = context.DisableResourceShare; ctx.PreferProxy = context.PreferProxy; ctx.PreferredProxyPreset = context.PreferredProxyPreset; + ctx.TargetDomain = context.TargetDomain; // アニメーション/プロパティ値をロード LoadAnimatedValues(ctx.Resource, ctx.Time); @@ -362,7 +364,7 @@ internal void CollectListInputValues(int slotIndex, int itemIndex, IList private void LoadAnimatedValues(GraphNode.Resource resource, TimeSpan time) { - var node = resource.GetOriginal(); + GraphNode node = resource.GetOriginal()!; for (int i = 0; i < node.Items.Count; i++) { INodeMember item = node.Items[i]; @@ -390,7 +392,7 @@ private void LoadAnimatedValues(GraphNode.Resource resource, TimeSpan time) private void PropagateOutputs(GraphNode.Resource resource) { - var node = resource.GetOriginal(); + GraphNode node = resource.GetOriginal()!; for (int itemIdx = 0; itemIdx < node.Items.Count; itemIdx++) { if (!_outputConnectionMap.TryGetValue((resource.SlotIndex, itemIdx), out var connIndices)) diff --git a/src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs b/src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs index d5bc5cd5d5..1ea8b0c9c1 100644 --- a/src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs +++ b/src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs @@ -1,91 +1,116 @@ using Beutl.Composition; +using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.Source; using Beutl.NodeGraph.Composition; using Beutl.NodeGraph.Nodes; namespace Beutl.NodeGraph; +// RenderNode.ChildNodes is deliberately left empty here. The graph output nodes this records through are read +// back out of the snapshot by PullOutputValue and only exist once Evaluate has run for this frame's time and +// composition flags; the next Snapshot.Build disposes them, so an array retained to back a span would hand the +// traversals disposed nodes. Revalidation and cache recursion therefore stop at this node: the graph subtree +// keeps its marks and is never render-cached. That is sound only while this node itself stays out of the cache, +// which NodeGraphFilterEffect.Resource.Update guarantees by bumping Version on every build. internal class NodeGraphFilterEffectRenderNode(NodeGraphFilterEffect.Resource resource) : FilterEffectRenderNode(resource) { + private static readonly IEqualityComparer s_renderNodeReferenceComparer = + ReferenceEqualityComparer.Instance; private readonly CompositionContext _compositionContext = new(TimeSpan.Zero); private NodeGraphFilterEffect.Resource? GraphResource => FilterEffect?.Resource as NodeGraphFilterEffect.Resource; - public override RenderNodeOperation[] Process(RenderNodeContext context) + public override void Process(RenderNodeContext context) { - var model = GraphResource?.Model; - var lastTime = GraphResource?.LastTime; - if (GraphResource == null || model == null || lastTime == null) - return context.Input; + NodeGraphFilterEffect.Resource? graphResource = GraphResource; + var model = graphResource?.Model; + var lastTime = graphResource?.LastTime; + if (graphResource == null || !graphResource.IsEnabled || model == null || lastTime == null) + { + context.PassThrough(); + return; + } - // 1. FilterEffectInputNode の OperationWrapperRenderNode を見つける(Build 時に作成済み) - OperationWrapperRenderNode? inputWrapper = FindInputWrapper(model); - if (inputWrapper == null) - return context.Input; + FilterEffectInputRenderNode? inputFacade = FindInputFacade(model, graphResource); + if (inputFacade == null) + { + context.PassThrough(); + return; + } - // 2. 入力 operations を OperationWrapperRenderNode に設定(Evaluate の前に行う) - inputWrapper.SetOperations(context.Input); + using (FilterEffectInputBinding binding = inputFacade.Bind(context)) + { + _compositionContext.Time = lastTime.Value; + _compositionContext.PreferProxy = graphResource.PreferProxy; + _compositionContext.PreferredProxyPreset = graphResource.PreferredProxyPreset; + _compositionContext.DisableResourceShare = graphResource.DisableResourceShare; + _compositionContext.TargetDomain = context.TargetDomain; + graphResource.Snapshot.Evaluate(CompositionTarget.Graphics, _compositionContext); - // 3. グラフのノードを評価 - _compositionContext.Time = lastTime.Value; - _compositionContext.PreferProxy = GraphResource.PreferProxy; - _compositionContext.PreferredProxyPreset = GraphResource.PreferredProxyPreset; - _compositionContext.DisableResourceShare = GraphResource.DisableResourceShare; - GraphResource.Snapshot.Evaluate(CompositionTarget.Graphics, _compositionContext); + var outputRenderNodes = PullOutputValue(model, graphResource); + if (outputRenderNodes.Count == 0) + { + context.PassThrough(); + } + else + { + foreach (IGrouping repeated in outputRenderNodes + .GroupBy(static node => node, s_renderNodeReferenceComparer) + .Where(static group => group.Skip(1).Any())) + { + binding.EnsureFanOutSafe(repeated.Key); + } - // 4. OutputNode から出力 RenderNode を収集 - var outputRenderNodes = PullOutputValue(model); - if (outputRenderNodes.Count == 0) - return context.Input; + foreach (RenderNode outputNode in outputRenderNodes) + { + context.PublishRange(binding.RecordSubtreeForPublication(outputNode)); + } + } - // 5. RenderNodeProcessor でグラフ出力ツリーを処理 - var allResults = new List(); - foreach (RenderNode outputNode in outputRenderNodes) - { - // Forward the working-scale ceiling into the output subtree. - var processor = new RenderNodeProcessor( - outputNode, context.IsRenderCacheEnabled, context.OutputScale, context.MaxWorkingScale); - allResults.AddRange(processor.PullToRoot()); + binding.PublishDeferredPreviews(); } - - inputWrapper.SetOperations([]); - return allResults.ToArray(); } - private OperationWrapperRenderNode? FindInputWrapper(GraphModel model) + private static FilterEffectInputRenderNode? FindInputFacade( + GraphModel model, + NodeGraphFilterEffect.Resource graphResource) { foreach (var node in model.Nodes) { if (node is FilterEffectInputNode) { - int slotIndex = GraphResource!.Snapshot.FindSlotIndex(node); + int slotIndex = graphResource.Snapshot.FindSlotIndex(node); if (slotIndex < 0) continue; - var resource = GraphResource!.Snapshot.GetResource(slotIndex); + var resource = graphResource.Snapshot.GetResource(slotIndex); if (resource is FilterEffectInputNode.Resource inputResource) - return inputResource.Wrapper; + return inputResource.InputFacade; } } return null; } - private List PullOutputValue(GraphModel model) + private static List PullOutputValue( + GraphModel model, + NodeGraphFilterEffect.Resource graphResource) { var result = new List(); foreach (var node in model.Nodes) { if (node is OutputNode outputNode) { - int slotIndex = GraphResource!.Snapshot.FindSlotIndex(outputNode); + int slotIndex = graphResource.Snapshot.FindSlotIndex(outputNode); if (slotIndex < 0) continue; - var resource = GraphResource!.Snapshot.GetResource(slotIndex); + var resource = graphResource.Snapshot.GetResource(slotIndex); if (resource == null) continue; if (!resource.ItemIndexMap.TryGetValue(outputNode.InputPort, out int itemIndex)) continue; - IItemValue? itemValue = GraphResource!.Snapshot.GetItemValue(slotIndex, itemIndex); + IItemValue? itemValue = graphResource.Snapshot.GetItemValue(slotIndex, itemIndex); if (itemValue?.GetBoxed() is RenderNode renderNode) { result.Add(renderNode); @@ -96,3 +121,321 @@ private List PullOutputValue(GraphModel model) return result; } } + +internal sealed class FilterEffectInputBinding : IDisposable +{ + private static readonly AsyncLocal s_current = new(); + private static readonly RenderResourceSlot?, Ref?>> s_previewSinkSlot = new(); + private static readonly TargetCommandDefinition s_emptyPreviewCommand = + CreatePreviewCommand([]); + private static readonly TargetCommandDefinition s_singlePreviewCommand = + CreatePreviewCommand([RenderInputReadback.Values([0])]); + private readonly RenderNodeContext _context; + private readonly FilterEffectInputRenderNode _inputFacade; + private readonly IReadOnlyList _graphInputs; + private readonly FilterEffectInputBinding? _previous; + private readonly Dictionary> _recordedSubtrees = + new(ReferenceEqualityComparer.Instance); + private readonly HashSet _activeNodes = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _consumedNonFanOutSubtrees = new(ReferenceEqualityComparer.Instance); + private readonly List _previews = []; + private bool _disposed; + + internal FilterEffectInputBinding( + FilterEffectInputRenderNode inputFacade, + RenderNodeContext context) + { + _inputFacade = inputFacade; + _context = context; + _graphInputs = context.Inputs; + _previous = s_current.Value; + s_current.Value = this; + } + + internal static bool TryGetCurrent(out FilterEffectInputBinding binding) + { + binding = s_current.Value!; + return binding is not null && !binding._disposed; + } + + internal IReadOnlyList RecordSubtree(RenderNode node) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(node); + RenderNode? canonicalNode = GetCanonicalNode(node); + if (canonicalNode == null) + return []; + + if (_recordedSubtrees.TryGetValue(canonicalNode, out IReadOnlyList? cached)) + return cached; + + if (!_activeNodes.Add(canonicalNode)) + { + throw new InvalidOperationException( + $"A node-graph render cycle was detected at '{canonicalNode.GetType().FullName}'."); + } + + try + { + IReadOnlyList result; + if (ReferenceEquals(canonicalNode, _inputFacade)) + { + result = _context.RecordNode(canonicalNode, _graphInputs); + } + else if (canonicalNode is ContainerRenderNode container) + { + var inputs = new List(); + foreach (RenderNode child in container.Children) + { + IReadOnlyList childOutputs = RecordSubtree(child); + MarkSubtreeConsumed(child, childOutputs); + inputs.AddRange(childOutputs); + } + + result = _context.RecordNode(canonicalNode, inputs); + } + else + { + result = _context.RecordNode(canonicalNode, []); + } + + _recordedSubtrees.Add(canonicalNode, result); + return result; + } + finally + { + _activeNodes.Remove(canonicalNode); + } + } + + internal IReadOnlyList RecordSubtreeForPublication(RenderNode node) + { + IReadOnlyList outputs = RecordSubtree(node); + MarkSubtreeConsumed(node, outputs); + return outputs; + } + + internal Rect MeasureSubtree(RenderNode node) + { + IReadOnlyList outputs = RecordSubtree(node); + return CalculateRecordedQueryBounds(outputs); + } + + internal void EnsureFanOutSafe(RenderNode node) + { + IReadOnlyList outputs = RecordSubtree(node); + if (outputs.All(static output => output.CanBeUsedAsValueInput)) + return; + + ReplaceWithFiniteLayer(node, outputs); + } + + internal void RegisterPreview( + RenderNode? node, + Func?, Ref?> replace) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(replace); + if (node is null) + { + _previews.Add(new DeferredPreview([], replace)); + return; + } + + IReadOnlyList outputs = RecordSubtree(node); + if (outputs.Count == 0 || HasEmptyOutputExtent(outputs)) + { + _previews.Add(new DeferredPreview([], replace)); + return; + } + + if (outputs is [RenderFragmentHandle optional] + && optional.CanBeUsedAsValueInput + && optional.ValueCardinality.Minimum == 0 + && optional.ValueCardinality.Maximum != 0) + { + _previews.Add(new DeferredPreview([optional], replace)); + return; + } + + // A layer preserves painter order and guarantees one runtime value for the deferred preview readback. + // When a raw output cannot fan out, replace the identity cache so later graph outputs share the layer. + RenderFragmentHandle layer = NormalizeToLayer(outputs); + if (outputs.Any(static output => !output.CanBeUsedAsValueInput)) + { + MarkSubtreeConsumed(node, outputs); + _recordedSubtrees[GetCanonicalNode(node)!] = [layer]; + } + _previews.Add(new DeferredPreview([layer], replace)); + } + + internal void PublishDeferredPreviews() + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (DeferredPreview preview in _previews) + { + Func?, Ref?> replace = preview.Replace; + IReadOnlyList inputs = preview.Inputs; + RenderResource?, Ref?>> sink = _context.Borrow(replace); + TargetCommandDefinition command = inputs.Count switch + { + 0 => s_emptyPreviewCommand, + 1 => s_singlePreviewCommand, + _ => throw new InvalidOperationException( + "A normalized node-graph preview must have zero or one value input."), + }; + _context.Publish(_context.TargetCommand( + inputs, + command.Call(default, [s_previewSinkSlot.Bind(sink)]))); + } + + _previews.Clear(); + } + + private IReadOnlyList ReplaceWithFiniteLayer( + RenderNode node, + IReadOnlyList outputs) + { + if (outputs.Count == 0 || HasEmptyOutputExtent(outputs)) + { + throw new InvalidOperationException( + $"The shared node-graph subtree '{node.GetType().FullName}' cannot be normalized " + + "because it has no finite non-empty recording bounds."); + } + + MarkSubtreeConsumed(node, outputs); + IReadOnlyList normalized = [NormalizeToLayer(outputs)]; + _recordedSubtrees[GetCanonicalNode(node)!] = normalized; + return normalized; + } + + private void MarkSubtreeConsumed( + RenderNode node, + IReadOnlyList outputs) + { + if (outputs.All(static output => output.CanBeUsedAsValueInput)) + return; + + RenderNode? canonicalNode = GetCanonicalNode(node); + if (canonicalNode == null) + return; + + // A non-value fragment cannot fan out. If its identity reappears after one parent has already + // consumed it, normalization is no longer safe because the first parent transaction is recorded. + // Fail here with the NodeGraph identity rather than later in transaction fan-out validation. + if (!_consumedNonFanOutSubtrees.Add(canonicalNode)) + { + throw new InvalidOperationException( + $"The non-value node-graph subtree '{canonicalNode.GetType().FullName}' is used by more than one consumer. " + + "Wrap the shared subtree in a finite value-producing layer before branching."); + } + } + + private static RenderNode? GetCanonicalNode(RenderNode node) + { + RenderNode current = node; + RenderNode? slow = node; + RenderNode? fast = node; + while (current is ReferencesChildRenderNode { Child: { IsDisposed: false } child }) + { + current = child; + slow = GetReferenceChild(slow); + fast = GetReferenceChild(GetReferenceChild(fast)); + if (slow != null && ReferenceEquals(slow, fast)) + { + throw new InvalidOperationException( + $"A node-graph render cycle was detected at '{slow.GetType().FullName}'."); + } + } + + return current is ReferencesChildRenderNode ? null : current; + } + + private static RenderNode? GetReferenceChild(RenderNode? node) + => node is ReferencesChildRenderNode { Child: { IsDisposed: false } child } ? child : null; + + /// + /// Normalizes into one value-eligible layer. + /// + /// + /// The recording node observes its own local coordinate space, which every enclosing target scope + /// separates from the request root. A symbolic subtree therefore defers its domain to graph-wide + /// owning-target lowering, which back-maps the root domain through those scopes. + /// + private RenderFragmentHandle NormalizeToLayer(IReadOnlyList outputs) + => _context.TryCalculateRecordedOutputExtent(outputs, out Rect bounds) + ? _context.Layer(outputs, bounds) + : _context.OwningTargetLayer(outputs); + + private bool HasEmptyOutputExtent(IReadOnlyList outputs) + => _context.TryCalculateRecordedOutputExtent(outputs, out Rect bounds) + && (bounds.Width == 0 || bounds.Height == 0); + + private Rect CalculateRecordedQueryBounds(IReadOnlyList fragments) + { + Rect result = Rect.Empty; + foreach (RenderFragmentHandle fragment in fragments) + { + result = result.Union(_context.GetRecordedMetadataHint(fragment).Bounds); + } + + return result; + } + + private static void ExecutePreview( + TargetCommandSession session, + Func?, Ref?> replace) + { + Ref? replacement = null; + Ref? previous = null; + + try + { + if (session.Inputs.Count == 1) + { + session.Inputs[0].UseSnapshot( + bitmap => replacement = Ref.Create(bitmap.Clone())); + } + + previous = replace(replacement); + replacement = null; + } + finally + { + replacement?.Dispose(); + previous?.Dispose(); + } + } + + private static TargetCommandDefinition CreatePreviewCommand( + IReadOnlyList inputReadbacks) + => TargetCommandDefinition.Create( + static (session, _) => session.UseResource( + s_previewSinkSlot, + sink => ExecutePreview(session, sink)), + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None, + inputReadbacks: inputReadbacks, + resources: [s_previewSinkSlot]); + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _previews.Clear(); + _recordedSubtrees.Clear(); + _activeNodes.Clear(); + _consumedNonFanOutSubtrees.Clear(); + if (ReferenceEquals(s_current.Value, this)) + s_current.Value = _previous; + } + + private sealed record DeferredPreview( + IReadOnlyList Inputs, + Func?, Ref?> Replace); + + private readonly record struct PreviewCommandState; +} diff --git a/src/Beutl.NodeGraph/Nodes/ConfigureNode.cs b/src/Beutl.NodeGraph/Nodes/ConfigureNode.cs index 5578694ca2..dc65306778 100644 --- a/src/Beutl.NodeGraph/Nodes/ConfigureNode.cs +++ b/src/Beutl.NodeGraph/Nodes/ConfigureNode.cs @@ -19,18 +19,64 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; var inputs = context.CollectListInputValues(node.InputPort); UpdateCore(context); var output = OutputPort; if (output == null) return; - output.HasChanges = inputs.Any(i => i?.HasChanges == true) || output.HasChanges; - output.RemoveRange(0, output.Children.Count); - foreach (var input in inputs.OfType()) + bool hasChanges = false; + if (output.Children.Any(static child => child is not ReferencesChildRenderNode)) { - output.AddChild(input); + DetachInputReferences(output); + hasChanges = true; + } + + int childIndex = 0; + foreach (RenderNode? input in inputs) + { + if (input is null) continue; + + ReferencesChildRenderNode reference; + if (childIndex < output.Children.Count) + { + reference = (ReferencesChildRenderNode)output.Children[childIndex]; + } + else + { + reference = new ReferencesChildRenderNode(input); + output.AddChild(reference); + hasChanges = true; + } + + hasChanges |= reference.Update(input); + childIndex++; + } + + while (output.Children.Count > childIndex) + { + int index = output.Children.Count - 1; + RenderNode child = output.Children[index]; + output.RemoveRange(index, 1); + ((ReferencesChildRenderNode)child).Dispose(); + hasChanges = true; + } + + output.HasChanges = inputs.Any(i => i?.HasChanges == true) || hasChanges || output.HasChanges; + } + + private static void DetachInputReferences(ContainerRenderNode output) + { + while (output.Children.Count > 0) + { + int index = output.Children.Count - 1; + RenderNode child = output.Children[index]; + output.RemoveRange(index, 1); + if (child is ReferencesChildRenderNode reference) + { + reference.Dispose(); + } } } diff --git a/src/Beutl.NodeGraph/Nodes/FactoryNode.cs b/src/Beutl.NodeGraph/Nodes/FactoryNode.cs index 0bbde932ca..821775b5df 100644 --- a/src/Beutl.NodeGraph/Nodes/FactoryNode.cs +++ b/src/Beutl.NodeGraph/Nodes/FactoryNode.cs @@ -53,7 +53,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; OutputPort = node.Object; } } diff --git a/src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs b/src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs index 915b7ad9a6..efc541048c 100644 --- a/src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs +++ b/src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs @@ -14,19 +14,28 @@ public FilterEffectInputNode() public partial class Resource { - internal OperationWrapperRenderNode Wrapper { get; } = new(); + internal FilterEffectInputRenderNode InputFacade { get; } = new(); public override void Update(GraphCompositionContext context) { - Output = Wrapper; + Output = InputFacade; } partial void PostDispose(bool disposing) { if (disposing) { - Wrapper.Dispose(); + InputFacade.Dispose(); } } } } + +internal sealed class FilterEffectInputRenderNode : RenderNode +{ + internal FilterEffectInputBinding Bind(RenderNodeContext context) + => new(this, context); + + public override void Process(RenderNodeContext context) + => context.PassThrough(); +} diff --git a/src/Beutl.NodeGraph/Nodes/FilterEffectNode.cs b/src/Beutl.NodeGraph/Nodes/FilterEffectNode.cs index da835cb418..d26eab9194 100644 --- a/src/Beutl.NodeGraph/Nodes/FilterEffectNode.cs +++ b/src/Beutl.NodeGraph/Nodes/FilterEffectNode.cs @@ -52,7 +52,7 @@ public partial class Resource { protected override void UpdateCore(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; FilterEffect.Resource? resource; var output = OutputPort; diff --git a/src/Beutl.NodeGraph/Nodes/GeometryNode.cs b/src/Beutl.NodeGraph/Nodes/GeometryNode.cs index 2769d1564a..7ba7a28eae 100644 --- a/src/Beutl.NodeGraph/Nodes/GeometryNode.cs +++ b/src/Beutl.NodeGraph/Nodes/GeometryNode.cs @@ -60,7 +60,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; OutputPort = node.Object; } } diff --git a/src/Beutl.NodeGraph/Nodes/Group/GroupInput.cs b/src/Beutl.NodeGraph/Nodes/Group/GroupInput.cs index dc6fdd2c15..e385127b7e 100644 --- a/src/Beutl.NodeGraph/Nodes/Group/GroupInput.cs +++ b/src/Beutl.NodeGraph/Nodes/Group/GroupInput.cs @@ -71,7 +71,7 @@ public override void Update(GraphCompositionContext context) { if (OuterInputValues == null) return; - var node = GetOriginal(); + var node = GetOriginal()!; // 外部 GroupNode の入力値を GroupInput の出力値にコピー for (int i = 0; i < ItemValues.Length && i < OuterInputValues.Length; i++) { diff --git a/src/Beutl.NodeGraph/Nodes/Group/GroupNode.cs b/src/Beutl.NodeGraph/Nodes/Group/GroupNode.cs index 275b7b6559..05a992274f 100644 --- a/src/Beutl.NodeGraph/Nodes/Group/GroupNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Group/GroupNode.cs @@ -291,7 +291,7 @@ public partial class Resource public override void Initialize(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; node.Group.TopologyChanged += OnGroupTopologyChanged; _innerSnapshot = new GraphSnapshot(); _innerSnapshot.Build(node.Group, context); @@ -306,7 +306,7 @@ private void OnGroupTopologyChanged(object? sender, EventArgs e) public override void Uninitialize() { - var node = GetOriginal(); + var node = GetOriginal()!; node.Group.TopologyChanged -= OnGroupTopologyChanged; _innerSnapshot?.Dispose(); _innerSnapshot = null; @@ -316,7 +316,7 @@ public override void Uninitialize() public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; if (_innerSnapshot == null) return; // GroupNodeの入力値からGroupInputの出力値に転送 diff --git a/src/Beutl.NodeGraph/Nodes/LayerInputNode.cs b/src/Beutl.NodeGraph/Nodes/LayerInputNode.cs index ce197b1ef6..e7ea70cc71 100644 --- a/src/Beutl.NodeGraph/Nodes/LayerInputNode.cs +++ b/src/Beutl.NodeGraph/Nodes/LayerInputNode.cs @@ -125,7 +125,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; // UseGlobalClock=false のアニメーションに対して、 // Element.Start 分のオフセットを適用して再評価 diff --git a/src/Beutl.NodeGraph/Nodes/TextNode.cs b/src/Beutl.NodeGraph/Nodes/TextNode.cs index 448967c79b..0fa8c939bf 100644 --- a/src/Beutl.NodeGraph/Nodes/TextNode.cs +++ b/src/Beutl.NodeGraph/Nodes/TextNode.cs @@ -62,7 +62,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; var output = Output; if (output?.Drawable?.Resource is not TextBlock.Resource resource) { diff --git a/src/Beutl.NodeGraph/Nodes/TransformNode.cs b/src/Beutl.NodeGraph/Nodes/TransformNode.cs index b3273ac524..60bade350b 100644 --- a/src/Beutl.NodeGraph/Nodes/TransformNode.cs +++ b/src/Beutl.NodeGraph/Nodes/TransformNode.cs @@ -17,7 +17,7 @@ public partial class Resource { protected override void UpdateCore(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; var matrix = context.HasConnection(node.Matrix) ? Matrix : Graphics.Matrix.Identity; diff --git a/src/Beutl.NodeGraph/Nodes/Utilities/ExpressionNode.cs b/src/Beutl.NodeGraph/Nodes/Utilities/ExpressionNode.cs index ff28e57e3e..a437307d8b 100644 --- a/src/Beutl.NodeGraph/Nodes/Utilities/ExpressionNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Utilities/ExpressionNode.cs @@ -30,7 +30,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; var state = node._state; string? expression = Expression; diff --git a/src/Beutl.NodeGraph/Nodes/Utilities/MatrixNode.cs b/src/Beutl.NodeGraph/Nodes/Utilities/MatrixNode.cs index a517548eab..700f9ce23b 100644 --- a/src/Beutl.NodeGraph/Nodes/Utilities/MatrixNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Utilities/MatrixNode.cs @@ -19,7 +19,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; Matrix matrix = GetMatrix(context, node); if (context.HasConnection(node.Input)) diff --git a/src/Beutl.NodeGraph/Nodes/Utilities/MeasureNode.cs b/src/Beutl.NodeGraph/Nodes/Utilities/MeasureNode.cs index a0ae7ffd18..b10695504b 100644 --- a/src/Beutl.NodeGraph/Nodes/Utilities/MeasureNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Utilities/MeasureNode.cs @@ -29,24 +29,40 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { + Rect rect = Rect.Empty; if (Input is RenderNode renderNode) { - // Scale 1 intentional: GraphCompositionContext carries no output scale; bounds are logical-res. - var processor = new RenderNodeProcessor(renderNode, true); - RenderNodeOperation[] list = processor.PullToRoot(); - Rect rect = Rect.Empty; - - foreach (RenderNodeOperation item in list) + if (FilterEffectInputBinding.TryGetCurrent(out FilterEffectInputBinding binding)) { - rect = rect.Union(item.Bounds); - item.Dispose(); + rect = binding.MeasureSubtree(renderNode); + } + else + { + // A TargetDomain also widens the measured extent, so it serves only as a + // fallback owner for graphs whose Full target access cannot resolve without one. + try + { + using var renderer = new RenderNodeRenderer(renderNode); + rect = renderer.Measure().QueryBounds; + } + catch (RenderTargetDomainRequiredException) when (context.TargetDomain is { } domain) + { + using var renderer = new RenderNodeRenderer(renderNode, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + }, + }); + rect = renderer.Measure().QueryBounds; + } } - - X = rect.X; - Y = rect.Y; - Width = rect.Width; - Height = rect.Height; } + + X = rect.X; + Y = rect.Y; + Width = rect.Width; + Height = rect.Height; } } } diff --git a/src/Beutl.NodeGraph/Nodes/Utilities/PreviewNode.cs b/src/Beutl.NodeGraph/Nodes/Utilities/PreviewNode.cs index 3474197881..68eefd9810 100644 --- a/src/Beutl.NodeGraph/Nodes/Utilities/PreviewNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Utilities/PreviewNode.cs @@ -7,6 +7,7 @@ namespace Beutl.NodeGraph.Nodes.Utilities; public partial class PreviewNode : GraphNode { + private readonly object _previewLock = new(); private readonly NodeMonitor?> _preview; public PreviewNode() @@ -21,23 +22,87 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; if (!node._preview.IsEnabled) return; - if (Input is RenderNode renderNode) + if (FilterEffectInputBinding.TryGetCurrent(out FilterEffectInputBinding binding)) { - // Scale 1 intentional: GraphCompositionContext carries no output scale; thumbnails are logical-res. - var processor = new RenderNodeProcessor(renderNode, true); - var bitmap = processor.RasterizeAndConcat(); - node._preview.Value?.Dispose(); - node._preview.Value = Ref.Create(bitmap); + binding.RegisterPreview(Input, node.SwapPreview); + } + else if (Input is RenderNode renderNode) + { + // A TargetDomain also widens the output extent, so it serves only as a fallback + // owner for graphs whose Full target access cannot resolve without one. + try + { + using var renderer = new RenderNodeRenderer(renderNode); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + node.ReplacePreview(rasterization.Bitmap?.Clone()); + } + catch (RenderTargetDomainRequiredException) when (context.TargetDomain is { } domain) + { + using var renderer = new RenderNodeRenderer(renderNode, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + node.ReplacePreview(rasterization.Bitmap?.Clone()); + } } else { - node._preview.Value?.Dispose(); - node._preview.Value = null; + node.ReplacePreview(null); + } + } + } + + private void ReplacePreview(Bitmap? bitmap) + { + Ref? replacement = bitmap is null ? null : Ref.Create(bitmap); + Ref? previous = null; + try + { + previous = SwapPreview(replacement); + replacement = null; + } + finally + { + replacement?.Dispose(); + previous?.Dispose(); + } + } + + private Ref? SwapPreview(Ref? replacement) + { + lock (_previewLock) + { + Ref? previous = _preview.Value; + try + { + _preview.Value = replacement; } + catch (Exception assignmentFailure) + { + try + { + _preview.Value = previous; + } + catch (Exception restoreFailure) + { + throw new AggregateException( + "The preview monitor rejected both the replacement and restoration notifications.", + assignmentFailure, + restoreFailure); + } + + throw; + } + + return previous; } } } diff --git a/src/Beutl.NodeGraph/Nodes/Utilities/TimeNode.cs b/src/Beutl.NodeGraph/Nodes/Utilities/TimeNode.cs index 1006c41fda..e3d46c9deb 100644 --- a/src/Beutl.NodeGraph/Nodes/Utilities/TimeNode.cs +++ b/src/Beutl.NodeGraph/Nodes/Utilities/TimeNode.cs @@ -24,7 +24,7 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = GetOriginal(); + var node = GetOriginal()!; float duration = (float)node.TimeRange.Duration.TotalSeconds; float start = (float)node.TimeRange.Start.TotalSeconds; float time = (float)context.Time.TotalSeconds - start; diff --git a/src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs b/src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs index e0194d2ca8..6e2a7c9541 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.Runtime.ExceptionServices; using Beutl.Composition; using Beutl.Engine; using Beutl.Graphics; @@ -34,12 +35,15 @@ protected override Size MeasureCore(Size availableSize, Drawable.Resource resour protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) { var r = (Resource)resource; + var parameters = new SceneBitmapParameters(r, context.OutputScale); context.DrawNode( - r, - static r => new SceneBitmapRenderNode(r), - static (node, r) => node.Update(r)); + parameters, + static parameters => SceneBitmapRenderNode.Create(parameters), + static (node, parameters) => node.Update(parameters)); } + private readonly record struct SceneBitmapParameters(Resource Resource, float OutputScale); + private readonly struct CapturedCompositionFrame(CompositionFrame frame) { public readonly ImmutableArray<(EngineObject.Resource Resource, int Version)> Objects = [.. frame.Objects.Select(r => (r, r.Version))]; @@ -154,72 +158,202 @@ partial void PostDispose(bool disposing) } } - private class SceneBitmapRenderNode(Resource resource) : RenderNode + private class SceneBitmapRenderNode : ContainerRenderNode { - private Renderer? _renderer; - - public (Resource Resource, int Version)? Scene { get; set; } = resource.Capture(); + private float _outputScale; + private PixelSize _frameSize; + + public (Resource Resource, int Version)? Scene { get; private set; } + + /// + /// These children are built once, by the that recorded the drawable, + /// at whatever density that context carried. A later request rasterizing at a different one would + /// otherwise replay a nested scene frozen at the first, while everything around it moved. Recording + /// walks children before their parent, so this is the last moment they can still be rebuilt. + /// + public override void PrepareForRequest(RenderNodePreparation preparation) + { + if (Scene is { } captured) + { + Update(new SceneBitmapParameters(captured.Resource, preparation.OutputScale)); + } + } - public bool Update(Resource resource) + public static SceneBitmapRenderNode Create(SceneBitmapParameters parameters) { - if (!resource.Compare(Scene)) + var node = new SceneBitmapRenderNode(); + try { - Scene = resource.Capture(); - HasChanges = true; - return true; + node.Update(parameters); + return node; } + catch + { + node.Dispose(); + throw; + } + } + + public bool Update(SceneBitmapParameters parameters) + { + Resource resource = parameters.Resource; + float outputScale = parameters.OutputScale; + bool sceneChanged = !resource.Compare(Scene); + bool scaleChanged = _outputScale != outputScale; + if (!sceneChanged && !scaleChanged) + return false; + + CompositionFrame? frame = resource.Frame; + PixelSize frameSize = frame?.Size ?? default; + bool rebuildAll = scaleChanged || frameSize != _frameSize; + ReconcileChildren(frame, outputScale, rebuildAll); - return false; + Scene = resource.Capture(); + _outputScale = outputScale; + _frameSize = frameSize; + HasChanges = true; + return true; } - public override RenderNodeOperation[] Process(RenderNodeContext context) + private void ReconcileChildren( + CompositionFrame? frame, + float outputScale, + bool rebuildAll) { - var frame = Scene?.Resource.Frame; - if (frame == null) - return []; - - // Inherit the outer render scale so nested scenes are not rasterized at 1x and upscaled. - float w = context.OutputScale; - var size = frame.Value.Size; - if (_renderer == null - || _renderer.FrameSize != size - || _renderer.OutputScale != w - || _renderer.MaxWorkingScale != context.MaxWorkingScale) - { - _renderer?.Dispose(); - _renderer = new Renderer(size.Width, size.Height, w, context.MaxWorkingScale); - } - - Renderer renderer = _renderer; - var bounds = new Rect(0, 0, size.Width, size.Height); - return - [ - RenderNodeOperation.CreateLambda( - bounds, - canvas => + int childIndex = 0; + if (frame is { } currentFrame) + { + Size canvasSize = currentFrame.Size.ToSize(1); + foreach (EngineObject.Resource item in currentFrame.Objects) + { + if (item is not Drawable.Resource drawableResource) + continue; + + Drawable drawable = drawableResource.GetOriginal()!; + DrawableRenderNode? node = childIndex < Children.Count + ? Children[childIndex] as DrawableRenderNode + : null; + bool canReuse = node?.Drawable is { } captured + && ReferenceEquals(captured.Resource.GetOriginal(), drawable); + if (canReuse) { - renderer.Render(frame.Value); - RenderTarget renderTarget = Renderer.GetInternalRenderTarget(renderer); - // Point-blit only when both buffer and canvas are at density 1; otherwise use scaled blit. - if (w == 1f && canvas.Density == 1f) + bool resourceChanged = !drawableResource.Compare(node!.Drawable); + if (resourceChanged || rebuildAll) { - canvas.DrawRenderTarget(renderTarget, default); + RebuildChildTransactionally( + node, + drawable, + drawableResource, + canvasSize, + outputScale); } - else + } + else + { + node = new DrawableRenderNode(drawableResource); + bool installed = false; + try { - canvas.DrawRenderTargetScaled(renderTarget, bounds); + using var graphics = new GraphicsContext2D( + node, + canvasSize, + outputScale); + drawable.Render(graphics, drawableResource); + + if (childIndex < Children.Count) + { + installed = true; + SetChild(childIndex, node); + } + else + { + AddChild(node); + installed = true; + } } - }, - effectiveScale: EffectiveScale.At(w)) - ]; + catch + { + if (!installed) + node.Dispose(); + throw; + } + } + + childIndex++; + } + } + + if (childIndex < Children.Count) + { + RenderNode[] removed = [.. Children.Skip(childIndex)]; + RemoveRange(childIndex, Children.Count - childIndex); + DisposeAll(removed); + } + } + + private static void RebuildChildTransactionally( + DrawableRenderNode destination, + Drawable drawable, + Drawable.Resource resource, + Size canvasSize, + float outputScale) + { + using var candidate = new DrawableRenderNode(resource); + using (var graphics = new GraphicsContext2D( + candidate, + canvasSize, + outputScale)) + { + drawable.Render(graphics, resource); + } + + RenderNode[] previous = [.. destination.Children]; + destination.BringFrom(candidate); + DisposeAll(previous); + destination.Update(resource); + destination.HasChanges = true; + } + + private static void DisposeAll(IEnumerable nodes) + { + List? failures = null; + foreach (RenderNode node in nodes) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + } + + if (failures is [var failure]) + ExceptionDispatchInfo.Capture(failure).Throw(); + if (failures is { Count: > 1 }) + { + throw new AggregateException( + "One or more nested-scene nodes failed to dispose.", + failures); + } + } + + public override void Process(RenderNodeContext context) + { + var frame = Scene?.Resource.Frame; + if (frame == null) + return; + + PixelSize size = frame.Value.Size; + var domain = new Rect(0, 0, size.Width, size.Height); + context.Publish(context.Layer(context.Inputs, domain, domainIsQueryFootprint: true)); } protected override void OnDispose(bool disposing) { - base.OnDispose(disposing); Scene = null; - _renderer?.Dispose(); - _renderer = null; + base.OnDispose(disposing); } } } diff --git a/src/Beutl.ProjectSystem/SceneCompositor.cs b/src/Beutl.ProjectSystem/SceneCompositor.cs index e077ffedc7..d7d3b11e7c 100644 --- a/src/Beutl.ProjectSystem/SceneCompositor.cs +++ b/src/Beutl.ProjectSystem/SceneCompositor.cs @@ -5,6 +5,7 @@ using Beutl.Composition; using Beutl.Configuration; using Beutl.Engine; +using Beutl.Graphics; using Beutl.Media; using Beutl.Media.Proxy; using Beutl.ProjectSystem; @@ -55,6 +56,7 @@ public CompositorContext(TimeSpan time, PreferProxy = !compositor.ForceOriginalSource && GlobalConfiguration.Instance.EditorConfig.PreviewSourceMode == PreviewSourceMode.PreferProxy; PreferredProxyPreset = ToPreset(GlobalConfiguration.Instance.ProxyStoreConfig.DefaultPreset); + TargetDomain = new Rect(default, compositor.Scene.FrameSize.ToSize(1)); } public IList CurrentElements { get; set; } @@ -146,7 +148,7 @@ public CompositionFrame EvaluateAudio(TimeRange timeRange) allResources.AddRange(flow.Span); foreach (EngineObject.Resource resource in flow.Span) { - eligibleObjects.Add(resource.GetOriginal()); + eligibleObjects.Add(resource.GetOriginal()!); } } diff --git a/src/Beutl.ProjectSystem/SceneRenderer.cs b/src/Beutl.ProjectSystem/SceneRenderer.cs index 330bb22b36..f5cd056ac8 100644 --- a/src/Beutl.ProjectSystem/SceneRenderer.cs +++ b/src/Beutl.ProjectSystem/SceneRenderer.cs @@ -7,22 +7,15 @@ public sealed class SceneRenderer : Renderer { private readonly SceneCompositor _compositor; + /// public SceneRenderer( Scene scene, + RenderIntent intent, float renderScale = 1f, bool disableResourceShare = false, - float maxWorkingScale = float.PositiveInfinity) - : this(scene, renderScale, disableResourceShare, maxWorkingScale, forceOriginalSource: false) - { - } - - public SceneRenderer( - Scene scene, - float renderScale, - bool disableResourceShare, - float maxWorkingScale, - bool forceOriginalSource) - : base(scene.FrameSize.Width, scene.FrameSize.Height, renderScale, maxWorkingScale) + float maxWorkingScale = float.PositiveInfinity, + bool forceOriginalSource = false) + : base(scene.FrameSize.Width, scene.FrameSize.Height, intent, renderScale, maxWorkingScale) { _compositor = new SceneCompositor(scene) { diff --git a/src/Beutl/Helpers/AvaloniaTypeConverter.cs b/src/Beutl/Helpers/AvaloniaTypeConverter.cs index 8403caaa18..5a2c4c9d74 100644 --- a/src/Beutl/Helpers/AvaloniaTypeConverter.cs +++ b/src/Beutl/Helpers/AvaloniaTypeConverter.cs @@ -7,8 +7,10 @@ using Beutl.Editor.Components.Helpers; using Beutl.Engine; using Beutl.Graphics.Rendering; +using Beutl.Logging; using Beutl.Media; using Beutl.Threading; +using Microsoft.Extensions.Logging; using Reactive.Bindings; using Reactive.Bindings.Extensions; using Dispatcher = Avalonia.Threading.Dispatcher; @@ -66,8 +68,7 @@ public static (IObservable, IDisposable) ToAvaGeometryS r => { using var context = new GeometryContext(); - var original = r.GetOriginal(); - original.ApplyTo(context, r); + r.ApplyTo(context); string svgPath = context.NativeObject.ToSvgPathData(); reactiveProperty.Value = Avalonia.Media.Geometry.Parse(svgPath); @@ -236,67 +237,318 @@ public static (Avalonia.Media.Brush?, IDisposable, Action?) ToAvaBrushSync(this (o, rc) => o.ToResource(rc), r => { - handler ??= new DrawableImageBrushHandler(r, imageBrush); + handler ??= new DrawableImageBrushHandler( + r, imageBrush, RenderThread.Dispatcher, ownsResource: false); handler.Update(); }); - return (imageBrush, d, null); + return ( + imageBrush, + System.Reactive.Disposables.Disposable.Create(() => + { + d.Dispose(); + handler?.Dispose(); + }), + null); } } return default; } - public sealed class DrawableImageBrushHandler + public sealed class DrawableImageBrushHandler : IDisposable { - private WriteableBitmap? _bitmap; - private CancellationTokenSource? _cts; + private static readonly ILogger s_thumbnailLogger = Log.CreateLogger(); + private readonly ImageBrush _imageBrush; private readonly DrawableBrush.Resource _drawableBrush; + private readonly Beutl.Threading.Dispatcher _renderDispatcher; + private readonly bool _ownsResource; + private readonly EventHandler _shutdownHandler; + private readonly object _gate = new(); + private WriteableBitmap? _bitmap; + private CancellationTokenSource? _cts; + private int _queuedUpdates; + private int _runningUpdates; + private bool _disposeRequested; + private bool _resourceReleased; public DrawableImageBrushHandler(DrawableBrush.Resource drawableBrush, ImageBrush imageBrush) + : this(drawableBrush, imageBrush, RenderThread.Dispatcher) + { + } + + public DrawableImageBrushHandler( + DrawableBrush.Resource drawableBrush, + ImageBrush imageBrush, + Beutl.Threading.Dispatcher renderDispatcher) + : this(drawableBrush, imageBrush, renderDispatcher, ownsResource: true) + { + } + + /// Creates a handler with an explicit resource owner. + /// + /// when the caller's subscription already owns + /// and disposes it; a second owner would dispose the same resource twice. + /// + internal DrawableImageBrushHandler( + DrawableBrush.Resource drawableBrush, + ImageBrush imageBrush, + Beutl.Threading.Dispatcher renderDispatcher, + bool ownsResource) { _imageBrush = imageBrush; _drawableBrush = drawableBrush; + _renderDispatcher = renderDispatcher; + _ownsResource = ownsResource; + // A shutdown drops queued work without running it, so the resource must be released from here too. + _shutdownHandler = (_, _) => ReleaseResourceIfSettled(); + _renderDispatcher.ShutdownStarted += _shutdownHandler; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposeRequested) + return; + + _disposeRequested = true; + _cts?.Cancel(); + } + + ClearPublishedBitmap(); + ReleaseResourceIfSettled(); } public void Update() { - _cts?.Cancel(); - _cts = new CancellationTokenSource(); - var token = _cts.Token; + CancellationToken token; + lock (_gate) + { + if (_disposeRequested) + return; + + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + token = _cts.Token; + _queuedUpdates++; + } - RenderThread.Dispatcher.Dispatch(async () => + _renderDispatcher.Dispatch(async () => { - if (_drawableBrush.Drawable == null) return; - var node = new DrawableRenderNode(_drawableBrush.Drawable); + lock (_gate) + { + _queuedUpdates--; + _runningUpdates++; + } + + try + { + await RenderAndPublishAsync(token); + } + finally + { + lock (_gate) + { + _runningUpdates--; + } + + ReleaseResourceIfSettled(); + } + }, DispatchPriority.Low); + } + + private void ClearPublishedBitmap() + { + WriteableBitmap? published; + lock (_gate) + { + published = _bitmap; + _bitmap = null; + } + + void Clear() + { + _imageBrush.Source = null; + published?.Dispose(); + } + + if (Dispatcher.UIThread.CheckAccess()) + Clear(); + else + Dispatcher.UIThread.Post(Clear, DispatcherPriority.Background); + } + + private void ReleaseResourceIfSettled() + { + lock (_gate) + { + if (_resourceReleased || !_disposeRequested) + return; + // An update already in flight is still recording from the resource, so it has to settle + // first even during shutdown; only work that never starts can be written off. + if (_runningUpdates > 0) + return; + // The ShutdownStarted event is one-shot, so a dispatcher that stopped before this handler + // subscribed never delivers it. Its own state is the signal that queued work is dead. + if (!_renderDispatcher.HasShutdownStarted && _queuedUpdates > 0) + return; + + _resourceReleased = true; + _cts?.Dispose(); + _cts = null; + } + + _renderDispatcher.ShutdownStarted -= _shutdownHandler; + if (!_ownsResource) + return; + + // A shutting-down dispatcher never runs queued work, so the release has to happen inline there. + if (_renderDispatcher.CheckAccess() || _renderDispatcher.HasShutdownStarted) + _drawableBrush.Dispose(); + else + _renderDispatcher.Dispatch(_drawableBrush.Dispose, DispatchPriority.Low); + } + + private async Task RenderAndPublishAsync(CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + if (_drawableBrush.Drawable == null) return; + { + // The node owns the recorded graph, and a thumbnail is re-rendered on every property change, + // so leaving it to the finalizer strands one graph per keystroke in the editor. + using var node = new DrawableRenderNode(_drawableBrush.Drawable); // TODO: UI側の物理的なサイズをもとに描画するように変更する using (var context = new GraphicsContext2D(node, new Graphics.Size(1920, 1080))) { _drawableBrush.Drawable.GetOriginal()!.Render(context, _drawableBrush.Drawable); } - var processor = new RenderNodeProcessor(node, false); - using var bitmap = processor.RasterizeAndConcat(); - - var previous = _bitmap; - var pixelSize = new PixelSize(bitmap.Width, bitmap.Height); - _bitmap = bitmap.ToAvaWriteableBitmap(null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + // A grouped drawable records a full-target layer scope, which cannot be + // resolved without a domain; use the canvas the content was recorded against. + TargetDomain = new Graphics.Rect(0, 0, 1920, 1080), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Media.Bitmap? bitmap = rasterization.Bitmap; + if (token.IsCancellationRequested || bitmap is null) + return; + + WriteableBitmap published = bitmap.ToAvaWriteableBitmap(null); + Stretch stretch = _drawableBrush.Stretch; await Dispatcher.UIThread.InvokeAsync(() => { - _imageBrush.Stretch = _drawableBrush.Stretch switch + WriteableBitmap? previous; + lock (_gate) + { + // A superseding update or a disposal must win over work that was already queued here. + if (token.IsCancellationRequested || _disposeRequested) + { + published.Dispose(); + return; + } + + previous = _bitmap; + _bitmap = published; + } + + Avalonia.Media.Stretch previousStretch = _imageBrush.Stretch; + + void Rollback() + { + bool disposed; + lock (_gate) + { + disposed = _disposeRequested; + _bitmap = disposed ? null : previous; + } + + // Restoring either property can raise the same listener that rejected the + // publication; a failure there must not leave the new bitmap owned by nobody. + Restore(() => _imageBrush.Source = disposed ? null : previous); + Restore(() => _imageBrush.Stretch = previousStretch); + if (disposed) + { + // Dispose ran during the notification and already cleared and released the + // publication, so restoring it would reinstate a thumbnail nobody owns. + previous?.Dispose(); + return; + } + + published.Dispose(); + } + + static void Restore(Action restore) + { + try + { + restore(); + } + catch (Exception ex) + { + s_thumbnailLogger.LogWarning(ex, "Failed to roll back a thumbnail publication."); + } + } + + try { - Stretch.Fill => Avalonia.Media.Stretch.Fill, - Stretch.Uniform => Avalonia.Media.Stretch.Uniform, - Stretch.UniformToFill => Avalonia.Media.Stretch.UniformToFill, - Stretch.None => Avalonia.Media.Stretch.None, - _ => Avalonia.Media.Stretch.Fill, - }; - _imageBrush.Source = _bitmap; + _imageBrush.Stretch = stretch switch + { + Stretch.Fill => Avalonia.Media.Stretch.Fill, + Stretch.Uniform => Avalonia.Media.Stretch.Uniform, + Stretch.UniformToFill => Avalonia.Media.Stretch.UniformToFill, + Stretch.None => Avalonia.Media.Stretch.None, + _ => Avalonia.Media.Stretch.Fill, + }; + + // Assigning Stretch can notify listeners that start a superseding update or a + // disposal, so the decision to commit has to be re-taken after it. + bool superseded; + lock (_gate) + { + superseded = token.IsCancellationRequested + || _disposeRequested + || !ReferenceEquals(_bitmap, published); + } + + if (superseded) + { + Rollback(); + return; + } + + _imageBrush.Source = published; + + // A listener can put the previous source back synchronously; that is a rejection. + if (!ReferenceEquals(_imageBrush.Source, published)) + { + Rollback(); + return; + } + } + catch (Exception ex) + { + s_thumbnailLogger.LogWarning(ex, "A thumbnail publication callback threw."); + Rollback(); + return; + } + previous?.Dispose(); }, DispatcherPriority.Background); - }, DispatchPriority.Low, token); + } } } } diff --git a/src/Beutl/Helpers/ExportSupersampling.cs b/src/Beutl/Helpers/ExportSupersampling.cs index 0d20a4adc0..183da9c6da 100644 --- a/src/Beutl/Helpers/ExportSupersampling.cs +++ b/src/Beutl/Helpers/ExportSupersampling.cs @@ -18,7 +18,7 @@ public static (long Width, long Height) GetRenderSize(PixelSize frameSize, int f /// Whether the supersampled surface fits the per-axis buffer limit on both axes. public static bool FitsBufferLimit( - PixelSize frameSize, int factor, int maxDimension = RenderNodeContext.MaxBufferDimension) + PixelSize frameSize, int factor, int maxDimension = RenderScaleUtilities.MaxBufferDimension) { (long width, long height) = GetRenderSize(frameSize, factor); return width <= maxDimension && height <= maxDimension; diff --git a/src/Beutl/ViewModels/Dialogs/SaveFrameDialogViewModel.cs b/src/Beutl/ViewModels/Dialogs/SaveFrameDialogViewModel.cs index 355e259086..1a905d6d4d 100644 --- a/src/Beutl/ViewModels/Dialogs/SaveFrameDialogViewModel.cs +++ b/src/Beutl/ViewModels/Dialogs/SaveFrameDialogViewModel.cs @@ -37,7 +37,7 @@ public SaveFrameDialogViewModel(PixelSize baseSize) (long width, long height) = SaveFrameScale.GetRenderSize(baseSize, scale); return string.Format( MessageStrings.SaveImageExceedsMaxRenderSize, - scale, width, height, RenderNodeContext.MaxBufferDimension); + scale, width, height, RenderScaleUtilities.MaxBufferDimension); }) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/EditViewModel.cs b/src/Beutl/ViewModels/EditViewModel.cs index f88be11ce6..0744eb9218 100644 --- a/src/Beutl/ViewModels/EditViewModel.cs +++ b/src/Beutl/ViewModels/EditViewModel.cs @@ -9,6 +9,7 @@ using Beutl.Editor; using Beutl.Editor.Observers; using Beutl.Editor.Operations; +using Beutl.Graphics.Rendering; using Beutl.Graphics.Rendering.Cache; using Beutl.Helpers; using Beutl.Logging; @@ -97,7 +98,7 @@ public EditViewModel(Scene scene, Beutl.Api.Services.ExtensionProvider extension .DistinctUntilChanged(); Renderer = frameSizeAndScale - .Select(t => new SceneRenderer(Scene, t.OutputScale, maxWorkingScale: WorkingScaleCeiling.Preview(t.OutputScale))) + .Select(t => new SceneRenderer(Scene, RenderIntent.Preview, t.OutputScale, maxWorkingScale: WorkingScaleCeiling.Preview(t.OutputScale))) .DisposePreviousValue() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables)!; diff --git a/src/Beutl/ViewModels/PlayerViewModel.cs b/src/Beutl/ViewModels/PlayerViewModel.cs index bf49e5cea1..7b1817ba5f 100644 --- a/src/Beutl/ViewModels/PlayerViewModel.cs +++ b/src/Beutl/ViewModels/PlayerViewModel.cs @@ -2074,24 +2074,39 @@ public async Task MeasureSelectedDrawable(Drawable drawable) return await RenderThread.Dispatcher.InvokeAsync(() => { if (Scene == null) throw new Exception("Scene is null."); - SceneRenderer renderer = EditViewModel.Renderer.Value; - var resource = drawable.ToResource(new CompositionContext(CurrentFrame.Value)); - PixelSize frameSize = renderer.FrameSize; + SceneRenderer sceneRenderer = EditViewModel.Renderer.Value; + PixelSize frameSize = sceneRenderer.FrameSize; + CompositionContext compositionContext = CreateSelectedDrawableCompositionContext( + CurrentFrame.Value, + frameSize); + using var resource = drawable.ToResource(compositionContext); using var root = new DrawableRenderNode(resource); using (var context = new GraphicsContext2D(root, frameSize.ToSize(1))) { drawable.Render(context, resource); } - var processor = new RenderNodeProcessor(root, false); - var bounds = Rect.Empty; - foreach (var op in processor.PullToRoot()) + var request = new RenderNodeRenderRequest { - bounds = bounds.Union(op.Bounds); - op.Dispose(); - } - - return PixelRect.FromRect(bounds).Size; + Intent = RenderIntent.Preview, + TargetDomain = compositionContext.TargetDomain, + OutputScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }; + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = request, + }); + RenderNodeRenderRequest measureRequest = request with + { + TargetDomain = ResolveSelectedDrawableDomain( + renderer, + request, + compositionContext.TargetDomain), + }; + return PixelRect.FromRect(GetSelectedDrawableRasterRegion(renderer.Measure(measureRequest))).Size; }); } @@ -2105,22 +2120,90 @@ public async Task DrawSelectedDrawable(Drawable drawable, float outputSc return await RenderThread.Dispatcher.InvokeAsync(() => { if (Scene == null) throw new Exception("Scene is null."); - // TODO: Rendererに特定のDrawableのみを描画するクラスを追加する - SceneRenderer renderer = EditViewModel.Renderer.Value; - var resource = drawable.ToResource(new CompositionContext(CurrentFrame.Value)); - PixelSize frameSize = renderer.FrameSize; + SceneRenderer sceneRenderer = EditViewModel.Renderer.Value; + PixelSize frameSize = sceneRenderer.FrameSize; + CompositionContext compositionContext = CreateSelectedDrawableCompositionContext( + CurrentFrame.Value, + frameSize); + using var resource = drawable.ToResource(compositionContext); using var root = new DrawableRenderNode(resource); - using (var context = new GraphicsContext2D(root, frameSize.ToSize(1))) + using (var context = new GraphicsContext2D(root, frameSize.ToSize(1), outputScale)) { drawable.Render(context, resource); } - var processor = new RenderNodeProcessor( - root, false, outputScale, WorkingScaleCeiling.Export()); - return processor.RasterizeAndConcat(); + var request = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = compositionContext.TargetDomain, + OutputScale = outputScale, + MaxWorkingScale = WorkingScaleCeiling.Export(), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }; + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = request, + }); + RenderNodeRenderRequest exportRequest = request with + { + TargetDomain = ResolveSelectedDrawableDomain( + renderer, + request, + compositionContext.TargetDomain), + }; + Rect outputBounds = GetSelectedDrawableRasterRegion(renderer.Measure(exportRequest)); + using RenderNodeRasterization rasterization = renderer.Rasterize(exportRequest with + { + RequestedRegion = outputBounds, + }); + return rasterization.Bitmap?.Clone() + ?? throw new InvalidOperationException("The selected drawable produced no raster output."); }); } + internal static Rect GetSelectedDrawableRasterRegion(RenderNodeMeasurement measurement) + => measurement.OutputBounds; + + /// + /// The target domain a selected-drawable export renders against: wide enough to own a fragment that + /// resolves its region from the target, but never narrower than the drawable itself. + /// + /// + /// A request's target domain is a hard output clip, so exporting against the scene frame cropped an + /// element hanging over the edge and produced nothing at all for one entirely outside it - neither of + /// which is what "save this element as an image" means. Measuring without a domain first asks the + /// drawable how much room it actually takes. A subtree that genuinely needs an owning domain says so by + /// throwing, and keeps the frame. + /// + internal static Rect? ResolveSelectedDrawableDomain( + RenderNodeRenderer renderer, + RenderNodeRenderRequest request, + Rect? frameDomain) + { + try + { + Rect measured = renderer.Measure(request with { TargetDomain = null }).OutputBounds; + if (measured.Width <= 0 || measured.Height <= 0) + return frameDomain; + + return frameDomain is { } frame ? frame.Union(measured) : measured; + } + catch (RenderTargetDomainRequiredException) + { + return frameDomain; + } + } + + internal static CompositionContext CreateSelectedDrawableCompositionContext( + TimeSpan frame, + PixelSize frameSize) + => new(frame) + { + TargetDomain = new Rect(default, frameSize.ToSize(1)), + }; + /// /// Renders the current frame at full scale on a throwaway renderer, ignoring preview quality. /// @@ -2155,14 +2238,7 @@ public async Task DrawFrameAtScale(float outputScale) missingSources.Count)); } - // Throwaway renderer with disableResourceShare to avoid mutating live preview resources. - using var renderer = new SceneRenderer( - Scene, - renderScale: outputScale, - disableResourceShare: true, - maxWorkingScale: WorkingScaleCeiling.Export(), - forceOriginalSource: true); - renderer.CacheOptions = RenderCacheOptions.Disabled; + using var renderer = ExportRendererFactory.Create(Scene, outputScale); var compositionFrame = renderer.Compositor.EvaluateGraphics(CurrentFrame.Value); renderer.Render(compositionFrame); diff --git a/src/Beutl/ViewModels/Tools/OutputViewModel.cs b/src/Beutl/ViewModels/Tools/OutputViewModel.cs index 315210a4eb..d69ffa3850 100644 --- a/src/Beutl/ViewModels/Tools/OutputViewModel.cs +++ b/src/Beutl/ViewModels/Tools/OutputViewModel.cs @@ -7,7 +7,6 @@ using Beutl.Editor; using Beutl.Editor.Services; using Beutl.Graphics.Rendering; -using Beutl.Graphics.Rendering.Cache; using Beutl.Helpers; using Beutl.Logging; using Beutl.Media; @@ -93,7 +92,7 @@ public OutputViewModel(EditViewModel editViewModel) (long width, long height) = ExportSupersampling.GetRenderSize(frameSize, factor); return string.Format( MessageStrings.SupersamplingExceedsMaxRenderSize, - Math.Max(1, factor), width, height, RenderNodeContext.MaxBufferDimension); + Math.Max(1, factor), width, height, RenderScaleUtilities.MaxBufferDimension); }) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposable); @@ -318,14 +317,7 @@ await Task.Run(async () => ClearEditViewModelCaches(); float renderScale = Math.Max(1, SupersampleFactor.Value); - float maxWorkingScale = WorkingScaleCeiling.Export(); - using var renderer = new SceneRenderer( - Model, - renderScale, - disableResourceShare: true, - maxWorkingScale, - forceOriginalSource: true); - renderer.CacheOptions = RenderCacheOptions.Disabled; + using var renderer = ExportRendererFactory.Create(Model, renderScale); var frameProgress = new Subject(); using var frameProvider = new FrameProviderImpl(Model, videoSettings.FrameRate, renderer, frameProgress); using var composer = new SceneComposer(Model, disableResourceShare: true, forceOriginalSource: true) diff --git a/tests/Beutl.AgentToolkit.Tests/Schema/SchemaGenerationTests.cs b/tests/Beutl.AgentToolkit.Tests/Schema/SchemaGenerationTests.cs index 6cd7fdd2f5..1d8577bf76 100644 --- a/tests/Beutl.AgentToolkit.Tests/Schema/SchemaGenerationTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Schema/SchemaGenerationTests.cs @@ -369,8 +369,9 @@ public void Organic_shader_recipe_modulates_source_color_and_compiles() }); using SKSLShader shader = CompileSkslOrSkip(script); + using SKSLShaderBuilder builder = shader.CreateBuilder(); - Assert.That(shader.Effect.Children.Contains("src"), Is.True); + Assert.That(builder.Children.Contains("src"), Is.True); } [Test] @@ -383,6 +384,7 @@ public void Single_sksl_effect_recipe_uses_neutral_pass_through_scaffold() string script = FindRequiredStringProperty(scaffoldRecipe.Patch, nameof(SKSLScriptEffect.Script)); using SKSLShader shader = CompileSkslOrSkip(script); + using SKSLShaderBuilder builder = shader.CreateBuilder(); Assert.Multiple(() => { @@ -390,7 +392,7 @@ public void Single_sksl_effect_recipe_uses_neutral_pass_through_scaffold() Assert.That(scaffold, Does.Not.Contain("sin(uv.x * 14.0")); Assert.That(script, Does.Contain("src.eval(fragCoord)")); Assert.That(script, Does.Not.Contain("sin(uv.x * 14.0")); - Assert.That(shader.Effect.Children.Contains("src"), Is.True); + Assert.That(builder.Children.Contains("src"), Is.True); Assert.That(scaffoldRecipe.Description, Does.Contain("blank pass-through scaffold")); Assert.That(scaffoldRecipe.Description, Does.Contain("organic-shader-field")); Assert.That(scaffoldRecipe.Description, Does.Contain("fine-film-grain-field")); diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/ReadDocumentTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/ReadDocumentTests.cs index 961b16abc9..6adc7eb2da 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/ReadDocumentTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/ReadDocumentTests.cs @@ -62,6 +62,58 @@ public void Get_started_returns_low_context_entrypoints() }); } + /// + /// A request's target domain is a hard clip on OutputBounds, and only on OutputBounds. This measurement + /// reads QueryBounds, which the domain never touches, so an off-frame drawable reports where it actually + /// is - the case an agent most needs, since it is how it works out how far to move the object back. + /// + [Test] + public void Measure_object_bounds_reports_an_off_frame_drawable_where_it_actually_is() + { + string dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var scene = new Scene(320, 180, "off-frame-measure") + { + Duration = TimeSpan.FromSeconds(2), + Uri = new Uri(Path.Combine(dir, "Scene.scene")) + }; + var plate = new RectShape + { + Name = "OffFrame", + Width = { CurrentValue = 40 }, + Height = { CurrentValue = 20 }, + Fill = { CurrentValue = Brushes.White }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(600, 400) }, + }; + var element = new Element + { + Length = TimeSpan.FromSeconds(2), + Uri = new Uri(Path.Combine(dir, "element.belm")) + }; + element.AddObject(plate); + scene.Children.Add(element); + using var session = new AgentToolkitTestSession(scene); + var manager = new AgentSessionManager(); + manager.UseSource(new AgentToolkitTestSessionSource(session)); + var tools = new QueryTools(manager); + + ToolResult result = tools.MeasureObjectBounds( + objectId: plate.Id.ToString(), + timeSeconds: 0.0); + + Assert.That(result.IsSuccess, Is.True, result.Error?.Message); + ObjectBoundsMeasurement measured = result.Value!.Objects.Single(); + Assert.Multiple(() => + { + Assert.That(measured.TransformedBounds.Width, Is.EqualTo(40).Within(0.5)); + Assert.That(measured.TransformedBounds.Height, Is.EqualTo(20).Within(0.5)); + Assert.That(measured.TransformedBounds.Left, Is.EqualTo(600).Within(0.5)); + Assert.That(measured.TransformedBounds.Top, Is.EqualTo(400).Within(0.5)); + }); + } + [Test] public void Measure_object_bounds_reports_a_nested_drawable_as_unsupported_even_with_a_time_filter() { @@ -847,14 +899,14 @@ public void Measure_object_bounds_reports_center_aligned_scene_bounds() Assert.That(all.Value.FrameCenter.X, Is.EqualTo(960)); Assert.That(all.Value.FrameCenter.Y, Is.EqualTo(540)); Assert.That(all.Value.TimeFiltered, Is.True); - Assert.That(titleBounds.MeasurementKind, Is.EqualTo("render-node-operation-bounds")); + Assert.That(titleBounds.MeasurementKind, Is.EqualTo("render-node-query-bounds")); Assert.That(titleBounds.LocalBounds.Width, Is.GreaterThan(0)); Assert.That(titleBounds.LocalBounds.Height, Is.GreaterThan(0)); Assert.That(titleBounds.TransformedBounds.Left, Is.LessThan(960)); Assert.That(titleBounds.TransformedBounds.Right, Is.GreaterThan(960)); Assert.That(titleBounds.TransformedBounds.Top, Is.LessThan(540)); Assert.That(titleBounds.TransformedBounds.Bottom, Is.GreaterThan(540)); - Assert.That(plateBounds.MeasurementKind, Is.EqualTo("render-node-operation-bounds")); + Assert.That(plateBounds.MeasurementKind, Is.EqualTo("render-node-query-bounds")); Assert.That(plateBounds.LocalBounds.Width, Is.EqualTo(200).Within(0.01)); Assert.That(plateBounds.LocalBounds.Height, Is.EqualTo(80).Within(0.01)); Assert.That(plateBounds.UserTranslate!.X, Is.EqualTo(120).Within(0.01)); @@ -913,7 +965,7 @@ public void Measure_object_bounds_includes_filter_effect_render_node_bounds() Assert.Multiple(() => { Assert.That(result.IsSuccess, Is.True, result.Error?.Message); - Assert.That(bounds.MeasurementKind, Is.EqualTo("render-node-operation-bounds")); + Assert.That(bounds.MeasurementKind, Is.EqualTo("render-node-query-bounds")); Assert.That(bounds.TransformedBounds.Left, Is.EqualTo(50).Within(0.01)); Assert.That(bounds.TransformedBounds.Top, Is.LessThan(75)); Assert.That(bounds.TransformedBounds.Right, Is.GreaterThan(150)); diff --git a/tests/Beutl.Benchmarks/Program.cs b/tests/Beutl.Benchmarks/Program.cs index d418330a64..da0a7b49ff 100644 --- a/tests/Beutl.Benchmarks/Program.cs +++ b/tests/Beutl.Benchmarks/Program.cs @@ -4,3 +4,4 @@ // Select a benchmark via `-- --filter `; no args shows an interactive picker. BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args); +return 0; diff --git a/tests/Beutl.Benchmarks/Properties/AssemblyInfo.cs b/tests/Beutl.Benchmarks/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..429147bcc1 --- /dev/null +++ b/tests/Beutl.Benchmarks/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Beutl.UnitTests")] diff --git a/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs new file mode 100644 index 0000000000..24da639cda --- /dev/null +++ b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkConfig.cs @@ -0,0 +1,88 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; + +namespace Beutl.Benchmarks.Rendering; + +/// +/// Shared BenchmarkDotNet policy for paired persistent-lifetime render-pipeline measurements. +/// Renderer warm-up and output/counter verification remain GlobalSetup responsibilities of the benchmark class. +/// +internal sealed class RenderPipelineBenchmarkConfig : ManualConfig +{ + public const string ArtifactsPathEnvironmentVariable = "BEUTL_RENDER_BENCHMARK_ARTIFACTS"; + + public const string CountersPathEnvironmentVariable = "BEUTL_RENDER_BENCHMARK_COUNTERS"; + + + public const int SetupWarmupFrameCount = 5; + + public const int BenchmarkWarmupCount = 3; + + public const int BenchmarkIterationCount = 15; + + public const int BenchmarkLaunchCount = 1; + + public const int BenchmarkInvocationCount = 1; + + public const int BenchmarkUnrollFactor = 1; + + public const string BenchmarkJobId = "RenderPipeline"; + + public static string ExpectedJobDisplay => + $"{BenchmarkJobId}(InvocationCount={BenchmarkInvocationCount}, " + + $"IterationCount={BenchmarkIterationCount}, LaunchCount={BenchmarkLaunchCount}, " + + $"RunStrategy={RunStrategy.Monitoring}, UnrollFactor={BenchmarkUnrollFactor}, " + + $"WarmupCount={BenchmarkWarmupCount})"; + + public const string LifetimeContract = + "persistent-root-pipeline-and-version-available-structural-program-render-cache-target-pool-state"; + + public const string RequestShapeContract = "complete-target-surface-request-with-rgba16f-readback"; + + public RenderPipelineBenchmarkConfig() + { + AddJob(Job.Default + .WithId(BenchmarkJobId) + .WithStrategy(RunStrategy.Monitoring) + .WithLaunchCount(BenchmarkLaunchCount) + .WithWarmupCount(BenchmarkWarmupCount) + .WithIterationCount(BenchmarkIterationCount) + .WithInvocationCount(BenchmarkInvocationCount) + .WithUnrollFactor(BenchmarkUnrollFactor)); + AddDiagnoser(MemoryDiagnoser.Default); + AddColumnProvider(DefaultColumnProviders.Instance); + AddLogger(ConsoleLogger.Default); + AddExporter(JsonExporter.Full); + + string? artifactsPath = Environment.GetEnvironmentVariable(ArtifactsPathEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(artifactsPath)) + { + ArtifactsPath = Path.GetFullPath(artifactsPath); + } + + string? countersPath = Environment.GetEnvironmentVariable(CountersPathEnvironmentVariable); + if (string.IsNullOrWhiteSpace(countersPath)) + { + string root = !string.IsNullOrWhiteSpace(artifactsPath) + ? Path.GetFullPath(artifactsPath) + : Path.GetFullPath("BenchmarkDotNet.Artifacts"); + Environment.SetEnvironmentVariable( + CountersPathEnvironmentVariable, + Path.Combine(root, "render-pipeline-counters")); + } + } + + public static string GetCountersPath() + { + string? value = Environment.GetEnvironmentVariable(CountersPathEnvironmentVariable); + return !string.IsNullOrWhiteSpace(value) + ? Path.GetFullPath(value) + : throw new InvalidOperationException( + $"{CountersPathEnvironmentVariable} was not initialized by the benchmark configuration."); + } +} diff --git a/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkScenes.cs b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkScenes.cs new file mode 100644 index 0000000000..108fe9520c --- /dev/null +++ b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarkScenes.cs @@ -0,0 +1,331 @@ +using System.Collections.ObjectModel; + +using Beutl.Graphics; +using Beutl.Media; + +namespace Beutl.Benchmarks.Rendering; + +internal enum RenderPipelineBenchmarkAnimation +{ + None, + ParameterOnly, + StructuralToggle, +} + +internal enum RenderPipelineBenchmarkBarrier +{ + None, + WholeSourceShader, + SpatialEffect, + CustomEffect, + TargetDependency, +} + +internal enum RenderPipelineBenchmarkLayout +{ + TargetDomain, + CenteredContent, + DrawableGrid, +} + +internal readonly record struct RenderPipelineBenchmarkFrameState( + float AnimatedAmount, + bool StructuralVariant); + +internal sealed record RenderPipelineBenchmarkSceneDefinition +{ + public RenderPipelineBenchmarkSceneDefinition( + string name, + int seed, + int semanticStageCount, + int topLevelDrawableCount = 1, + float contentScale = 0.8f, + RenderPipelineBenchmarkAnimation animation = RenderPipelineBenchmarkAnimation.None, + RenderPipelineBenchmarkBarrier barrier = RenderPipelineBenchmarkBarrier.None, + bool hasStaticPrefixCache = false, + bool hasTargetDependencies = false, + RenderPipelineBenchmarkLayout layout = RenderPipelineBenchmarkLayout.TargetDomain) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentOutOfRangeException.ThrowIfNegative(semanticStageCount); + ArgumentOutOfRangeException.ThrowIfLessThan(topLevelDrawableCount, 1); + if (!float.IsFinite(contentScale) || contentScale is <= 0 or > 1) + { + throw new ArgumentOutOfRangeException( + nameof(contentScale), contentScale, "Content scale must be finite and in the range (0, 1]."); + } + + Name = name; + Seed = seed; + SemanticStageCount = semanticStageCount; + TopLevelDrawableCount = topLevelDrawableCount; + ContentScale = contentScale; + Animation = animation; + Barrier = barrier; + HasStaticPrefixCache = hasStaticPrefixCache; + HasTargetDependencies = hasTargetDependencies; + Layout = layout; + } + + public string Name { get; } + + public int Seed { get; } + + public int SemanticStageCount { get; } + + public int TopLevelDrawableCount { get; } + + public float ContentScale { get; } + + public RenderPipelineBenchmarkAnimation Animation { get; } + + public RenderPipelineBenchmarkBarrier Barrier { get; } + + public bool HasStaticPrefixCache { get; } + + public bool HasTargetDependencies { get; } + + public RenderPipelineBenchmarkLayout Layout { get; } + + public RenderPipelineBenchmarkFrameState GetFrameState(int frameIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(frameIndex); + + return Animation switch + { + RenderPipelineBenchmarkAnimation.ParameterOnly => new( + AnimatedAmount: 0.75f + ((frameIndex % 60) / 59f * 0.5f), + StructuralVariant: false), + RenderPipelineBenchmarkAnimation.StructuralToggle => new( + AnimatedAmount: 1f, + StructuralVariant: ((frameIndex / 8) & 1) != 0), + _ => new(AnimatedAmount: 1f, StructuralVariant: false), + }; + } +} + +/// +/// Stable workload metadata and source pixels shared by the baseline and feature render-pipeline benchmarks. +/// Scene construction belongs in the benchmark harness so both worktrees consume these exact definitions. +/// +internal static class RenderPipelineBenchmarkScenes +{ + public const int SourceSeed = 20_040_719; + + public const int DrawableGridMargin = 12; + + public static readonly PixelSize ReferenceSize = new(384, 216); + + public static readonly Rect TargetDomain = new(0, 0, ReferenceSize.Width, ReferenceSize.Height); + + public static readonly PixelSize Hd1080 = new(1920, 1080); + + private static readonly RenderPipelineBenchmarkSceneDefinition[] s_all = + [ + new("NoEffectControl", SourceSeed + 0, semanticStageCount: 0), + new("SingleShader", SourceSeed + 1, semanticStageCount: 1), + new("ShaderOpacityShader", SourceSeed + 2, semanticStageCount: 3), + new( + "ShaderOpacityShaderBarrier", + SourceSeed + 3, + semanticStageCount: 4, + barrier: RenderPipelineBenchmarkBarrier.WholeSourceShader), + new("LongInvariantChain", SourceSeed + 4, semanticStageCount: 10), + new( + "ParameterOnlyAnimation", + SourceSeed + 5, + semanticStageCount: 3, + animation: RenderPipelineBenchmarkAnimation.ParameterOnly), + new( + "StructuralToggle", + SourceSeed + 6, + semanticStageCount: 3, + animation: RenderPipelineBenchmarkAnimation.StructuralToggle), + new( + "StaticPrefixAnimatedTail", + SourceSeed + 7, + semanticStageCount: 6, + animation: RenderPipelineBenchmarkAnimation.ParameterOnly, + hasStaticPrefixCache: true), + new( + "StaticSpatialPrefixAnimatedBlurTail", + SourceSeed + 20, + semanticStageCount: 2, + animation: RenderPipelineBenchmarkAnimation.ParameterOnly, + barrier: RenderPipelineBenchmarkBarrier.SpatialEffect, + hasStaticPrefixCache: true), + // Spatial stages leave the fusible shader path. These workloads share one source so topology, + // CustomEffect boundaries, and mixed-segment costs can be compared without input noise. + new( + "SpatialGroupChain", + SourceSeed + 20, + semanticStageCount: 3, + barrier: RenderPipelineBenchmarkBarrier.SpatialEffect), + new( + "SpatialNodeChain", + SourceSeed + 20, + semanticStageCount: 3, + barrier: RenderPipelineBenchmarkBarrier.SpatialEffect), + new( + "LayerCustomEffect", + SourceSeed + 20, + semanticStageCount: 1, + barrier: RenderPipelineBenchmarkBarrier.CustomEffect), + new( + "BlurCustomBlur", + SourceSeed + 20, + semanticStageCount: 3, + barrier: RenderPipelineBenchmarkBarrier.CustomEffect), + new( + "MixedSpatialColor", + SourceSeed + 8, + semanticStageCount: 5, + barrier: RenderPipelineBenchmarkBarrier.SpatialEffect), + new( + "SmallObjectFixedOverhead", + SourceSeed + 9, + semanticStageCount: 3, + contentScale: 0.1f, + layout: RenderPipelineBenchmarkLayout.CenteredContent), + new( + "MultipleDrawablesTargetDependencies", + SourceSeed + 10, + semanticStageCount: 4, + topLevelDrawableCount: 4, + barrier: RenderPipelineBenchmarkBarrier.TargetDependency, + hasTargetDependencies: true, + layout: RenderPipelineBenchmarkLayout.DrawableGrid), + ]; + + private static readonly IReadOnlyDictionary s_byName + = new ReadOnlyDictionary( + s_all.ToDictionary(static scene => scene.Name, StringComparer.Ordinal)); + + public static IReadOnlyList All { get; } = Array.AsReadOnly(s_all); + + public static RenderPipelineBenchmarkSceneDefinition Get(string name) + { + ArgumentNullException.ThrowIfNull(name); + return s_byName.TryGetValue(name, out RenderPipelineBenchmarkSceneDefinition? scene) + ? scene + : throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown render-pipeline benchmark scene."); + } + + /// + /// Bounds of one of a scene's top-level drawables, in the target domain. + /// + /// + /// The benchmark harness draws from this and the frozen-workload gate checks recorded counters + /// against it, so the two cannot drift into disagreeing about a scene's rendered extent. + /// + public static Rect GetDrawableBounds(RenderPipelineBenchmarkSceneDefinition scene, int index) + { + ArgumentNullException.ThrowIfNull(scene); + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, scene.TopLevelDrawableCount); + + switch (scene.Layout) + { + case RenderPipelineBenchmarkLayout.CenteredContent: + int contentWidth = Math.Max(1, (int)MathF.Round(TargetDomain.Width * scene.ContentScale)); + int contentHeight = Math.Max(1, (int)MathF.Round(TargetDomain.Height * scene.ContentScale)); + return new Rect( + MathF.Floor((TargetDomain.Width - contentWidth) / 2), + MathF.Floor((TargetDomain.Height - contentHeight) / 2), + contentWidth, + contentHeight); + case RenderPipelineBenchmarkLayout.DrawableGrid: + int cellWidth = (ReferenceSize.Width - (DrawableGridMargin * 3)) / 2; + int cellHeight = (ReferenceSize.Height - (DrawableGridMargin * 3)) / 2; + return new Rect( + DrawableGridMargin + ((index & 1) * (cellWidth + DrawableGridMargin)), + DrawableGridMargin + ((index >> 1) * (cellHeight + DrawableGridMargin)), + cellWidth, + cellHeight); + default: + return TargetDomain; + } + } + + /// + /// Device extent a scene rasterizes to, which is the union of its top-level drawables. + /// + public static PixelSize GetOutputSize(RenderPipelineBenchmarkSceneDefinition scene) + { + ArgumentNullException.ThrowIfNull(scene); + Rect union = GetDrawableBounds(scene, 0); + for (int index = 1; index < scene.TopLevelDrawableCount; index++) + union = union.Union(GetDrawableBounds(scene, index)); + return new PixelSize((int)union.Width, (int)union.Height); + } + + public static Half[] CreateLinearPremultipliedRgba16F( + RenderPipelineBenchmarkSceneDefinition scene, + PixelSize size) + { + ArgumentNullException.ThrowIfNull(scene); + int length = GetRequiredComponentCount(size); + var result = new Half[length]; + FillLinearPremultipliedRgba16F(result, scene, size); + return result; + } + + public static void FillLinearPremultipliedRgba16F( + Span destination, + RenderPipelineBenchmarkSceneDefinition scene, + PixelSize size) + { + ArgumentNullException.ThrowIfNull(scene); + int requiredLength = GetRequiredComponentCount(size); + if (destination.Length != requiredLength) + { + throw new ArgumentException( + $"Destination must contain exactly {requiredLength} RGBA components.", nameof(destination)); + } + + int index = 0; + for (int y = 0; y < size.Height; y++) + { + for (int x = 0; x < size.Width; x++) + { + uint sample = MixPixel(scene.Seed, x, y); + float alpha = (96 + ((sample >> 24) & 0x8f)) / 255f; + float red = ((sample >> 16) & 0xff) / 255f * alpha; + float green = ((sample >> 8) & 0xff) / 255f * alpha; + float blue = (sample & 0xff) / 255f * alpha; + + destination[index++] = (Half)red; + destination[index++] = (Half)green; + destination[index++] = (Half)blue; + destination[index++] = (Half)alpha; + } + } + } + + private static int GetRequiredComponentCount(PixelSize size) + { + if (size.Width <= 0 || size.Height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size), size, "Benchmark dimensions must be positive."); + } + + return checked(size.Width * size.Height * 4); + } + + private static uint MixPixel(int seed, int x, int y) + { + unchecked + { + uint value = (uint)seed; + value ^= (uint)(x / 16) * 0x9e37_79b9u; + value ^= (uint)(y / 16) * 0x85eb_ca6bu; + value ^= (uint)x * 0xc2b2_ae35u; + value ^= (uint)y * 0x27d4_eb2fu; + value ^= value >> 16; + value *= 0x7feb_352du; + value ^= value >> 15; + value *= 0x846c_a68bu; + return value ^ (value >> 16); + } + } +} diff --git a/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs new file mode 100644 index 0000000000..e5a92c98e6 --- /dev/null +++ b/tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs @@ -0,0 +1,1117 @@ +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +using BenchmarkDotNet.Attributes; + +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +using Silk.NET.Vulkan; + +using SkiaSharp; + +using Bitmap = Beutl.Media.Bitmap; + +namespace Beutl.Benchmarks.Rendering; + +/// +/// Complete-request render-pipeline workloads with renderer, node, program-cache, render-cache, and target-pool +/// lifetimes that persist across setup, warm-up, and measured iterations. +/// +[Config(typeof(RenderPipelineBenchmarkConfig))] +public class RenderPipelineBenchmarks +{ + private RenderPipelineBenchmarkSession? _session; + + public static IEnumerable SceneNames + => RenderPipelineBenchmarkScenes.All.Select(static scene => scene.Name); + + [ParamsSource(nameof(SceneNames))] + public string CaseName { get; set; } = string.Empty; + + [GlobalSetup] + public void Setup() + { + _session = RenderThread.Dispatcher.Invoke(() => new RenderPipelineBenchmarkSession(CaseName)); + RenderThread.Dispatcher.Invoke(_session.WarmAndVerify); + } + + /// + /// Renders one complete requested surface. Output validation lives in , and request-wide + /// counters and full-image hashing come from cleanup, so the measured body contains only production frame-state + /// update, render, readback, cheap token sampling, and steady-state disposal of the preceding result. + /// + [Benchmark] + public ulong RenderCompleteTargetRequest() + { + RenderPipelineBenchmarkSession session = _session + ?? throw new InvalidOperationException("Benchmark setup did not create a render session."); + return RenderThread.Dispatcher.Invoke(session.RenderMeasuredFrame); + } + + [GlobalCleanup] + public void Cleanup() + { + RenderPipelineBenchmarkSession? session = Interlocked.Exchange(ref _session, null); + if (session is null) + return; + + RenderPipelineBenchmarkCounterRecord record = RenderThread.Dispatcher.Invoke(() => + { + try + { + return session.CreateCounterRecord(); + } + finally + { + session.Dispose(); + } + }); + + string directory = RenderPipelineBenchmarkConfig.GetCountersPath(); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, CaseName + ".json"); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + JsonSerializer.Serialize(stream, record, RenderPipelineBenchmarkCounterRecord.JsonOptions); + stream.WriteByte((byte)'\n'); + } +} + +internal sealed class RenderPipelineBenchmarkSession : IDisposable +{ + private static readonly Rect s_targetDomain = RenderPipelineBenchmarkScenes.TargetDomain; + + private readonly RenderPipelineBenchmarkSceneDefinition _scene; + private readonly RenderNode _root; + private readonly RenderNodeRenderer _renderer; + private readonly IReadOnlyList _animatedNodes; + private readonly IReadOnlyList _sceneResources; + private int _nextFrame; + private RenderPipelineObservedFrame? _lastSetupFrame; + private RenderNodeRasterization? _lastSetupRasterization; + private int _lastSetupFrameIndex = -1; + private RenderNodeRasterization? _lastMeasuredRasterization; + private int _lastMeasuredFrameIndex = -1; + private ulong _lastMeasuredToken; + private bool _disposed; + + public RenderPipelineBenchmarkSession(string caseName) + { + RenderThread.Dispatcher.VerifyAccess(); + _scene = RenderPipelineBenchmarkScenes.Get(caseName); + _ = GraphicsContextFactory.GetOrCreateShared() + ?? throw new InvalidOperationException( + "A real graphics context is required for render-pipeline benchmarks."); + + var animatedNodes = new List(); + var sceneResources = new List(); + _root = CreateScene(_scene, animatedNodes, sceneResources); + _animatedNodes = animatedNodes.AsReadOnly(); + _sceneResources = sceneResources.AsReadOnly(); + RenderNodeRendererOptions options = CreateRendererOptions(_scene); + RenderPipelineInternalDiagnostics.SetPurpose(options, RenderRequestPurpose.Frame); + _renderer = new RenderNodeRenderer(_root, options); + } + + public void WarmAndVerify() + { + ThrowIfDisposed(); + RenderThread.Dispatcher.VerifyAccess(); + + (int Frame, bool RetainRasterization)[] plan = GetSetupRenderPlan(_scene); + var observed = new List(plan.Length); + foreach ((int frame, bool retainRasterization) in plan) + { + observed.Add(RenderAndObserve( + frame, + retainRasterization)); + } + + RenderPipelineObservedFrame first = observed[0]; + if (first.IsEmpty || first.Width <= 0 || first.Height <= 0 + || !double.IsFinite(first.Energy) || first.Energy <= 1) + { + throw new InvalidOperationException( + $"Benchmark scene '{_scene.Name}' produced an empty, non-finite, or vacuous setup output."); + } + + if (observed.Any(item => item.IsEmpty + || item.Bounds != first.Bounds + || item.Width != first.Width + || item.Height != first.Height)) + { + throw new InvalidOperationException( + $"Benchmark scene '{_scene.Name}' did not preserve stable setup bounds and device dimensions."); + } + + int distinctOutputs = observed.Select(static item => item.Sha256).Distinct(StringComparer.Ordinal).Count(); + bool expectsAnimation = _scene.Animation != RenderPipelineBenchmarkAnimation.None; + if ((expectsAnimation && distinctOutputs < 2) || (!expectsAnimation && distinctOutputs != 1)) + { + throw new InvalidOperationException( + $"Benchmark scene '{_scene.Name}' output stability did not match its declared animation mode."); + } + + _lastSetupFrame = observed[^1]; + _nextFrame = checked(plan[^1].Frame + 1); + } + + public ulong RenderMeasuredFrame() + { + ThrowIfDisposed(); + RenderThread.Dispatcher.VerifyAccess(); + int frameIndex = _nextFrame++; + ApplyFrameState(frameIndex); + _lastMeasuredRasterization?.Dispose(); + RenderNodeRasterization rasterization = _renderer.Rasterize(); + _lastMeasuredRasterization = rasterization; + Bitmap? bitmap = rasterization.Bitmap; + _lastMeasuredFrameIndex = frameIndex; + _lastMeasuredToken = bitmap is null ? 0 : SampleToken(bitmap.GetPixelSpan()); + return _lastMeasuredToken; + } + + public RenderPipelineBenchmarkCounterRecord CreateCounterRecord() + { + ThrowIfDisposed(); + RenderThread.Dispatcher.VerifyAccess(); + RenderPipelineObservedFrame setup = _lastSetupFrame + ?? throw new InvalidOperationException("Benchmark setup verification did not complete."); + if (_lastMeasuredFrameIndex < 0) + throw new InvalidOperationException("Benchmark completed without a measured request."); + RenderPipelineObservedFrame measured = Observe( + _lastMeasuredRasterization + ?? throw new InvalidOperationException("The final measured output was not retained for cleanup.")); + using var diagnosticSession = new DiagnosticSession(_scene); + DiagnosticCapture diagnostics = diagnosticSession.Capture(_nextFrame); + AssertMatchingDiagnosticOutput(setup, diagnostics.SetupOutput); + AssertMatchingDiagnosticOutput(measured, diagnostics.MeasuredOutput); + using var expectationSession = new DiagnosticSession(_scene); + DiagnosticCapture expectation = expectationSession.Capture(_nextFrame); + AssertMatchingDiagnosticOutput(diagnostics.MeasuredOutput, expectation.MeasuredOutput); + + return new RenderPipelineBenchmarkCounterRecord + { + SchemaVersion = 3, + CaseName = _scene.Name, + Seed = _scene.Seed, + Width = setup.Width, + Height = setup.Height, + SetupWarmupFrames = RenderPipelineBenchmarkConfig.SetupWarmupFrameCount, + Lifetime = RenderPipelineBenchmarkConfig.LifetimeContract, + RequestShape = RenderPipelineBenchmarkConfig.RequestShapeContract, + SemanticStageCount = _scene.SemanticStageCount, + TopLevelDrawableCount = _scene.TopLevelDrawableCount, + Animation = _scene.Animation.ToString(), + Barrier = _scene.Barrier.ToString(), + HasStaticPrefixCache = _scene.HasStaticPrefixCache, + HasTargetDependencies = _scene.HasTargetDependencies, + OutputSha256 = setup.Sha256, + OutputChecksum = setup.Checksum.ToString("x16"), + OutputBounds = setup.Bounds, + MeasuredOutputSha256 = measured.Sha256, + MeasuredOutputChecksum = measured.Checksum.ToString("x16"), + MeasuredOutputBounds = measured.Bounds, + MeasuredWidth = measured.Width, + MeasuredHeight = measured.Height, + ExpectedMeasuredOutputSha256 = expectation.MeasuredOutput.Sha256, + ExpectedMeasuredOutputChecksum = expectation.MeasuredOutput.Checksum.ToString("x16"), + ExpectedMeasuredOutputBounds = expectation.MeasuredOutput.Bounds, + ExpectedMeasuredWidth = expectation.MeasuredOutput.Width, + ExpectedMeasuredHeight = expectation.MeasuredOutput.Height, + SetupLastRequestCounters = diagnostics.SetupCounters, + MeasuredLastRequestCounters = diagnostics.MeasuredCounters, + LastExecutionStatistics = diagnostics.LastExecutionStatistics, + StructuralPlanCacheStatistics = diagnostics.StructuralPlanCacheStatistics, + ProgramCacheStatistics = diagnostics.ProgramCacheStatistics, + TargetPoolStatistics = diagnostics.TargetPoolStatistics, + }; + } + + public void Dispose() + { + if (_disposed) + return; + + RenderThread.Dispatcher.VerifyAccess(); + _lastMeasuredRasterization?.Dispose(); + _lastMeasuredRasterization = null; + _lastSetupRasterization?.Dispose(); + _lastSetupRasterization = null; + _renderer.Dispose(); + _root.Dispose(); + for (int index = _sceneResources.Count - 1; index >= 0; index--) + _sceneResources[index].Dispose(); + _disposed = true; + } + + private RenderPipelineObservedFrame RenderAndObserve(int frameIndex, bool retainRasterization) + { + ApplyFrameState(frameIndex); + RenderNodeRasterization? rasterization = _renderer.Rasterize(); + try + { + RenderPipelineObservedFrame observed = Observe(rasterization); + if (retainRasterization) + { + _lastSetupRasterization?.Dispose(); + _lastSetupRasterization = rasterization; + _lastSetupFrameIndex = frameIndex; + rasterization = null; + } + return observed; + } + finally + { + rasterization?.Dispose(); + } + } + + private static RenderPipelineObservedFrame Observe(RenderNodeRasterization rasterization) + { + Bitmap? bitmap = rasterization.Bitmap; + if (bitmap is null) + { + return new RenderPipelineObservedFrame( + true, + rasterization.Bounds, + 0, + 0, + 0, + 0, + string.Empty, + 0); + } + + Span bytes = bitmap.GetPixelSpan(); + string sha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + Span components = bitmap.GetPixelSpan(); + ulong token = SampleToken(components); + ulong checksum = CalculateChecksum(components); + double energy = 0; + for (int index = 0; index < components.Length; index += 17) + energy += Math.Abs((float)BitConverter.UInt16BitsToHalf(components[index])); + + return new RenderPipelineObservedFrame( + false, + rasterization.Bounds, + bitmap.Width, + bitmap.Height, + token, + checksum, + sha256, + energy); + } + + private void ApplyFrameState(int frameIndex) + { + RenderPipelineBenchmarkFrameState state = _scene.GetFrameState(frameIndex); + foreach (IFrameStateConsumer node in _animatedNodes) + node.Apply(state); + } + + private static void ValidateSceneCounters( + RenderPipelineBenchmarkSceneDefinition scene, + IReadOnlyDictionary counters) + { + if (scene.Name == "ShaderOpacityShader" + && counters.GetValueOrDefault("FusedShaderRunExecutions") < 1) + { + throw new InvalidOperationException("The primary workload did not execute its fused three-stage chain."); + } + + // Built-in spatial filters may replay directly, but a whole-source shader still splits shader runs. + // CustomEffect topology is pinned by the benchmark unit test because its own target is not leased from + // the renderer pool and therefore cannot be inferred from IntermediateTargetAcquisitions. + long barrierEvidence = scene.Barrier switch + { + RenderPipelineBenchmarkBarrier.WholeSourceShader => counters.GetValueOrDefault("ShaderRunExecutions"), + _ => long.MaxValue, + }; + if (barrierEvidence < 1) + { + throw new InvalidOperationException($"Barrier workload '{scene.Name}' did not retain a hard island boundary."); + } + + if (scene.HasStaticPrefixCache && counters.GetValueOrDefault("StructuralPlanCacheHits") < 1) + { + throw new InvalidOperationException("The static-prefix workload did not reach its persistent render cache."); + } + + if (scene.HasTargetDependencies + && counters.GetValueOrDefault("TargetPoolCreates") < 1) + { + throw new InvalidOperationException("The multi-root workload did not record every target dependency."); + } + } + + private static void ValidateRequestCounters(IReadOnlyDictionary counters) + { + if (counters.Count == 0 || !counters.ContainsKey("ShaderStageExecutions")) + throw new InvalidOperationException("A benchmark request produced no request-wide diagnostics."); + if (counters.GetValueOrDefault("Failures") != 0 + || counters.GetValueOrDefault("CleanupFailures") != 0 + || counters.GetValueOrDefault("FailedOutcomes") != 0) + { + throw new InvalidOperationException("A benchmark request reported a render or cleanup failure."); + } + if (counters.GetValueOrDefault("IntermediateAcquires") + != counters.GetValueOrDefault("IntermediateDischarges")) + { + throw new InvalidOperationException("A benchmark request did not discharge every intermediate acquire."); + } + + long outcomes = counters.GetValueOrDefault("ExecutedOutcomes") + + counters.GetValueOrDefault("CachedOutcomes") + + counters.GetValueOrDefault("MetadataOutcomes") + + counters.GetValueOrDefault("SkippedOutcomes") + + counters.GetValueOrDefault("FailedOutcomes"); + if (outcomes != counters.GetValueOrDefault("RecordedFragments")) + throw new InvalidOperationException("A benchmark request did not reconcile every recorded fragment."); + } + + private static ulong CalculateChecksum(ReadOnlySpan components) + { + const ulong offset = 14695981039346656037; + const ulong prime = 1099511628211; + ulong result = offset; + for (int index = 0; index < components.Length; index += 13) + { + result ^= components[index]; + result *= prime; + } + return result; + } + + private static ulong SampleToken(ReadOnlySpan components) + => components.Length == 0 + ? 0 + : ((ulong)components[0] << 48) + | ((ulong)components[components.Length / 3] << 32) + | ((ulong)components[components.Length * 2 / 3] << 16) + | components[^1]; + + private static int[] GetSetupFrames(RenderPipelineBenchmarkSceneDefinition scene) + => scene.Animation == RenderPipelineBenchmarkAnimation.StructuralToggle + ? [0, 1, 7, 8, 9] + : Enumerable.Range(0, RenderPipelineBenchmarkConfig.SetupWarmupFrameCount).ToArray(); + + private static (int Frame, bool RetainRasterization)[] GetSetupRenderPlan( + RenderPipelineBenchmarkSceneDefinition scene) + { + int[] frames = GetSetupFrames(scene); + return frames + .Select((frame, index) => (frame, index == frames.Length - 1)) + .ToArray(); + } + + internal static IReadOnlyList<(int Frame, bool RetainRasterization)> GetSetupRenderPlanForTest( + string caseName) + => GetSetupRenderPlan(RenderPipelineBenchmarkScenes.Get(caseName)); + + private static RenderNodeRendererOptions CreateRendererOptions(RenderPipelineBenchmarkSceneDefinition scene) + => new() + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = s_targetDomain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(scene.HasStaticPrefixCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + }, + }; + + private static void AssertMatchingDiagnosticOutput( + RenderPipelineObservedFrame production, + RenderPipelineObservedFrame diagnostic) + { + if (production.IsEmpty != diagnostic.IsEmpty + || production.Bounds != diagnostic.Bounds + || production.Width != diagnostic.Width + || production.Height != diagnostic.Height + || production.Token != diagnostic.Token + || production.Checksum != diagnostic.Checksum + || !string.Equals(production.Sha256, diagnostic.Sha256, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Independent benchmark output captures did not reproduce the same output."); + } + } + + private static RenderNode CreateScene( + RenderPipelineBenchmarkSceneDefinition scene, + List animatedNodes, + List sceneResources) + { + return scene.Name switch + { + "NoEffectControl" => CreateSource(scene, s_targetDomain), + "SingleShader" => WrapShader(CreateSource(scene, s_targetDomain), BenchmarkShader.Gamma), + "ShaderOpacityShader" => CreatePrimary(scene, barrier: false), + "ShaderOpacityShaderBarrier" => CreatePrimary(scene, barrier: true), + "LongInvariantChain" => CreateLongChain(scene), + "ParameterOnlyAnimation" => CreateAnimatedChain(scene, animatedNodes), + "StructuralToggle" => CreateStructuralToggle(scene, animatedNodes), + "StaticPrefixAnimatedTail" => CreateStaticPrefix(scene, animatedNodes), + "StaticSpatialPrefixAnimatedBlurTail" => CreateStaticSpatialPrefix( + scene, + animatedNodes, + sceneResources), + "MixedSpatialColor" => CreateMixedChain(scene, sceneResources), + "SpatialGroupChain" => CreateSpatialGroupChain(scene, sceneResources), + "SpatialNodeChain" => CreateSpatialNodeChain(scene, sceneResources), + "LayerCustomEffect" => CreateCustomEffectChain(scene, sceneResources, mixed: false), + "BlurCustomBlur" => CreateCustomEffectChain(scene, sceneResources, mixed: true), + "SmallObjectFixedOverhead" => CreateSmallObject(scene), + "MultipleDrawablesTargetDependencies" => CreateMultipleRoots(scene), + _ => throw new ArgumentOutOfRangeException(nameof(scene), scene.Name, "Unknown benchmark scene."), + }; + } + + private static RenderNode CreatePrimary(RenderPipelineBenchmarkSceneDefinition scene, bool barrier) + { + RenderNode current = CreateSource(scene, s_targetDomain); + current = WrapShader(current, BenchmarkShader.Gamma); + current = WrapOpacity(current, 0.625f); + if (barrier) + current = WrapShader(current, BenchmarkShader.WholeSourceIdentity); + return WrapShader(current, BenchmarkShader.Invert); + } + + private static RenderNode CreateLongChain(RenderPipelineBenchmarkSceneDefinition scene) + { + RenderNode current = CreateSource(scene, s_targetDomain); + for (int index = 0; index < scene.SemanticStageCount; index++) + current = WrapShader(current, (index & 1) == 0 ? BenchmarkShader.Gamma : BenchmarkShader.Invert); + return current; + } + + private static RenderNode CreateAnimatedChain( + RenderPipelineBenchmarkSceneDefinition scene, + List animatedNodes) + { + RenderNode current = WrapShader(CreateSource(scene, s_targetDomain), BenchmarkShader.Gamma); + var animated = new BenchmarkAnimatedShaderNode(); + animated.AddChild(current); + animatedNodes.Add(animated); + return WrapShader(animated, BenchmarkShader.Invert); + } + + private static RenderNode CreateStructuralToggle( + RenderPipelineBenchmarkSceneDefinition scene, + List animatedNodes) + { + RenderNode current = WrapShader(CreateSource(scene, s_targetDomain), BenchmarkShader.Gamma); + var toggle = new BenchmarkStructuralToggleNode(); + toggle.AddChild(current); + animatedNodes.Add(toggle); + return WrapShader(toggle, BenchmarkShader.Invert); + } + + private static RenderNode CreateStaticPrefix( + RenderPipelineBenchmarkSceneDefinition scene, + List animatedNodes) + { + RenderNode prefix = CreateSource(scene, s_targetDomain); + prefix = WrapShader(prefix, BenchmarkShader.Gamma); + prefix = WrapShader(prefix, BenchmarkShader.Invert); + prefix = WrapShader(prefix, BenchmarkShader.ChannelRotate); + var cacheBoundary = new BenchmarkCacheBoundaryNode(); + cacheBoundary.AddChild(prefix); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + cacheBoundary.Cache.RecordSuccessfulStableRequest(); + + var animated = new BenchmarkAnimatedShaderNode(); + animated.AddChild(cacheBoundary); + animatedNodes.Add(animated); + RenderNode current = WrapOpacity(animated, 0.875f); + return WrapShader(current, BenchmarkShader.ChannelRotate); + } + + private static RenderNode CreateStaticSpatialPrefix( + RenderPipelineBenchmarkSceneDefinition scene, + List animatedNodes, + List sceneResources) + { + FilterEffect.Resource prefixResource = new Blur + { + Sigma = { CurrentValue = new Size(3, 3) }, + }.ToResource(CompositionContext.Default); + sceneResources.Add(prefixResource); + FilterEffectRenderNode prefix = prefixResource.CreateRenderNode(); + prefix.AddChild(CreateSource(scene, s_targetDomain)); + + var cacheBoundary = new BenchmarkCacheBoundaryNode(); + cacheBoundary.AddChild(prefix); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + cacheBoundary.Cache.RecordSuccessfulStableRequest(); + + var tailEffect = new Blur(); + FilterEffect.Resource tailResource = tailEffect.ToResource(CompositionContext.Default); + sceneResources.Add(tailResource); + FilterEffectRenderNode tail = tailResource.CreateRenderNode(); + tail.AddChild(cacheBoundary); + animatedNodes.Add(new BenchmarkAnimatedBlurNode(tailEffect, tailResource, tail)); + return tail; + } + + private static RenderNode CreateMixedChain( + RenderPipelineBenchmarkSceneDefinition scene, + List sceneResources) + { + RenderNode current = WrapShader(CreateSource(scene, s_targetDomain), BenchmarkShader.Gamma); + Blur blur = CreateMixedSpatialEffect(); + FilterEffect.Resource blurResource = blur.ToResource(CompositionContext.Default); + sceneResources.Add(blurResource); + FilterEffectRenderNode blurNode = blurResource.CreateRenderNode(); + blurNode.AddChild(current); + current = blurNode; + current = WrapShader(current, BenchmarkShader.Invert); + current = WrapOpacity(current, 0.8f); + return WrapShader(current, BenchmarkShader.ChannelRotate); + } + + // One effect node whose group holds every blur: the recorder keeps them in one segment. + private static RenderNode CreateSpatialGroupChain( + RenderPipelineBenchmarkSceneDefinition scene, + List sceneResources) + { + var group = new FilterEffectGroup(); + for (int index = 0; index < scene.SemanticStageCount; index++) + group.Children.Add(new Blur { Sigma = { CurrentValue = new Size(2 + index, 2 + index) } }); + + FilterEffect.Resource resource = group.ToResource(CompositionContext.Default); + sceneResources.Add(resource); + FilterEffectRenderNode node = resource.CreateRenderNode(); + node.AddChild(CreateSource(scene, s_targetDomain)); + return node; + } + + // One effect node per blur: each node records its own segment. + private static RenderNode CreateSpatialNodeChain( + RenderPipelineBenchmarkSceneDefinition scene, + List sceneResources) + { + RenderNode current = CreateSource(scene, s_targetDomain); + for (int index = 0; index < scene.SemanticStageCount; index++) + { + var blur = new Blur { Sigma = { CurrentValue = new Size(2 + index, 2 + index) } }; + FilterEffect.Resource resource = blur.ToResource(CompositionContext.Default); + sceneResources.Add(resource); + FilterEffectRenderNode node = resource.CreateRenderNode(); + node.AddChild(current); + current = node; + } + + return current; + } + + private static RenderNode CreateCustomEffectChain( + RenderPipelineBenchmarkSceneDefinition scene, + List sceneResources, + bool mixed) + { + FilterEffect effect = CreateCustomEffect(mixed); + FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + sceneResources.Add(resource); + FilterEffectRenderNode node = resource.CreateRenderNode(); + node.AddChild(CreateSource(scene, s_targetDomain)); + return node; + } + + private static FilterEffect CreateCustomEffect(bool mixed) + { + var group = new FilterEffectGroup(); + if (mixed) + group.Children.Add(new Blur { Sigma = { CurrentValue = new Size(3, 3) } }); + group.Children.Add(new LayerEffect()); + if (mixed) + group.Children.Add(new Blur { Sigma = { CurrentValue = new Size(4, 4) } }); + return group; + } + + internal static FilterEffect CreateCustomEffectForTest(bool mixed) + => CreateCustomEffect(mixed); + + private static Blur CreateMixedSpatialEffect() + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(3, 3); + return blur; + } + + internal static FilterEffect CreateMixedSpatialEffectForTest() + => CreateMixedSpatialEffect(); + + private static RenderNode CreateSmallObject(RenderPipelineBenchmarkSceneDefinition scene) + { + Rect bounds = RenderPipelineBenchmarkScenes.GetDrawableBounds(scene, 0); + RenderNode current = WrapShader(CreateSource(scene, bounds), BenchmarkShader.Gamma); + current = WrapOpacity(current, 0.75f); + return WrapShader(current, BenchmarkShader.Invert); + } + + private static RenderNode CreateMultipleRoots(RenderPipelineBenchmarkSceneDefinition scene) + { + var root = new ContainerRenderNode(); + for (int index = 0; index < scene.TopLevelDrawableCount; index++) + { + Rect bounds = RenderPipelineBenchmarkScenes.GetDrawableBounds(scene, index); + RenderNode source = WrapShader(CreateSource(scene, bounds, index), BenchmarkShader.ChannelRotate); + var dependency = new BenchmarkTargetDependencyNode(bounds, index); + dependency.AddChild(source); + root.AddChild(dependency); + } + return root; + } + + private static RenderNode CreateSource( + RenderPipelineBenchmarkSceneDefinition scene, + Rect bounds, + int variant = 0) + { + var size = new PixelSize((int)bounds.Width, (int)bounds.Height); + RenderTarget target = RenderTarget.Create(size.Width, size.Height) + ?? throw new InvalidOperationException( + $"Could not allocate the persistent {size.Width}x{size.Height} benchmark source."); + using (var bitmap = new Bitmap( + size.Width, + size.Height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb)) + { + RenderPipelineBenchmarkSceneDefinition sourceScene = variant == 0 + ? scene + : new RenderPipelineBenchmarkSceneDefinition( + scene.Name + "-source-" + variant, + scene.Seed + variant * 101, + scene.SemanticStageCount); + RenderPipelineBenchmarkScenes.CreateLinearPremultipliedRgba16F(sourceScene, size) + .CopyTo(bitmap.GetPixelSpan()); + using var canvas = new ImmediateCanvas(target, 1, 1, new Size(size.Width, size.Height)); + canvas.Clear(); + canvas.DrawBitmap(bitmap, Brushes.Resource.White, null); + } + return new BenchmarkMaterializedSourceNode(target, bounds); + } + + private static RenderNode WrapShader(RenderNode child, ShaderDescription description) + { + var node = new BenchmarkShaderNode(description); + node.AddChild(child); + return node; + } + + private static RenderNode WrapOpacity(RenderNode child, float opacity) + { + var node = new OpacityRenderNode(opacity); + node.AddChild(child); + return node; + } + + private sealed class DiagnosticSession : IDisposable + { + private readonly RenderPipelineBenchmarkSceneDefinition _scene; + private readonly RenderNode _root; + private readonly RenderNodeRenderer _renderer; + private readonly IReadOnlyList _animatedNodes; + private readonly IReadOnlyList _sceneResources; + private bool _disposed; + + public DiagnosticSession(RenderPipelineBenchmarkSceneDefinition scene) + { + _scene = scene; + var animatedNodes = new List(); + var sceneResources = new List(); + _root = CreateScene(scene, animatedNodes, sceneResources); + _animatedNodes = animatedNodes.AsReadOnly(); + _sceneResources = sceneResources.AsReadOnly(); + RenderNodeRendererOptions options = CreateRendererOptions(scene); + RenderPipelineInternalDiagnostics.Attach(options, RenderRequestPurpose.Frame); + _renderer = new RenderNodeRenderer(_root, options); + } + + public DiagnosticCapture Capture(int productionNextFrame) + { + ObjectDisposedException.ThrowIf(_disposed, this); + int[] setupFrames = GetSetupFrames(_scene); + RenderPipelineObservedFrame? setupOutput = null; + SortedDictionary? setupCounters = null; + for (int index = 0; index < setupFrames.Length; index++) + { + ApplyFrameState(setupFrames[index]); + using RenderNodeRasterization rasterization = _renderer.Rasterize(); + SortedDictionary counters = CaptureCounters(setupFrames[index], "setup"); + ValidateRequestCounters(counters); + if (index == setupFrames.Length - 1) + { + setupOutput = Observe(rasterization); + setupCounters = counters; + } + } + + SortedDictionary verifiedSetupCounters = setupCounters + ?? throw new InvalidOperationException("The untimed diagnostic setup did not render a request."); + ValidateSceneCounters(_scene, verifiedSetupCounters); + + int firstMeasuredFrame = checked(setupFrames[^1] + 1); + if (productionNextFrame <= firstMeasuredFrame) + throw new InvalidOperationException("The production benchmark completed without a measured request."); + + SortedDictionary? measuredCounters = null; + RenderPipelineObservedFrame? measuredOutput = null; + for (int frameIndex = firstMeasuredFrame; frameIndex < productionNextFrame; frameIndex++) + { + ApplyFrameState(frameIndex); + using RenderNodeRasterization rasterization = _renderer.Rasterize(); + SortedDictionary counters = CaptureCounters(frameIndex, "measured-shape"); + ValidateRequestCounters(counters); + if (frameIndex == productionNextFrame - 1) + { + measuredOutput = Observe(rasterization); + measuredCounters = counters; + } + } + SortedDictionary verifiedMeasuredCounters = measuredCounters + ?? throw new InvalidOperationException( + "The untimed diagnostic session did not replay the final measured request."); + ValidateSceneCounters(_scene, verifiedMeasuredCounters); + + return new DiagnosticCapture( + setupOutput + ?? throw new InvalidOperationException("The untimed diagnostic setup produced no output."), + measuredOutput + ?? throw new InvalidOperationException("The untimed diagnostic session produced no measured output."), + verifiedSetupCounters, + verifiedMeasuredCounters, + RenderPipelineInternalDiagnostics.CaptureNumericProperties( + _renderer, + "LastExecutionStatistics"), + RenderPipelineInternalDiagnostics.CaptureNumericProperties( + _renderer, + "StructuralPlanCacheStatistics"), + RenderPipelineInternalDiagnostics.CaptureNumericProperties( + _renderer, + "ProgramCacheStatistics"), + RenderPipelineInternalDiagnostics.CaptureNumericProperties( + _renderer, + "TargetPoolStatistics")); + } + + public void Dispose() + { + if (_disposed) + return; + + _renderer.Dispose(); + _root.Dispose(); + for (int index = _sceneResources.Count - 1; index >= 0; index--) + _sceneResources[index].Dispose(); + _disposed = true; + } + + private SortedDictionary CaptureCounters(int frameIndex, string phase) + { + SortedDictionary counters = + RenderPipelineInternalDiagnostics.CaptureLatestCounters(_renderer, out bool succeeded); + if (!succeeded) + { + throw new InvalidOperationException( + $"Untimed {phase} diagnostic render {frameIndex} for '{_scene.Name}' failed."); + } + return counters; + } + + private void ApplyFrameState(int frameIndex) + { + RenderPipelineBenchmarkFrameState state = _scene.GetFrameState(frameIndex); + foreach (IFrameStateConsumer node in _animatedNodes) + node.Apply(state); + } + } + + private sealed record DiagnosticCapture( + RenderPipelineObservedFrame SetupOutput, + RenderPipelineObservedFrame MeasuredOutput, + SortedDictionary SetupCounters, + SortedDictionary MeasuredCounters, + SortedDictionary LastExecutionStatistics, + SortedDictionary StructuralPlanCacheStatistics, + SortedDictionary ProgramCacheStatistics, + SortedDictionary TargetPoolStatistics); + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} + +internal interface IFrameStateConsumer +{ + void Apply(RenderPipelineBenchmarkFrameState state); +} + +internal sealed class BenchmarkMaterializedSourceNode( + RenderTarget target, + Rect bounds) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow(target); + context.Publish(context.MaterializedInput(MaterializedInputDescription.FromRenderTarget( + resource, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds))); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + target.Dispose(); + } +} + +internal sealed class BenchmarkShaderNode(ShaderDescription description) : ContainerRenderNode +{ + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.Inputs) + context.Publish(context.Shader(input, description)); + } +} + +internal sealed class BenchmarkAnimatedShaderNode : ContainerRenderNode, IFrameStateConsumer +{ + private float _amount = 1; + + public void Apply(RenderPipelineBenchmarkFrameState state) + { + if (_amount.Equals(state.AnimatedAmount)) + return; + + _amount = state.AnimatedAmount; + HasChanges = true; + } + + public override void Process(RenderNodeContext context) + { + float amount = _amount; + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float amount; half4 apply(half4 color) { " + + "return half4(min(color.rgb * amount, color.aaa), color.a); }", + bindings => bindings.Uniform("amount", amount)); + foreach (RenderFragmentHandle input in context.Inputs) + context.Publish(context.Shader(input, description)); + } +} + +internal sealed class BenchmarkAnimatedBlurNode( + Blur effect, + FilterEffect.Resource resource, + FilterEffectRenderNode node) : IFrameStateConsumer +{ + private float _sigma; + + public void Apply(RenderPipelineBenchmarkFrameState state) + { + float sigma = 1 + ((state.AnimatedAmount - 0.75f) * 8); + if (_sigma.Equals(sigma)) + return; + + _sigma = sigma; + effect.Sigma.CurrentValue = new Size(sigma, sigma); + bool updateOnly = false; + resource.Update(effect, CompositionContext.Default, ref updateOnly); + if (!node.Update(resource)) + throw new InvalidOperationException("The animated Blur resource did not publish its changed sigma."); + } +} + +internal sealed class BenchmarkStructuralToggleNode : ContainerRenderNode, IFrameStateConsumer +{ + private bool _variant; + + public void Apply(RenderPipelineBenchmarkFrameState state) + { + _variant = state.StructuralVariant; + } + + public override void Process(RenderNodeContext context) + { + ShaderDescription description = _variant ? BenchmarkShader.ChannelRotate : BenchmarkShader.Invert; + foreach (RenderFragmentHandle input in context.Inputs) + context.Publish(context.Shader(input, description)); + } +} + +internal sealed class BenchmarkCacheBoundaryNode : ContainerRenderNode +{ + public override void Process(RenderNodeContext context) => context.PassThrough(); +} + +internal sealed class BenchmarkTargetDependencyNode(Rect bounds, int index) : ContainerRenderNode +{ + public override void Process(RenderNodeContext context) + { + context.PublishRange(context.Inputs); + TargetCommandDescription command = TargetCommandDescription.Create( + index, + static (_, _) => { }, + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.OutputBounds); + context.Publish(context.TargetCommand(context.Inputs, command)); + } +} + +internal static class BenchmarkShader +{ + public static ShaderDescription Gamma { get; } = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(sqrt(max(color.rgb, half3(0))), color.a); }"); + + public static ShaderDescription Invert { get; } = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.a - color.rgb, color.a); }"); + + public static ShaderDescription ChannelRotate { get; } = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.g, color.b, color.r, color.a); }"); + + public static ShaderDescription WholeSourceIdentity { get; } = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Identity); +} + +internal sealed record RenderPipelineObservedFrame( + bool IsEmpty, + Rect Bounds, + int Width, + int Height, + ulong Token, + ulong Checksum, + string Sha256, + double Energy); + +internal static class RenderPipelineInternalDiagnostics +{ + private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + // Feature 004 removed the request-wide diagnostics recorder, so counters come from the component + // statistics the renderer still publishes rather than from a per-request snapshot. + public static void Attach(RenderNodeRendererOptions options, RenderRequestPurpose purpose) + => SetPurpose(options, purpose); + + public static void SetPurpose(RenderNodeRendererOptions options, RenderRequestPurpose purpose) + => SetProperty(GetProperty(options, "DefaultRequest"), "Purpose", purpose); + + public static SortedDictionary CaptureLatestCounters(object renderer, out bool succeeded) + { + SortedDictionary result = CaptureNumericProperties(renderer, "LastExecutionStatistics"); + foreach (KeyValuePair pair in CaptureNumericProperties(renderer, "TargetPoolStatistics")) + result[$"TargetPool{pair.Key}"] = pair.Value; + foreach (KeyValuePair pair in CaptureNumericProperties(renderer, "ProgramCacheStatistics")) + result[$"ProgramCache{pair.Key}"] = pair.Value; + foreach (KeyValuePair pair in CaptureNumericProperties(renderer, "StructuralPlanCacheStatistics")) + result[$"StructuralPlanCache{pair.Key}"] = pair.Value; + succeeded = true; + return result; + } + + public static SortedDictionary CaptureNumericProperties(object owner, string propertyName) + { + object value = GetProperty(owner, propertyName); + var result = new SortedDictionary(StringComparer.Ordinal); + foreach (PropertyInfo property in value.GetType().GetProperties(InstanceFlags).OrderBy(static x => x.Name)) + { + object? propertyValue = property.GetValue(value); + if (TryConvertInt64(propertyValue, out long number)) + result.Add(property.Name, number); + } + return result; + } + + private static bool TryConvertInt64(object? value, out long result) + { + switch (value) + { + case byte item: result = item; return true; + case sbyte item: result = item; return true; + case short item: result = item; return true; + case ushort item: result = item; return true; + case int item: result = item; return true; + case uint item: result = item; return true; + case long item: result = item; return true; + case ulong item when item <= long.MaxValue: result = (long)item; return true; + default: result = 0; return false; + } + } + + private static object GetProperty(object owner, string name) + { + PropertyInfo property = owner.GetType().GetProperty(name, InstanceFlags) + ?? throw new MissingMemberException(owner.GetType().FullName, name); + return property.GetValue(owner) + ?? throw new InvalidOperationException($"Property '{name}' unexpectedly returned null."); + } + + private static void SetProperty(object owner, string name, object value) + { + PropertyInfo property = owner.GetType().GetProperty(name, InstanceFlags) + ?? throw new MissingMemberException(owner.GetType().FullName, name); + property.SetValue(owner, value); + } +} + +internal sealed class RenderPipelineBenchmarkCounterRecord +{ + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + public int SchemaVersion { get; init; } + public string CaseName { get; init; } = string.Empty; + public int Seed { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public int SetupWarmupFrames { get; init; } + public string Lifetime { get; init; } = string.Empty; + public string RequestShape { get; init; } = string.Empty; + public int SemanticStageCount { get; init; } + public int TopLevelDrawableCount { get; init; } + public string Animation { get; init; } = string.Empty; + public string Barrier { get; init; } = string.Empty; + public bool HasStaticPrefixCache { get; init; } + public bool HasTargetDependencies { get; init; } + public string OutputSha256 { get; init; } = string.Empty; + public string OutputChecksum { get; init; } = string.Empty; + public Rect OutputBounds { get; init; } + public string MeasuredOutputSha256 { get; init; } = string.Empty; + public string MeasuredOutputChecksum { get; init; } = string.Empty; + public Rect MeasuredOutputBounds { get; init; } + public int MeasuredWidth { get; init; } + public int MeasuredHeight { get; init; } + public string ExpectedMeasuredOutputSha256 { get; init; } = string.Empty; + public string ExpectedMeasuredOutputChecksum { get; init; } = string.Empty; + public Rect ExpectedMeasuredOutputBounds { get; init; } + public int ExpectedMeasuredWidth { get; init; } + public int ExpectedMeasuredHeight { get; init; } + public SortedDictionary SetupLastRequestCounters { get; init; } = new(StringComparer.Ordinal); + public SortedDictionary MeasuredLastRequestCounters { get; init; } = new(StringComparer.Ordinal); + public SortedDictionary LastExecutionStatistics { get; init; } = new(StringComparer.Ordinal); + public SortedDictionary StructuralPlanCacheStatistics { get; init; } = new(StringComparer.Ordinal); + public SortedDictionary ProgramCacheStatistics { get; init; } = new(StringComparer.Ordinal); + public SortedDictionary TargetPoolStatistics { get; init; } = new(StringComparer.Ordinal); +} diff --git a/tests/Beutl.Graphics3DTests/AttachmentContentRecordTests.cs b/tests/Beutl.Graphics3DTests/AttachmentContentRecordTests.cs new file mode 100644 index 0000000000..3777cebaf8 --- /dev/null +++ b/tests/Beutl.Graphics3DTests/AttachmentContentRecordTests.cs @@ -0,0 +1,53 @@ +using Beutl.Graphics.Backend; + +namespace Beutl.Graphics3DTests; + +/// +/// Pins that what the backend records about a texture's contents survives contact with a render pass. +/// +/// +/// The record exists so a caller that wants a blank target can skip a clear that would change nothing. A +/// pass writes its attachments, so a record left saying "transparent" across one would make that caller +/// skip a clear it needed and read the pass's output instead. +/// +[TestFixture] +[NonParallelizable] +public sealed class AttachmentContentRecordTests +{ + private const int Width = 16; + private const int Height = 8; + + [Test] + [Category("GpuPassFusionGpu")] + public void UsingAClearedTextureAsAnAttachment_StopsItReportingTransparentContents() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using IRenderPass3D pass = context.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using ITexture2D color = context.CreateTexture2D(Width, Height, TextureFormat.RGBA8Unorm); + using IFramebuffer3D framebuffer = context.CreateFramebuffer3D(pass, [color], null); + + var clearable = (ITransparentClearableTexture)color; + clearable.ClearToTransparent(); + bool transparentBeforeThePass = clearable.HasTransparentContents; + + framebuffer.PrepareForRendering(); + bool transparentAfterThePass = clearable.HasTransparentContents; + + context.WaitIdle(); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + transparentBeforeThePass, + Is.True, + "precondition: a recorded clear is what the record is for"); + Assert.That( + transparentAfterThePass, + Is.False, + "a pass writes its attachments, so the record cannot still say transparent"); + } + }); + } +} diff --git a/tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs b/tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs new file mode 100644 index 0000000000..5dcdf1a444 --- /dev/null +++ b/tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs @@ -0,0 +1,415 @@ +using System.Numerics; + +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics3D; +using Beutl.Graphics3D.Lighting; +using Beutl.Graphics3D.Materials; +using Beutl.Graphics3D.Primitives; +using Beutl.Graphics3D.Textures; +using Beutl.Media; + +namespace Beutl.Graphics3DTests; + +[TestFixture] +[NonParallelizable] +public sealed class GpuPassFusion3DBoundaryTests +{ + [Test] + [Category("GpuPassFusionGpu")] + public void Scene3D_MaterializesOneBackendBoundary_ThenResumesTwoDimensionalWork() + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + scene.BackgroundColor.CurrentValue = new Color(255, 32, 64, 96); + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + using var sceneNode = new Scene3DRenderNode(resource); + using var root = new DownstreamShaderNode(sceneNode); + + using (CompiledRenderRequest compiled = Compile(root)) + { + RenderFragmentReference sceneBoundary = compiled.Graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(static fragment => fragment.Kind == RenderFragmentKind.OpaqueSource); + CompiledShaderRun[] shaderRuns = compiled.ExecutionPlan.ShaderRuns.ToArray(); + Assert.Multiple(() => + { + Assert.That( + sceneBoundary.ValueCardinality, + Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + Assert.That(compiled.ExecutionPlan.Boundaries.Count(static item => + item.Reason == ExecutionIslandBoundaryReason.ThreeD), Is.EqualTo(1)); + Assert.That(compiled.ExecutionPlan.Boundaries.Count(static item => + item.Reason == ExecutionIslandBoundaryReason.BackendTransition), Is.EqualTo(1)); + Assert.That(shaderRuns, Has.Length.EqualTo(1)); + }); + Assert.That(shaderRuns.Single().Stages, Has.Length.EqualTo(1)); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 32, 24), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The 3D-to-2D hand-off must publish a bitmap."); + Vector4 expectedBackground = scene.BackgroundColor.CurrentValue.ToLinearPremultiplied(); + Vector4 actualCenter = ReadLinearPixel(bitmap, 16, 12); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(new Rect(0, 0, 32, 24))); + Assert.That(renderer.LastExecutionStatistics.Synchronizations, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(1)); + Assert.That(bitmap.GetPixelSpan().ToArray(), Has.Some.Not.Zero, + "The 3D-to-2D hand-off must publish a non-vacuous value."); + Assert.That(actualCenter.X, Is.EqualTo(expectedBackground.Z).Within(0.003f), + "The downstream shader must swap blue into the red channel."); + Assert.That(actualCenter.Y, Is.EqualTo(expectedBackground.Y).Within(0.003f), + "The downstream shader must preserve the green channel."); + Assert.That(actualCenter.Z, Is.EqualTo(expectedBackground.X).Within(0.003f), + "The downstream shader must swap red into the blue channel."); + Assert.That(actualCenter.W, Is.EqualTo(expectedBackground.W).Within(0.003f), + "The downstream shader must preserve alpha."); + }); + }); + } + + private static Vector4 ReadLinearPixel(Bitmap bitmap, int x, int y) + { + int offset = ((y * bitmap.Width) + x) * 4; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + return new Vector4( + (float)BitConverter.UInt16BitsToHalf(pixels[offset]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 1]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 2]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3])); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void Scene3D_ReusesItsRendererAcrossRequests() + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + using var sceneNode = new Scene3DRenderNode(resource); + Renderer3D? firstRenderer; + + using (var renderer = new RenderNodeRenderer( + sceneNode, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 32, 24), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + })) + { + Assert.That(resource.Renderer, Is.Null); + using (renderer.Rasterize()) + { + } + + firstRenderer = resource.Renderer; + Assert.That(firstRenderer, Is.Not.Null); + + using (renderer.Rasterize()) + { + } + + Assert.That(resource.Renderer, Is.SameAs(firstRenderer)); + } + + Assert.That( + resource.Renderer, + Is.SameAs(firstRenderer), + "Request and RenderNodeRenderer cleanup must not dispose the scene-owned renderer."); + }); + } + + [TestCase(MaterialTextureDependency.BasicDiffuseMap)] + [TestCase(MaterialTextureDependency.PbrAlbedoMap)] + [TestCase(MaterialTextureDependency.PbrNormalMap)] + [TestCase(MaterialTextureDependency.PbrMetallicRoughnessMap)] + [TestCase(MaterialTextureDependency.PbrEmissiveMap)] + [TestCase(MaterialTextureDependency.PbrAOMap)] + [TestCase(MaterialTextureDependency.TransparentColorMap)] + [Category("GpuPassFusionGpu")] + public void Scene3D_DrawableTextureConsumesItsPlannedNestedTarget( + MaterialTextureDependency dependency) + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + var drawable = new RectShape(); + drawable.Width.CurrentValue = 12; + drawable.Height.CurrentValue = 8; + drawable.Fill.CurrentValue = new SolidColorBrush(new Color(255, 192, 0, 0)); + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = drawable; + texture.TextureWidth.CurrentValue = 12; + texture.TextureHeight.CurrentValue = 8; + Material3D material = CreateMaterial(dependency, texture); + using var resource = CreateSceneResource(material); + using var sceneNode = new Scene3DRenderNode(resource); + + var targetDomain = new Rect(0, 0, 48, 36); + using CompiledRenderRequest compiled = Compile( + sceneNode, + targetDomain, + outputScale: 1.75f, + maxWorkingScale: 0.75f); + CompiledRenderRequest nested = compiled.NestedRequests.Single(); + NestedRenderTargetBinding binding = nested.Request.Options.TargetBinding + ?? throw new AssertionException("The drawable texture has no planned target binding."); + Assert.Multiple(() => + { + Assert.That(nested.Request.Options.TargetDomain, Is.EqualTo(new Rect(0, 0, 12, 8))); + Assert.That(nested.Request.Options.OutputScale, Is.EqualTo(0.75f)); + Assert.That(nested.Request.Options.MaxWorkingScale, Is.EqualTo(0.75f)); + Assert.That(binding.IsReady, Is.False); + }); + + ushort[] renderedPixels; + RenderExecutionStatistics statistics; + using var registry = new RenderTargetLeaseRegistry(factory: null); + using (RenderTargetLeaseSession targets = registry.BeginSession(RenderIntent.Preview)) + using (RenderTargetLease root = targets.Acquire( + PixelRect.FromRect(compiled.ExecutionTargetBounds, 1.75f).Size)) + using (var canvas = new ImmediateCanvas( + root.Target, + density: 1.75f, + maxWorkingScale: 0.75f, + logicalSize: compiled.ExecutionTargetBounds.Size)) + using (canvas.PushTransform(Matrix.CreateTranslation( + -compiled.ExecutionTargetBounds.X, + -compiled.ExecutionTargetBounds.Y))) + { + canvas.Clear(); + var executor = new RenderRequestExecutor(targets); + executor.Execute( + compiled, + canvas, + replayBounds: compiled.ExecutionTargetBounds); + statistics = executor.Statistics; + using Bitmap rendered = root.Target.Snapshot(); + renderedPixels = rendered.GetPixelSpan().ToArray(); + } + + ushort[] controlPixels = RenderScene(CreateMaterial(dependency, texture: null)); + double maximumRgbDifference = MaximumRgbDifference(renderedPixels, controlPixels); + Assert.Multiple(() => + { + Assert.That(statistics.IntermediateTargetAcquisitions, Is.GreaterThanOrEqualTo(1)); + Assert.That(maximumRgbDifference, Is.GreaterThan(0.01), + "The textured scene must differ from the no-texture control in RGB content."); + Assert.That(binding.Density, Is.EqualTo(0.75f), + "The planned drawable target must match the 3D surface density passed to GetTexture."); + Assert.That(binding.DeviceBounds, Is.EqualTo(new PixelRect(0, 0, 9, 6))); + Assert.That(binding.IsDisposed, Is.True); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + }); + } + + private static Scene3D.Resource CreateSceneResource(Material3D material) + { + var cube = new Cube3D(); + cube.Material.CurrentValue = material; + var light = new DirectionalLight3D(); + light.Direction.CurrentValue = new Vector3(0, 0, -1); + light.Intensity.CurrentValue = 1; + light.IsEnabled = true; + + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 48; + scene.RenderHeight.CurrentValue = 36; + scene.BackgroundColor.CurrentValue = Colors.Black; + scene.AmbientColor.CurrentValue = Colors.White; + // Full white ambient saturates the clamped output, hiding every additive/specular + // contribution and zeroing the emissive/normal/metallic-roughness RGB differences. + scene.AmbientIntensity.CurrentValue = 0.2f; + var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + resource.Objects.Add((Object3D.Resource)cube.ToResource(CompositionContext.Default)); + resource.Lights.Add((Light3D.Resource)light.ToResource(CompositionContext.Default)); + return resource; + } + + private static ushort[] RenderScene(Material3D material) + { + using var resource = CreateSceneResource(material); + using var sceneNode = new Scene3DRenderNode(resource); + var targetDomain = new Rect(0, 0, 48, 36); + using CompiledRenderRequest compiled = Compile( + sceneNode, + targetDomain, + outputScale: 1.75f, + maxWorkingScale: 0.75f); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession(RenderIntent.Preview); + using RenderTargetLease root = targets.Acquire( + PixelRect.FromRect(compiled.ExecutionTargetBounds, 1.75f).Size); + using var canvas = new ImmediateCanvas( + root.Target, + density: 1.75f, + maxWorkingScale: 0.75f, + logicalSize: compiled.ExecutionTargetBounds.Size); + using (canvas.PushTransform(Matrix.CreateTranslation( + -compiled.ExecutionTargetBounds.X, + -compiled.ExecutionTargetBounds.Y))) + { + canvas.Clear(); + var executor = new RenderRequestExecutor(targets); + executor.Execute(compiled, canvas, replayBounds: compiled.ExecutionTargetBounds); + using Bitmap rendered = root.Target.Snapshot(); + return rendered.GetPixelSpan().ToArray(); + } + } + + private static double MaximumRgbDifference(ushort[] textured, ushort[] control) + { + Assert.That(textured, Has.Length.EqualTo(control.Length)); + double maximum = 0; + for (int offset = 0; offset < textured.Length; offset += 4) + { + for (int channel = 0; channel < 3; channel++) + { + float texturedValue = (float)BitConverter.UInt16BitsToHalf(textured[offset + channel]); + float controlValue = (float)BitConverter.UInt16BitsToHalf(control[offset + channel]); + Assert.That(float.IsFinite(texturedValue), Is.True, + $"Textured output contains a non-finite value at component {offset + channel}."); + Assert.That(float.IsFinite(controlValue), Is.True, + $"Control output contains a non-finite value at component {offset + channel}."); + maximum = Math.Max(maximum, Math.Abs(texturedValue - controlValue)); + } + } + + return maximum; + } + + private static Material3D CreateMaterial( + MaterialTextureDependency dependency, + TextureSource? texture) + { + if (dependency == MaterialTextureDependency.BasicDiffuseMap) + { + var material = new BasicMaterial(); + material.DiffuseMap.CurrentValue = texture; + return material; + } + + if (dependency == MaterialTextureDependency.TransparentColorMap) + { + var material = new TransparentMaterial(); + material.ColorMap.CurrentValue = texture; + return material; + } + + var pbrMaterial = new PBRMaterial(); + if (dependency == MaterialTextureDependency.PbrEmissiveMap) + pbrMaterial.Emissive.CurrentValue = Colors.White; + switch (dependency) + { + case MaterialTextureDependency.PbrAlbedoMap: + pbrMaterial.AlbedoMap.CurrentValue = texture; + break; + case MaterialTextureDependency.PbrNormalMap: + pbrMaterial.NormalMap.CurrentValue = texture; + break; + case MaterialTextureDependency.PbrMetallicRoughnessMap: + // The map multiplies the base factors, so zero bases would make it unobservable. + pbrMaterial.Metallic.CurrentValue = 1f; + pbrMaterial.Roughness.CurrentValue = 1f; + pbrMaterial.MetallicRoughnessMap.CurrentValue = texture; + break; + case MaterialTextureDependency.PbrEmissiveMap: + pbrMaterial.EmissiveMap.CurrentValue = texture; + break; + case MaterialTextureDependency.PbrAOMap: + pbrMaterial.AOMap.CurrentValue = texture; + break; + default: + throw new ArgumentOutOfRangeException(nameof(dependency), dependency, null); + } + + return pbrMaterial; + } + + private static CompiledRenderRequest Compile(RenderNode root) + => Compile(root, new Rect(0, 0, 32, 24)); + + private static CompiledRenderRequest Compile( + RenderNode root, + Rect targetDomain, + float outputScale = 1, + float maxWorkingScale = 1) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: targetDomain, + outputScale: outputScale, + maxWorkingScale: maxWorkingScale, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return new RenderRequestCompiler().Compile(request, graph); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class DownstreamShaderNode(RenderNode sceneNode) : RenderNode + { + private static readonly ShaderDescription s_shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }"); + + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.RecordSubtree(sceneNode)) + context.Publish(context.Shader(input, s_shader)); + } + } + + public enum MaterialTextureDependency + { + BasicDiffuseMap, + PbrAlbedoMap, + PbrNormalMap, + PbrMetallicRoughnessMap, + PbrEmissiveMap, + PbrAOMap, + TransparentColorMap, + } +} diff --git a/tests/Beutl.Graphics3DTests/GpuTestEnvironment.cs b/tests/Beutl.Graphics3DTests/GpuTestEnvironment.cs index ca201c4f08..8a261d9747 100644 --- a/tests/Beutl.Graphics3DTests/GpuTestEnvironment.cs +++ b/tests/Beutl.Graphics3DTests/GpuTestEnvironment.cs @@ -1,4 +1,5 @@ using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; using Beutl.Graphics.Rendering; namespace Beutl.Graphics3DTests; @@ -113,8 +114,33 @@ private static void EnsureInitialized() } public static T InvokeOnRenderThread(Func func) - => RenderThread.Dispatcher.Invoke(func); + { + int before = VulkanValidationErrorLog.Shared.Count; + T result = RenderThread.Dispatcher.Invoke(func); + FailOnValidationErrorsSince(before); + return result; + } public static void InvokeOnRenderThread(Action action) - => RenderThread.Dispatcher.Invoke(action); + { + int before = VulkanValidationErrorLog.Shared.Count; + RenderThread.Dispatcher.Invoke(action); + FailOnValidationErrorsSince(before); + } + + /// + /// Fails the current test when the work just run reported a Vulkan validation error. + /// + /// + /// Nothing is recorded unless the job enabled validation, so this is inert on an ordinary run. The + /// layer reports some errors at queue submission rather than at the offending call, so an error can + /// land on a later invocation than the one that caused it; it still fails the run, which is what the + /// gate is for. + /// + private static void FailOnValidationErrorsSince(int previousCount) + { + string report = VulkanValidationErrorLog.Shared.DescribeSince(previousCount); + if (report.Length != 0) + Assert.Fail(report); + } } diff --git a/tests/Beutl.Graphics3DTests/RenderPassTransferScopeTests.cs b/tests/Beutl.Graphics3DTests/RenderPassTransferScopeTests.cs new file mode 100644 index 0000000000..e77b74640b --- /dev/null +++ b/tests/Beutl.Graphics3DTests/RenderPassTransferScopeTests.cs @@ -0,0 +1,231 @@ +using System.Runtime.InteropServices; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; +using Beutl.Graphics.Effects; +using Beutl.Media; + +namespace Beutl.Graphics3DTests; + +/// +/// Pins that a transfer issued while a render pass is recording splits that pass instead of taking its +/// own submission. +/// +/// +/// Meshes upload their vertex and index buffers lazily from inside the draw loop, which runs between +/// and . Vulkan forbids a transfer +/// inside a render pass instance, so appending one to the pass's own batch loses the upload: the mesh +/// then draws undefined vertices over the whole framebuffer, which a deferred renderer turns into a +/// black frame. Giving the transfer its own batch avoids that but submits it ahead of the pass, so every +/// draw already recorded in the pass runs after work requested later than it. Ending the instance, +/// recording the transfer, and beginning it again keeps the whole sequence on one command buffer in +/// recording order, which is the only arrangement that is right for both. The submission count is the +/// observable part of that contract; the rendering consequence is covered by the lit-framebuffer +/// assertions in . +/// +[TestFixture] +[NonParallelizable] +public sealed class RenderPassTransferScopeTests +{ + private const int Width = 16; + private const int Height = 8; + + private const string PassthroughFragmentShader = """ + #version 450 + + layout(location = 0) in vec2 fragCoord; + layout(location = 0) out vec4 outColor; + layout(binding = 0) uniform sampler2D sourceTexture; + + void main() { + outColor = texture(sourceTexture, fragCoord); + } + """; + + // Past VulkanRenderPass3D's 128-byte push-constant limit, so SetPushConstants rejects it. + [StructLayout(LayoutKind.Sequential, Size = 192)] + private struct OversizedPushConstants + { + public byte First; + } + + /// + /// A claimed render-pass scope sends every barrier through the split path, and a scope claimed before + /// its instance is open cannot split, so it falls back to a batch of its own submitted ahead. That is + /// wrong for the pass's own attachment transitions: those have to stay in recording order behind + /// whatever was recorded before them, or the queue runs them against an image that has since moved to a + /// different layout. The batch is therefore claimed at the command that opens the instance, not before. + /// + [Test] + [Category("GpuPassFusionGpu")] + public void BeginningAPass_KeepsItsPreparationBarriersInTheRecordedBatch() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using IRenderPass3D pass = context.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using ITexture2D color = context.CreateTexture2D(Width, Height, TextureFormat.RGBA8Unorm); + using IFramebuffer3D framebuffer = context.CreateFramebuffer3D(pass, [color], null); + + // Put the attachment somewhere Begin has to transition it back from, so the barrier this test is + // about is recorded on every backend. A texture that is already an attachment - which is how the + // Metal-backed one arrives - would make the observation below vacuous. + framebuffer.PrepareForSampling(); + + var duringSampling = new List(); + var duringBegin = new List(); + using (VulkanCommandPool.Observe(duringSampling.Add)) + { + framebuffer.PrepareForSampling(); + } + + using (VulkanCommandPool.Observe(duringBegin.Add)) + { + pass.Begin(framebuffer, [Colors.Transparent]); + } + + pass.End(); + context.WaitIdle(); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + duringSampling.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.Zero, + "precondition: an ordinary barrier outside a pass joins the recorded batch"); + Assert.That( + duringBegin.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.Zero, + "Opening a render pass must not submit anything: its attachment transitions belong to " + + "the batch already being recorded, in the order they were recorded."); + } + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void CopyBufferInsideARenderPass_SplitsThatPassInsteadOfSubmittingAheadOfIt() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + uint[] payload = [0x11223344u, 0x55667788u, 0x99AABBCCu, 0xDDEEFF00u]; + ulong size = (ulong)(payload.Length * sizeof(uint)); + + using IBuffer source = context.CreateBuffer( + size, + BufferUsage.TransferSource, + MemoryProperty.HostVisible | MemoryProperty.HostCoherent); + using IBuffer destination = context.CreateBuffer( + size, + BufferUsage.VertexBuffer | BufferUsage.TransferDestination, + MemoryProperty.DeviceLocal); + source.Upload(payload); + + using ITexture2D color = context.CreateTexture2D(Width, Height, TextureFormat.RGBA8Unorm); + using ITexture2D depth = context.CreateTexture2D(Width, Height, TextureFormat.Depth32Float); + using IRenderPass3D renderPass = context.CreateRenderPass3D( + [TextureFormat.RGBA8Unorm], + TextureFormat.Depth32Float); + using IFramebuffer3D framebuffer = context.CreateFramebuffer3D(renderPass, [color], depth); + + var duringPass = new List(); + renderPass.Begin(framebuffer, [Colors.Transparent]); + using (VulkanCommandPool.Observe(duringPass.Add)) + { + context.CopyBuffer(source, destination, size); + } + + renderPass.End(); + context.WaitIdle(); + + Assert.That( + duringPass.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.Zero, + "A transfer recorded inside a render pass must split that pass and stay on its batch, so " + + "it lands after the draws already recorded there rather than ahead of all of them."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void APassBodyFailure_ReleasesTheRenderPassScope() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using GLSLFilterPipeline pipeline = GLSLFilterPipeline.Create( + context, + PassthroughFragmentShader, + ShaderOutputCoverage.ProvablyFull) + ?? throw new AssertionException("The passthrough filter pipeline could not be created."); + using ITexture2D shaderSource = context.CreateTexture2D(Width, Height, TextureFormat.RGBA16Float); + using ITexture2D shaderDestination = context.CreateTexture2D(Width, Height, TextureFormat.RGBA16Float); + + // Throws from VulkanRenderPass3D.SetPushConstants, between the pass's Begin and End. + Assert.Throws( + () => pipeline.Execute(shaderSource, shaderDestination, new OversizedPushConstants { First = 1 })); + + uint[] payload = [0x11223344u, 0x55667788u]; + ulong size = (ulong)(payload.Length * sizeof(uint)); + using IBuffer source = context.CreateBuffer( + size, + BufferUsage.TransferSource, + MemoryProperty.HostVisible | MemoryProperty.HostCoherent); + using IBuffer destination = context.CreateBuffer( + size, + BufferUsage.VertexBuffer | BufferUsage.TransferDestination, + MemoryProperty.DeviceLocal); + source.Upload(payload); + + var afterFailure = new List(); + using (VulkanCommandPool.Observe(afterFailure.Add)) + { + context.CopyBuffer(source, destination, size); + } + + Assert.That( + afterFailure.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.Zero, + "A render pass whose body threw must release the render-pass scope, otherwise every later " + + "transfer in the process takes its own out-of-band submission and the shared batch keeps " + + "an unterminated render pass."); + + context.WaitIdle(); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void WaitIdleInsideARenderPass_DoesNotSubmitThatPass() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using ITexture2D color = context.CreateTexture2D(Width, Height, TextureFormat.RGBA8Unorm); + using ITexture2D depth = context.CreateTexture2D(Width, Height, TextureFormat.Depth32Float); + using IRenderPass3D renderPass = context.CreateRenderPass3D( + [TextureFormat.RGBA8Unorm], + TextureFormat.Depth32Float); + using IFramebuffer3D framebuffer = context.CreateFramebuffer3D(renderPass, [color], depth); + + var duringPass = new List(); + renderPass.Begin(framebuffer, [Colors.Transparent]); + using (VulkanCommandPool.Observe(duringPass.Add)) + { + // What an out-of-tree Material3D.Resource can reach from EnsurePipeline or Bind. + context.WaitIdle(); + } + + // Assert before End: unguarded, the pass's command buffer has already been freed by here, + // so End would record into freed memory and take the test host down with it. + Assert.That( + duringPass.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.Zero, + "A synchronous flush inside a render pass must not submit the batch that pass is still " + + "recording into."); + + renderPass.End(); + context.WaitIdle(); + }); + } +} diff --git a/tests/Beutl.Graphics3DTests/RenderTargetPoolRetainedContextTests.cs b/tests/Beutl.Graphics3DTests/RenderTargetPoolRetainedContextTests.cs new file mode 100644 index 0000000000..bcf2e4d72d --- /dev/null +++ b/tests/Beutl.Graphics3DTests/RenderTargetPoolRetainedContextTests.cs @@ -0,0 +1,91 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.Graphics3DTests; + +// A renderer with no TargetFactory is reusable: it can render into a caller-owned destination and +// then rasterize on its own. The second request carries no context of its own, so the pool has to +// keep allocating on the context the first one bound — otherwise the target it creates fails the +// compatibility check the pool runs on every surface it hands out. Only a real shared GPU context +// separates the two allocation paths, which is why these are Vulkan-gated. +[TestFixture] +public sealed class RenderTargetPoolRetainedContextTests +{ + [Test] + public void ATargetLessRequestAfterACpuDestinationStaysOnTheCpu() + { + GpuTestEnvironment.EnsureAvailable(); + + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using var pool = new RenderTargetPool(factory: null); + using RenderTarget destination = CreateCpuTarget(new PixelSize(8, 8)); + + using (RenderTargetPoolRequest request = pool.BeginRequest(destination)) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 4)); + Assert.That(lease.Target.Value.Context, Is.Null, "the destination is a CPU surface"); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + Assert.That(request.ExpectedContextHandle, Is.Null, "a target-less request names no context"); + + PooledRenderTargetLease? lease = null; + Assert.That( + () => lease = request.Acquire(new PixelSize(6, 6)), + Throws.Nothing, + "the pool must not hand back a target from a context it will then reject"); + using (lease) + { + Assert.That( + lease!.Target.Value.Context, + Is.Null, + "allocating on the shared GPU backend here is what makes the pool reject its own target"); + } + } + }); + } + + [Test] + public void ATargetLessRequestAfterASharedContextDestinationStaysOnThatContext() + { + GpuTestEnvironment.EnsureAvailable(); + + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using var pool = new RenderTargetPool(factory: null); + using RenderTarget? destination = RenderTarget.Create(8, 8); + Assert.That(destination, Is.Not.Null); + nint? destinationContext = destination!.Value.Context?.Handle; + + using (RenderTargetPoolRequest request = pool.BeginRequest(destination)) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 4)); + Assert.That(lease.Target.Value.Context?.Handle, Is.EqualTo(destinationContext)); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(6, 6)); + Assert.That(lease.Target.Value.Context?.Handle, Is.EqualTo(destinationContext)); + } + }); + } + + private static RenderTarget CreateCpuTarget(PixelSize size) + { + SKSurface surface = SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear()))!; + return new TestCpuRenderTarget(surface, size); + } + + private sealed class TestCpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} diff --git a/tests/Beutl.Graphics3DTests/ShaderDescriptionSpirvEquivalenceTests.cs b/tests/Beutl.Graphics3DTests/ShaderDescriptionSpirvEquivalenceTests.cs new file mode 100644 index 0000000000..e1d2d7c584 --- /dev/null +++ b/tests/Beutl.Graphics3DTests/ShaderDescriptionSpirvEquivalenceTests.cs @@ -0,0 +1,204 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.Graphics3DTests; + +[TestFixture] +[NonParallelizable] +public sealed class ShaderDescriptionSpirvEquivalenceTests +{ + /// + /// Adjacent RgbaF16 codes. The two paths are compiled by different shader compilers, so they are + /// only guaranteed to agree to within their rounding: a backend whose half is real fp16 + /// (Metal through MoltenVK) settles a result on either neighbouring code, while one that + /// evaluates half at float precision (SwiftShader) reproduces the bits exactly. A lowering + /// that dropped the premultiply, transposed a channel, or lost the uniform moves a channel by far + /// more than one code. + /// + private const int MaximumLoweringStorageCodeDistance = 1; + + private static readonly Rect s_bounds = new(0, 0, 24, 16); + + [TestCase(0f)] + [TestCase(0.125f)] + [TestCase(0.375f)] + [TestCase(0.73f)] + [TestCase(1f)] + [Category("GpuPassFusionGpu")] + public void OpacityDescription_NativeSpirvMatchesSkslExactly(float opacity) + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + (ushort[] source, RenderExecutionStatistics sourceStatistics) = + Render(ShaderBackendPreference.Sksl, 1); + (ushort[] sksl, RenderExecutionStatistics expectedStatistics) = + Render(ShaderBackendPreference.Sksl, opacity); + ushort[] spirv = RenderNativeSpirv(source, opacity); + + Assert.Multiple(() => + { + Assert.That(sourceStatistics.SpirvShaderRunExecutions, Is.Zero); + Assert.That(expectedStatistics.SpirvShaderRunExecutions, Is.Zero); + Assert.That( + MaximumStorageCodeDistance(spirv, sksl), + Is.LessThanOrEqualTo(MaximumLoweringStorageCodeDistance), + "The native SPIR-V lowering must reproduce the SkSL premultiplied-linear RGBA16F " + + "result to within the rounding the two shader compilers are free to differ by."); + if (opacity > 0) + Assert.That(sksl, Has.Some.Not.Zero, "the comparison must not be vacuous"); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void OpacityDescription_AutoUsesBitExactSkslFallback() + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + (ushort[] expected, _) = Render(ShaderBackendPreference.Sksl, 0.375f); + (ushort[] actual, RenderExecutionStatistics statistics) = + Render(ShaderBackendPreference.Auto, 0.375f); + + Assert.Multiple(() => + { + Assert.That(statistics.SpirvShaderRunExecutions, Is.Zero); + Assert.That(actual, Is.EqualTo(expected)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void OpacityDescription_ExplicitSpirvReportsNonExactSkiaHandoff() + { + GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + Assert.That( + () => Render(ShaderBackendPreference.Spirv, 0.375f), + Throws.TypeOf() + .With.Message.Contains("cannot be handed to the Skia compositor bit-exactly")); + }); + } + + // Half is sign-magnitude, so its raw codes are not monotonic across zero. Mirroring the negative + // half restores the ordering that makes adjacent representable values exactly one apart. + private static int MaximumStorageCodeDistance(ushort[] left, ushort[] right) + { + Assert.That(left, Has.Length.EqualTo(right.Length)); + int maximum = 0; + for (int i = 0; i < left.Length; i++) + maximum = Math.Max(maximum, Math.Abs(OrderedHalfCode(left[i]) - OrderedHalfCode(right[i]))); + return maximum; + } + + private static int OrderedHalfCode(ushort bits) + { + int magnitude = bits & 0x7FFF; + return (bits & 0x8000) != 0 ? -magnitude : magnitude; + } + + private static ushort[] RenderNativeSpirv(ushort[] sourcePixels, float opacity) + { + IGraphicsContext context = GpuTestEnvironment.SharedContext; + using ITexture2D source = context.CreateTexture2D(24, 16, TextureFormat.RGBA16Float); + using ITexture2D destination = context.CreateTexture2D(24, 16, TextureFormat.RGBA16Float); + source.Upload(MemoryMarshal.AsBytes(sourcePixels.AsSpan())); + + SpirvShaderLowering lowering = OpacityRenderNode.CreateFusionDescription(opacity).SpirvLowering + ?? throw new AssertionException("The opacity description must provide a native SPIR-V lowering."); + using GLSLFilterPipeline pipeline = GLSLFilterPipeline.Create( + context, + lowering.FragmentShaderSource, + ShaderOutputCoverage.ProvablyFull) + ?? throw new AssertionException("The native opacity pipeline could not be created."); + SpirvPushConstants pushConstants = default; + Span bytes = pushConstants; + BinaryPrimitives.WriteInt32LittleEndian( + bytes.Slice(SpirvPushConstants.UserByteOffset, sizeof(float)), + BitConverter.SingleToInt32Bits(opacity)); + + pipeline.Execute(source, destination, pushConstants); + byte[] result = destination.DownloadPixels(); + return MemoryMarshal.Cast(result).ToArray(); + } + + private static (ushort[] Pixels, RenderExecutionStatistics Statistics) Render( + ShaderBackendPreference backendPreference, + float opacity) + { + using Brush.Resource gradient = CreateGradient(); + using var source = new RectangleRenderNode(s_bounds, gradient, pen: null); + using var root = new OpacityShaderNode(source, opacity); + using CompiledRenderRequest compiled = Compile(root); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession(RenderIntent.Preview); + using RenderTargetLease output = targets.Acquire(PixelRect.FromRect(s_bounds, 1).Size); + using var canvas = new ImmediateCanvas(output.Target, 1, 1, s_bounds.Size); + canvas.Clear(); + var executor = new RenderRequestExecutor( + targets, + shaderBackendPreference: backendPreference); + executor.Execute(compiled, canvas, replayBounds: s_bounds); + using Bitmap bitmap = output.Target.Snapshot(); + return (bitmap.GetPixelSpan().ToArray(), executor.Statistics); + } + + private static Brush.Resource CreateGradient() + { + var gradient = new LinearGradientBrush(); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(255, 255, 17, 31), 0)); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(192, 23, 240, 83), 0.33f)); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(96, 19, 47, 255), 0.67f)); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(255, 241, 193, 7), 1)); + return (Brush.Resource)gradient.ToResource(CompositionContext.Default); + } + + private static CompiledRenderRequest Compile(RenderNode root) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return new RenderRequestCompiler().Compile( + request, + graph, + SkslBackendBudgetResolver.Portable); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class OpacityShaderNode(RenderNode source, float opacity) : RenderNode + { + private readonly ShaderDescription _description = + OpacityRenderNode.CreateFusionDescription(opacity); + + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.RecordSubtree(source)) + context.Publish(context.Shader(input, _description)); + } + } +} diff --git a/tests/Beutl.Graphics3DTests/TestCategories.cs b/tests/Beutl.Graphics3DTests/TestCategories.cs new file mode 100644 index 0000000000..11e1b23280 --- /dev/null +++ b/tests/Beutl.Graphics3DTests/TestCategories.cs @@ -0,0 +1,12 @@ +namespace Beutl.Graphics3DTests; + +/// Category names shared across the suite. +internal static class TestCategories +{ + /// + /// + /// The string has to match the one in Beutl.UnitTests, because the validation job filters both + /// assemblies on the same category name. + /// + public const string KnownVulkanSkiaLayoutInterop = "KnownVulkanSkiaLayoutInterop"; +} diff --git a/tests/Beutl.Graphics3DTests/VulkanContextIsolationTests.cs b/tests/Beutl.Graphics3DTests/VulkanContextIsolationTests.cs new file mode 100644 index 0000000000..ee46adbf51 --- /dev/null +++ b/tests/Beutl.Graphics3DTests/VulkanContextIsolationTests.cs @@ -0,0 +1,245 @@ +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Composite; +using Beutl.Graphics.Backend.Vulkan; +using Beutl.Media; +using Silk.NET.Vulkan; + +namespace Beutl.Graphics3DTests; + +/// +/// Pins the boundary checks the Vulkan backend owes its callers: a handle means nothing outside the device +/// that made it, and a render pass instance cannot contain another on the same command buffer. Vulkan +/// diagnoses neither, so both have to be rejected before a native call. +/// +[TestFixture] +[NonParallelizable] +public sealed class VulkanContextIsolationTests +{ + private const int Width = 16; + private const int Height = 8; + + [Test] + [Category("GpuPassFusionGpu")] + public void ASecondRenderPass_IsRejectedWhileAnotherIsRecording() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using IRenderPass3D outer = context.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using ITexture2D outerColor = CreateColorTexture(context); + using IFramebuffer3D outerFramebuffer = context.CreateFramebuffer3D(outer, [outerColor], null); + using IRenderPass3D inner = context.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using ITexture2D innerColor = CreateColorTexture(context); + using IFramebuffer3D innerFramebuffer = context.CreateFramebuffer3D(inner, [innerColor], null); + + outer.Begin(outerFramebuffer, [Colors.Transparent]); + try + { + Assert.That( + () => inner.Begin(innerFramebuffer, [Colors.Transparent]), + Throws.InvalidOperationException, + "Vulkan forbids a render pass instance inside another on the same command buffer."); + } + finally + { + outer.End(); + } + + Assert.That( + () => inner.Begin(innerFramebuffer, [Colors.Transparent]), + Throws.Nothing, + "The rejected attempt must not leave the batch claimed."); + inner.End(); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void AFramebufferFromAnotherContext_IsRejected() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using IGraphicsContext foreign = GraphicsContextFactory.CreateContext(); + using IRenderPass3D pass = context.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using IRenderPass3D foreignPass = foreign.CreateRenderPass3D([TextureFormat.RGBA8Unorm], null); + using ITexture2D foreignColor = CreateColorTexture(foreign); + using IFramebuffer3D foreignFramebuffer = + foreign.CreateFramebuffer3D(foreignPass, [foreignColor], null); + + Assert.Multiple(() => + { + Assert.That( + () => context.CreateFramebuffer3D(pass, [foreignColor], null), + Throws.ArgumentException, + "A texture allocated on another device cannot back this context's framebuffer."); + Assert.That( + () => pass.Begin(foreignFramebuffer, [Colors.Transparent]), + Throws.ArgumentException, + "A framebuffer from another device cannot be bound here."); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ABufferFromAnotherContext_IsRejectedByACopy() + { + IGraphicsContext context = GpuTestEnvironment.EnsureAvailable(); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using IGraphicsContext foreign = GraphicsContextFactory.CreateContext(); + using IBuffer local = context.CreateBuffer( + 16, + BufferUsage.TransferSource | BufferUsage.TransferDestination, + MemoryProperty.HostVisible | MemoryProperty.HostCoherent); + using IBuffer other = foreign.CreateBuffer( + 16, + BufferUsage.TransferSource | BufferUsage.TransferDestination, + MemoryProperty.HostVisible | MemoryProperty.HostCoherent); + + Assert.Multiple(() => + { + Assert.That(() => context.CopyBuffer(other, local, 16), Throws.ArgumentException); + Assert.That(() => context.CopyBuffer(local, other, 16), Throws.ArgumentException); + }); + }); + } + + /// + /// Skia's allocator picks whichever bind entry point the device exposes. Intercepting only the 1.0 form + /// let a scratch image bound through the core 1.1 or KHR form skip the transparent clear and show + /// whatever the reused allocation last held. + /// + [Test] + [Category("GpuPassFusionGpu")] + public void EveryBindEntryPointTheDeviceExposes_IsIntercepted() + { + VulkanContext vulkanContext = ResolveVulkan(GpuTestEnvironment.EnsureAvailable()); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + Vk vk = vulkanContext.Vk; + Device device = vulkanContext.Device; + foreach (string name in new[] { "vkBindImageMemory", "vkBindImageMemory2", "vkBindImageMemory2KHR" }) + { + nint real = vk.GetDeviceProcAddr(device, name); + if (real == 0) + { + TestContext.WriteLine($"{name}: not exposed by this device"); + continue; + } + + nint resolved = vulkanContext.GetVulkanProcAddress(name, IntPtr.Zero, device.Handle); + Assert.That(resolved, Is.Not.EqualTo(real), $"{name} must resolve to the initializing proxy."); + Assert.That(resolved, Is.Not.EqualTo(IntPtr.Zero)); + } + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void TheLogicalDevice_EnablesTheShaderFeaturesItAdvertises() + { + VulkanContext vulkanContext = ResolveVulkan(GpuTestEnvironment.EnsureAvailable()); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + PhysicalDeviceFeatures available = ReadAdvertisedFeatures(vulkanContext); + TestContext.WriteLine( + $"advertised int64={available.ShaderInt64} float64={available.ShaderFloat64} " + + $"cubeArray={available.ImageCubeArray}"); + Assert.Multiple(() => + { + Assert.That(vulkanContext.SupportsShaderInt64, Is.EqualTo((bool)available.ShaderInt64)); + Assert.That(vulkanContext.SupportsShaderFloat64, Is.EqualTo((bool)available.ShaderFloat64)); + Assert.That( + vulkanContext.SupportsImageCubeArray, + Is.EqualTo((bool)available.ImageCubeArray), + "point-light shadows sample a cube array, so the device has to request the feature " + + "its own views and shaders rely on"); + }); + }); + } + + /// + /// A shadow atlas binds its whole array to the lighting pass while only the lights actually present + /// fill a slot. A slot nothing wrote to used to stay in UNDEFINED, so the descriptor handed the sampler + /// an image in a layout it may not read - undefined behaviour the driver need not report, and what + /// validation flags as InvalidImageLayout on every unfilled slot. + /// + [Test] + [Category("GpuPassFusionGpu")] + public void AFreshArrayTexture_IsReadableInEverySlotBeforeAnythingWritesToIt() + { + VulkanContext context = ResolveVulkan(GpuTestEnvironment.EnsureAvailable()); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + // Through the context, which picks the usage flags the format supports. Constructing the texture + // directly would let this fixture ask for a depth attachment backed by a colour format. + using var array = (VulkanTextureArray)context.CreateTextureArray( + Width, + Height, + 4, + TextureFormat.RGBA8Unorm); + + using (Assert.EnterMultipleScope()) + { + for (uint layer = 0; layer < 4; layer++) + { + Assert.That( + array.GetLayerLayout(layer), + Is.EqualTo(ImageLayout.ShaderReadOnlyOptimal), + $"layer {layer}"); + } + } + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void AFreshCubeArrayTexture_IsReadableInEveryFaceBeforeAnythingWritesToIt() + { + VulkanContext context = ResolveVulkan(GpuTestEnvironment.EnsureAvailable()); + GpuTestEnvironment.InvokeOnRenderThread(() => + { + using var cubes = (VulkanTextureCubeArray)context.CreateTextureCubeArray( + Height, + 2, + TextureFormat.Depth32Float); + + using (Assert.EnterMultipleScope()) + { + for (uint cube = 0; cube < 2; cube++) + { + for (int face = 0; face < 6; face++) + { + Assert.That( + cubes.GetFaceLayout(cube, face), + Is.EqualTo(ImageLayout.ShaderReadOnlyOptimal), + $"cube {cube} face {face}"); + } + } + } + }); + } + + // On macOS the shared context is a CompositeContext pairing a Metal context with the Vulkan one that + // owns the device; everywhere else it is the Vulkan context itself. + private static VulkanContext ResolveVulkan(IGraphicsContext context) + => context switch + { + VulkanContext vulkan => vulkan, + CompositeContext composite => composite.Vulkan, + _ => throw new InvalidOperationException( + $"'{context.GetType().Name}' is not backed by a Vulkan context."), + }; + + private static unsafe PhysicalDeviceFeatures ReadAdvertisedFeatures(VulkanContext context) + { + PhysicalDeviceFeatures features; + context.Vk.GetPhysicalDeviceFeatures(context.PhysicalDevice, &features); + return features; + } + + private static ITexture2D CreateColorTexture(IGraphicsContext context) + => context.CreateTexture2D(Width, Height, TextureFormat.RGBA8Unorm); +} diff --git a/tests/Beutl.HeadlessUITests/DrawableBrushThumbnailTests.cs b/tests/Beutl.HeadlessUITests/DrawableBrushThumbnailTests.cs new file mode 100644 index 0000000000..55873b3e93 --- /dev/null +++ b/tests/Beutl.HeadlessUITests/DrawableBrushThumbnailTests.cs @@ -0,0 +1,916 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reactive.Subjects; +using Avalonia.Headless.NUnit; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Threading; +using AvaDispatcher = Avalonia.Threading.Dispatcher; +using AvaImageBrush = Avalonia.Media.ImageBrush; +using AvaPixelSize = Avalonia.PixelSize; +using AvaPropertyChangedEventArgs = Avalonia.AvaloniaPropertyChangedEventArgs; +using AvaStretch = Avalonia.Media.Stretch; + +namespace Beutl.HeadlessUITests; + +[NonParallelizable] +[TestFixture] +public class DrawableBrushThumbnailTests +{ + [AvaloniaTest] + public async Task Update_publishes_initial_thumbnail_and_propagates_resource_changes() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(40, 24), + TimeSpan.FromSeconds(5)); + + var first = (WriteableBitmap)imageBrush.Source!; + uint initialPixel = SamplePixel(first, 20, 12); + Assert.That(imageBrush.Stretch, Is.EqualTo(AvaStretch.Uniform)); + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + drawableBrush.Stretch.CurrentValue = Stretch.None; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && !ReferenceEquals(bitmap, first) + && bitmap.PixelSize == new AvaPixelSize(72, 36), + TimeSpan.FromSeconds(5)); + var second = (WriteableBitmap)imageBrush.Source!; + uint updatedPixel = SamplePixel(second, 36, 18); + + Assert.Multiple(() => + { + Assert.That(imageBrush.Stretch, Is.EqualTo(AvaStretch.None)); + Assert.That(imageBrush.Source, Is.Not.SameAs(first)); + Assert.That( + second.PixelSize, + Is.EqualTo(new AvaPixelSize(72, 36))); + Assert.That((initialPixel & 0xFF), Is.GreaterThan(200), "The initial thumbnail must be red."); + Assert.That(((initialPixel >> 16) & 0xFF), Is.LessThan(30), "The initial thumbnail must not be blue."); + Assert.That(((updatedPixel >> 16) & 0xFF), Is.GreaterThan(200), "The updated thumbnail must be blue."); + Assert.That((updatedPixel & 0xFF), Is.LessThan(30), "The updated thumbnail must not be red."); + Assert.That((initialPixel >> 24), Is.GreaterThan(200)); + Assert.That((updatedPixel >> 24), Is.GreaterThan(200)); + }); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + + Assert.That(imageBrush.Source, Is.Null); + } + + private static uint SamplePixel(WriteableBitmap bitmap, int x, int y) + { + using ILockedFramebuffer buffer = bitmap.Lock(); + Assert.That( + buffer.Format, + Is.EqualTo(Avalonia.Platform.PixelFormat.Rgba8888) + .Or.EqualTo(Avalonia.Platform.PixelFormat.Bgra8888), + $"SamplePixel assumes a 32bpp RGBA or BGRA bitmap but the format is {buffer.Format}."); + Assert.That(x, Is.InRange(0, buffer.Size.Width - 1)); + Assert.That(y, Is.InRange(0, buffer.Size.Height - 1)); + + uint pixel; + unsafe + { + byte* row = (byte*)buffer.Address + (y * buffer.RowBytes); + pixel = ((uint*)row)[x]; + } + + return buffer.Format == Avalonia.Platform.PixelFormat.Bgra8888 + ? (pixel & 0xFF00FF00) | ((pixel & 0x000000FF) << 16) | ((pixel & 0x00FF0000) >> 16) + : pixel; + } + + [AvaloniaTest] + public async Task Superseded_update_never_publishes_its_stale_thumbnail() + { + GpuTestGate.EnsureAvailable(); + var staleDrawable = new BlockingThumbnailDrawable(40, 24, Brushes.Resource.Red); + var drawableBrush = new DrawableBrush(staleDrawable); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var publishedSizes = new ConcurrentQueue(); + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property == AvaImageBrush.SourceProperty + && imageBrush.Source is WriteableBitmap bitmap) + { + publishedSizes.Enqueue(bitmap.PixelSize); + } + }; + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await staleDrawable.RenderEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + UpdateResource(resource, drawableBrush); + handler.Update(); + staleDrawable.ReleaseRender(); + + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(72, 36), + TimeSpan.FromSeconds(5)); + + Assert.That(publishedSizes.ToArray(), Is.EqualTo(new[] { new AvaPixelSize(72, 36) })); + } + finally + { + staleDrawable.ReleaseRender(); + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + } + + [AvaloniaTest] + public async Task Superseded_queued_publication_is_discarded_before_ui_delivery() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var publishedSizes = new ConcurrentQueue(); + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property == AvaImageBrush.SourceProperty + && imageBrush.Source is WriteableBitmap bitmap) + { + publishedSizes.Enqueue(bitmap.PixelSize); + } + }; + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + Assert.That(AvaDispatcher.UIThread.CheckAccess(), Is.True); + handler.Update(); + RenderThread.Dispatcher.Invoke( + static () => { }, + DispatchPriority.Low, + CancellationToken.None); + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(72, 36), + TimeSpan.FromSeconds(5)); + + Assert.That( + publishedSizes.ToArray(), + Is.EqualTo(new[] { new AvaPixelSize(72, 36) })); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + } + + [AvaloniaTest] + public async Task Reentrant_source_callback_can_dispose_without_lock_inversion() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + var callbackResult = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Task? disposalTask = null; + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property != AvaImageBrush.SourceProperty + || imageBrush.Source is not WriteableBitmap) + { + return; + } + + disposalTask = Task.Run(() => + { + handler.Dispose(); + handler.Dispose(); + }); + callbackResult.TrySetResult(disposalTask.Wait(TimeSpan.FromSeconds(1))); + }; + + try + { + handler.Update(); + + Assert.That( + await callbackResult.Task.WaitAsync(TimeSpan.FromSeconds(5)), + Is.True, + "A synchronous Source callback must not wait on the handler publication lock."); + await disposalTask!.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync( + () => resource.IsDisposed && imageBrush.Source is null, + TimeSpan.FromSeconds(5)); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + + Assert.That(imageBrush.Source, Is.Null); + } + + [AvaloniaTest] + public async Task Superseding_update_during_stretch_notification_cancels_pending_source_publication() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(40, 24), + TimeSpan.FromSeconds(5)); + + var first = (WriteableBitmap)imageBrush.Source!; + var replacementPublications = new ConcurrentQueue(); + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property == AvaImageBrush.SourceProperty + && imageBrush.Source is WriteableBitmap bitmap + && !ReferenceEquals(bitmap, first)) + { + replacementPublications.Enqueue(bitmap); + } + }; + + int superseded = 0; + bool supersedingUpdateCompleted = false; + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property == AvaImageBrush.StretchProperty + && imageBrush.Stretch == AvaStretch.None + && Interlocked.Exchange(ref superseded, 1) == 0) + { + supersedingUpdateCompleted = Task.Run(handler.Update) + .Wait(TimeSpan.FromSeconds(1)); + } + }; + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + drawableBrush.Stretch.CurrentValue = Stretch.None; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && !ReferenceEquals(bitmap, first) + && bitmap.PixelSize == new AvaPixelSize(72, 36), + TimeSpan.FromSeconds(5)); + RenderThread.Dispatcher.Invoke( + static () => { }, + DispatchPriority.Low, + CancellationToken.None); + AvaDispatcher.UIThread.RunJobs(); + + Assert.Multiple(() => + { + Assert.That( + supersedingUpdateCompleted, + Is.True, + "A superseding update must be able to cancel the publishing update outside the gate."); + Assert.That( + replacementPublications.Count, + Is.EqualTo(1), + "The canceled publication must not assign its stale bitmap before the replacement."); + }); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + + Assert.That(imageBrush.Source, Is.Null); + } + + [AvaloniaTest] + public async Task Reentrant_source_callback_that_restores_previous_bitmap_prevents_commit() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(40, 24), + TimeSpan.FromSeconds(5)); + + var first = (WriteableBitmap)imageBrush.Source!; + WriteableBitmap? rejected = null; + EventHandler restorePrevious = (_, args) => + { + if (args.Property == AvaImageBrush.SourceProperty + && imageBrush.Source is WriteableBitmap bitmap + && !ReferenceEquals(bitmap, first) + && rejected is null) + { + rejected = bitmap; + imageBrush.Source = first; + } + }; + imageBrush.PropertyChanged += restorePrevious; + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + drawableBrush.Stretch.CurrentValue = Stretch.None; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => rejected is not null && ReferenceEquals(imageBrush.Source, first), + TimeSpan.FromSeconds(5)); + imageBrush.PropertyChanged -= restorePrevious; + + Assert.Multiple(() => + { + Assert.That(imageBrush.Source, Is.SameAs(first)); + Assert.That(imageBrush.Stretch, Is.EqualTo(AvaStretch.Uniform)); + }); + using (first.Lock()) + { + } + + Assert.That( + CanLock(rejected!), + Is.False, + "The rejected bitmap must be disposed after the reentrant publication is rolled back."); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + + Assert.That(imageBrush.Source, Is.Null); + } + + [AvaloniaTest] + public async Task Throwing_publication_callbacks_roll_back_bitmap_and_stretch_ownership() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(40, 24), + TimeSpan.FromSeconds(5)); + + var first = (WriteableBitmap)imageBrush.Source!; + WriteableBitmap? rejected = null; + EventHandler throwOnReplacement = (_, args) => + { + if (args.Property == AvaImageBrush.SourceProperty + && imageBrush.Source is WriteableBitmap bitmap + && !ReferenceEquals(bitmap, first)) + { + rejected = bitmap; + throw new InvalidOperationException("Injected publication failure."); + } + }; + imageBrush.PropertyChanged += throwOnReplacement; + int stretchRollbackFailures = 0; + EventHandler throwOnStretchRollback = (_, args) => + { + if (args.Property == AvaImageBrush.StretchProperty + && imageBrush.Stretch == AvaStretch.Uniform + && rejected is not null) + { + stretchRollbackFailures++; + throw new InvalidOperationException("Injected stretch rollback failure."); + } + }; + imageBrush.PropertyChanged += throwOnStretchRollback; + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + drawableBrush.Stretch.CurrentValue = Stretch.None; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => rejected is not null && ReferenceEquals(imageBrush.Source, first), + TimeSpan.FromSeconds(5)); + imageBrush.PropertyChanged -= throwOnReplacement; + imageBrush.PropertyChanged -= throwOnStretchRollback; + + Assert.Multiple(() => + { + Assert.That(imageBrush.Source, Is.SameAs(first)); + Assert.That(imageBrush.Stretch, Is.EqualTo(AvaStretch.Uniform)); + Assert.That(rejected, Is.Not.Null); + Assert.That(rejected, Is.Not.SameAs(first)); + Assert.That(stretchRollbackFailures, Is.EqualTo(1)); + }); + using (first.Lock()) + { + } + + Assert.That( + () => + { + using var _ = rejected!.Lock(); + }, + Throws.Exception); + + drawableBrush.Drawable.CurrentValue = CreateRectangle(96, 48, Colors.Green); + drawableBrush.Stretch.CurrentValue = Stretch.UniformToFill; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(96, 48), + TimeSpan.FromSeconds(5)); + Assert.Multiple(() => + { + Assert.That(imageBrush.Source, Is.Not.SameAs(first)); + Assert.That(imageBrush.Source, Is.Not.SameAs(rejected)); + Assert.That(imageBrush.Stretch, Is.EqualTo(AvaStretch.UniformToFill)); + }); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + + Assert.That(imageBrush.Source, Is.Null); + } + + [AvaloniaTest] + public async Task Dispose_during_empty_update_cancels_publication_and_releases_resource_once_idle() + { + var blockingDrawable = new BlockingThumbnailDrawable(0, 0, null); + var drawableBrush = new DrawableBrush(blockingDrawable); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await blockingDrawable.RenderEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + handler.Dispose(); + Assert.Multiple(() => + { + Assert.That(resource.IsDisposed, Is.False, + "The resource must remain alive until the render-thread update exits."); + Assert.That(imageBrush.Source, Is.Null); + }); + + blockingDrawable.ReleaseRender(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + + handler.Update(); + await RenderThread.Dispatcher.InvokeAsync( + static () => { }, + DispatchPriority.Low, + CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(resource.IsDisposed, Is.True); + Assert.That(imageBrush.Source, Is.Null); + }); + + handler.Dispose(); + } + finally + { + blockingDrawable.ReleaseRender(); + handler.Dispose(); + } + } + + [AvaloniaTest] + public void Immediate_subscription_disposal_cancels_resource_creation() + { + var blockerEntered = new ManualResetEventSlim(); + var releaseBlocker = new ManualResetEventSlim(); + IDisposable? subscription = null; + + try + { + RenderThread.Dispatcher.Dispatch( + () => + { + blockerEntered.Set(); + releaseBlocker.Wait(); + }, + DispatchPriority.High); + Assert.That(blockerEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); + + var drawableBrush = new DisposalTrackingDrawableBrush(); + using var clock = new BehaviorSubject(TimeSpan.Zero); + (_, subscription, _) = drawableBrush.ToAvaBrushSync(clock); + subscription.Dispose(); + + releaseBlocker.Set(); + RenderThread.Dispatcher.Invoke( + static () => { }, + DispatchPriority.Low, + CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(drawableBrush.ResourceUpdateCalls, Is.Zero); + Assert.That(drawableBrush.ResourceDisposeCalls, Is.Zero); + }); + } + finally + { + subscription?.Dispose(); + releaseBlocker.Set(); + RenderThread.Dispatcher.Invoke( + static () => { }, + DispatchPriority.Low, + CancellationToken.None); + blockerEntered.Dispose(); + releaseBlocker.Dispose(); + } + } + + [AvaloniaTest] + public async Task Resource_lifetime_and_updates_are_serialized_on_render_dispatcher() + { + var drawableBrush = new DisposalTrackingDrawableBrush(); + using var clock = new BehaviorSubject(TimeSpan.Zero); + (_, IDisposable subscription, _) = drawableBrush.ToAvaBrushSync(clock); + + await WaitUntilAsync( + () => drawableBrush.ResourceUpdateCalls == 1, + TimeSpan.FromSeconds(5)); + + clock.OnNext(TimeSpan.FromSeconds(1)); + await WaitUntilAsync( + () => drawableBrush.ResourceUpdateCalls == 2, + TimeSpan.FromSeconds(5)); + + subscription.Dispose(); + + await WaitUntilAsync( + () => drawableBrush.ResourceDisposeCalls == 1, + TimeSpan.FromSeconds(5)); + Assert.Multiple(() => + { + Assert.That(drawableBrush.ResourceDisposeCalls, Is.EqualTo(1)); + Assert.That( + drawableBrush.ResourceCreationThreadId, + Is.EqualTo(RenderThread.Dispatcher.Thread.ManagedThreadId)); + Assert.That( + drawableBrush.LastResourceUpdateThreadId, + Is.EqualTo(RenderThread.Dispatcher.Thread.ManagedThreadId)); + Assert.That( + drawableBrush.ResourceDisposalThreadId, + Is.EqualTo(RenderThread.Dispatcher.Thread.ManagedThreadId)); + }); + } + + [AvaloniaTest] + public void Render_dispatcher_shutdown_abandons_queued_update_and_releases_resource() + { + var drawableBrush = new DrawableBrush(); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource( + new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var blockerEntered = new ManualResetEventSlim(); + var releaseBlocker = new ManualResetEventSlim(); + Dispatcher dispatcher = Dispatcher.Spawn(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler( + resource, + imageBrush, + dispatcher); + + try + { + dispatcher.Dispatch( + () => + { + blockerEntered.Set(); + releaseBlocker.Wait(); + }, + DispatchPriority.High); + Assert.That(blockerEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); + + handler.Update(); + handler.Dispose(); + Assert.That(resource.IsDisposed, Is.False); + + dispatcher.Shutdown(); + + Assert.Multiple(() => + { + Assert.That(resource.IsDisposed, Is.True); + Assert.That(imageBrush.Source, Is.Null); + }); + } + finally + { + handler.Dispose(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + releaseBlocker.Set(); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True); + blockerEntered.Dispose(); + releaseBlocker.Dispose(); + resource.Dispose(); + } + } + + [AvaloniaTest] + public void Handler_attached_to_an_already_stopped_dispatcher_still_releases_its_resource() + { + var drawableBrush = new DrawableBrush(); + var resource = (DrawableBrush.Resource)drawableBrush.ToResource( + new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + Dispatcher dispatcher = Dispatcher.Spawn(); + var loopEntered = new ManualResetEventSlim(); + + // Shutdown racing Start is swallowed and leaves the loop running forever, so wait until the + // loop is demonstrably live before stopping it. + dispatcher.Dispatch(loopEntered.Set, DispatchPriority.High); + Assert.That(loopEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); + dispatcher.Shutdown(); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True); + + // ShutdownStarted is one-shot and already fired, so this handler never receives it. + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler( + resource, + imageBrush, + dispatcher); + + try + { + handler.Update(); + handler.Dispose(); + + Assert.That(resource.IsDisposed, Is.True); + } + finally + { + handler.Dispose(); + loopEntered.Dispose(); + resource.Dispose(); + } + } + + [AvaloniaTest] + public async Task Grouped_content_publishes_a_thumbnail() + { + GpuTestGate.EnsureAvailable(); + var group = new DrawableGroup(); + group.Children.Add(CreateRectangle(40, 24, Colors.Red)); + var drawableBrush = new DrawableBrush(group); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap, + TimeSpan.FromSeconds(5)); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + } + + // Dispose clears the published thumbnail, so a rollback triggered by that same disposal must not + // put the previous one back: the handler would be gone while still owning the bitmap it reinstated. + [AvaloniaTest] + public async Task Disposal_during_stretch_notification_leaves_no_thumbnail_behind() + { + GpuTestGate.EnsureAvailable(); + var drawableBrush = new DrawableBrush(CreateRectangle(40, 24, Colors.Red)); + drawableBrush.Stretch.CurrentValue = Stretch.Uniform; + var resource = (DrawableBrush.Resource)drawableBrush.ToResource(new CompositionContext(TimeSpan.Zero)); + var imageBrush = new AvaImageBrush(); + var handler = new AvaloniaTypeConverter.DrawableImageBrushHandler(resource, imageBrush); + + try + { + handler.Update(); + await WaitUntilAsync( + () => imageBrush.Source is WriteableBitmap bitmap + && bitmap.PixelSize == new AvaPixelSize(40, 24), + TimeSpan.FromSeconds(5)); + + int disposedOnce = 0; + imageBrush.PropertyChanged += (_, args) => + { + if (args.Property == AvaImageBrush.StretchProperty + && imageBrush.Stretch == AvaStretch.None + && Interlocked.Exchange(ref disposedOnce, 1) == 0) + { + handler.Dispose(); + } + }; + + drawableBrush.Drawable.CurrentValue = CreateRectangle(72, 36, Colors.Blue); + drawableBrush.Stretch.CurrentValue = Stretch.None; + UpdateResource(resource, drawableBrush); + handler.Update(); + + await WaitUntilAsync(() => disposedOnce == 1, TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + + Assert.That(imageBrush.Source, Is.Null, + "a disposed handler must not leave the previous thumbnail installed"); + } + finally + { + handler.Dispose(); + await WaitUntilAsync(() => resource.IsDisposed, TimeSpan.FromSeconds(5)); + } + } + + private static RectShape CreateRectangle(float width, float height, Color color) + { + return new RectShape + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + Fill = { CurrentValue = new SolidColorBrush(color) }, + }; + } + + private static void UpdateResource(DrawableBrush.Resource resource, DrawableBrush drawableBrush) + { + bool updateOnly = false; + resource.Update(drawableBrush, new CompositionContext(TimeSpan.Zero), ref updateOnly); + } + + private static bool CanLock(WriteableBitmap bitmap) + { + try + { + using var _ = bitmap.Lock(); + return true; + } + catch + { + return false; + } + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + AvaDispatcher.UIThread.RunJobs(); + if (stopwatch.Elapsed >= timeout) + Assert.Fail($"Condition was not met within {timeout}."); + + await Task.Delay(10); + } + } +} + +internal sealed partial class BlockingThumbnailDrawable( + float width, + float height, + Brush.Resource? fill) : Drawable +{ + private readonly TaskCompletionSource _renderEntered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseRender = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource RenderEntered => _renderEntered; + + public void ReleaseRender() => _releaseRender.TrySetResult(true); + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + if (width > 0 && height > 0 && fill is not null) + { + context.DrawRectangle( + new Rect(0, 0, width, height), + fill, + null); + } + + _renderEntered.TrySetResult(true); + _releaseRender.Task.GetAwaiter().GetResult(); + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(width, height); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} + +internal sealed partial class DisposalTrackingDrawableBrush : DrawableBrush +{ + private int _resourceCreationThreadId; + private int _resourceDisposalThreadId; + private int _lastResourceUpdateThreadId; + private int _resourceDisposeCalls; + private int _resourceUpdateCalls; + + public int ResourceCreationThreadId => Volatile.Read(ref _resourceCreationThreadId); + + public int ResourceDisposalThreadId => Volatile.Read(ref _resourceDisposalThreadId); + + public int LastResourceUpdateThreadId => Volatile.Read(ref _lastResourceUpdateThreadId); + + public int ResourceDisposeCalls => Volatile.Read(ref _resourceDisposeCalls); + + public int ResourceUpdateCalls => Volatile.Read(ref _resourceUpdateCalls); + + public partial class Resource + { + private DisposalTrackingDrawableBrush? _owner; + + partial void PostUpdate(DisposalTrackingDrawableBrush obj, CompositionContext context) + { + _owner = obj; + int updateCount = Interlocked.Increment(ref obj._resourceUpdateCalls); + if (updateCount == 1) + { + Volatile.Write( + ref obj._resourceCreationThreadId, + Environment.CurrentManagedThreadId); + } + else + { + Volatile.Write( + ref obj._lastResourceUpdateThreadId, + Environment.CurrentManagedThreadId); + } + } + + partial void PostDispose(bool disposing) + { + if (disposing && _owner is not null) + { + Volatile.Write( + ref _owner._resourceDisposalThreadId, + Environment.CurrentManagedThreadId); + Interlocked.Increment(ref _owner._resourceDisposeCalls); + } + } + } +} diff --git a/tests/Beutl.HeadlessUITests/PreviewRenderTests.cs b/tests/Beutl.HeadlessUITests/PreviewRenderTests.cs index 4b3db044df..417d541a5f 100644 --- a/tests/Beutl.HeadlessUITests/PreviewRenderTests.cs +++ b/tests/Beutl.HeadlessUITests/PreviewRenderTests.cs @@ -64,7 +64,7 @@ public async Task SceneRenderer_renders_a_non_empty_preview_frame() Bitmap snapshot = RenderThread.Dispatcher.Invoke(() => { - using var renderer = new SceneRenderer(scene); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); CompositionFrame frame = renderer.Compositor.EvaluateGraphics(TimeSpan.Zero); renderer.Render(frame); return renderer.Snapshot(); diff --git a/tests/Beutl.HeadlessUITests/SelectedDrawableRenderTests.cs b/tests/Beutl.HeadlessUITests/SelectedDrawableRenderTests.cs new file mode 100644 index 0000000000..52f48a8fbd --- /dev/null +++ b/tests/Beutl.HeadlessUITests/SelectedDrawableRenderTests.cs @@ -0,0 +1,442 @@ +using Avalonia.Headless.NUnit; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.Testing.Headless; +using Beutl.ViewModels; + +namespace Beutl.HeadlessUITests; + +[NonParallelizable] +[TestFixture] +public class SelectedDrawableRenderTests +{ + private static Task ResetProjectAsync() => TestReset.ResetShellAsync(); + + private static string NewWorkspace(string name) + { + string location = Path.Combine(BeutlHomeIsolation.CurrentHome!, name); + Directory.CreateDirectory(location); + return location; + } + + private static async Task OpenEditor(string name) + { + Project project = (await TestShell.Project.CreateProject( + 320, 240, 30, 44100, name, NewWorkspace(name)))!; + HeadlessTestHelpers.Settle(); + Scene scene = project.Items.OfType().First(); + + TestShell.Editor.ActivateTabItem(scene); + HeadlessTestHelpers.Settle(); + return (EditViewModel)TestShell.Editor.SelectedTabItem.Value!.Context.Value; + } + + [Test] + public void Selected_drawable_raster_region_uses_output_bounds_not_query_bounds() + { + var outputBounds = new Rect(0, 0, 320, 240); + var queryBounds = new Rect(37, 29, 48, 32); + var measurement = new RenderNodeMeasurement( + outputBounds, + queryBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + HasFragments: true, + HasContributingValues: false, + HasTargetEffects: true); + + Assert.That( + PlayerViewModel.GetSelectedDrawableRasterRegion(measurement), + Is.EqualTo(outputBounds)); + } + + [AvaloniaTest] + public async Task Shifted_selected_drawable_measure_matches_rasterization_and_caller_owns_result() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-shifted"); + var drawable = new RectShape + { + Width = { CurrentValue = 48 }, + Height = { CurrentValue = 32 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(37, 29) }, + Fill = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }; + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(drawable); + Bitmap playerBitmap = await editor.Player.DrawSelectedDrawable(drawable); + try + { + (RenderNodeMeasurement measurement, RenderNodeRasterization rasterization) = + RenderSelectedDrawable(drawable, editor.Renderer.Value.FrameSize); + try + { + Bitmap ownedBitmap = rasterization.Bitmap + ?? throw new AssertionException("The shifted non-empty drawable produced no bitmap."); + bool contentMatches = playerBitmap.GetPixelSpan() + .SequenceEqual(ownedBitmap.GetPixelSpan()); + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(37, 29, 48, 32))); + Assert.That(rasterization.Bounds, Is.EqualTo(measurement.OutputBounds)); + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(rasterization.IsDisposed, Is.False); + Assert.That(ownedBitmap.IsDisposed, Is.False, + "Disposing the renderer must not dispose its returned rasterization."); + Assert.That(measuredSize, Is.EqualTo(PixelRect.FromRect(measurement.OutputBounds).Size)); + Assert.That(playerBitmap.Width, Is.EqualTo(ownedBitmap.Width)); + Assert.That(playerBitmap.Height, Is.EqualTo(ownedBitmap.Height)); + Assert.That(contentMatches, Is.True, + "PlayerViewModel must return the same rendered pixels as direct rasterization."); + Assert.That(playerBitmap.IsDisposed, Is.False, + "PlayerViewModel must return a clone that survives disposal of its rasterization."); + }); + AssertOpaqueRedCenter(playerBitmap, "PlayerViewModel bitmap"); + AssertOpaqueRedCenter(ownedBitmap, "direct rasterization bitmap"); + + rasterization.Dispose(); + Assert.Multiple(() => + { + Assert.That(rasterization.IsDisposed, Is.True); + Assert.That(ownedBitmap.IsDisposed, Is.True, + "The caller-owned rasterization must dispose its bitmap."); + Assert.That( + () => _ = rasterization.Bitmap, + Throws.TypeOf()); + }); + + rasterization.Dispose(); + } + finally + { + rasterization.Dispose(); + } + } + finally + { + playerBitmap.Dispose(); + } + + Assert.That(playerBitmap.IsDisposed, Is.True); + } + + /// + /// "Save this element as an image" is about the element, not about what the scene happens to show. The + /// export used to render against the scene frame as its target domain, which is a hard output clip, so + /// an element hanging over the edge came out cropped and one entirely outside it produced no output at + /// all and threw. + /// + [AvaloniaTest] + public async Task Selected_drawable_outside_the_frame_is_still_exported_whole() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-off-frame"); + var drawable = new RectShape + { + Width = { CurrentValue = 48 }, + Height = { CurrentValue = 32 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + // The project frame is 320x240, so this sits entirely beyond its bottom-right corner. + Transform = { CurrentValue = new TranslateTransform(400, 300) }, + Fill = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }; + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(drawable); + Bitmap bitmap = await editor.Player.DrawSelectedDrawable(drawable); + try + { + Assert.Multiple(() => + { + Assert.That(measuredSize, Is.EqualTo(new PixelSize(48, 32))); + Assert.That(bitmap.Width, Is.EqualTo(48)); + Assert.That(bitmap.Height, Is.EqualTo(32)); + }); + AssertOpaqueRedCenter(bitmap, "off-frame export"); + } + finally + { + bitmap.Dispose(); + } + } + + /// An element straddling the edge keeps the half the frame does not show. + [AvaloniaTest] + public async Task Selected_drawable_straddling_the_frame_edge_keeps_its_hidden_half() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-straddling"); + var drawable = new RectShape + { + Width = { CurrentValue = 48 }, + Height = { CurrentValue = 32 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + // Half of its width hangs past the frame's right edge at x = 320. + Transform = { CurrentValue = new TranslateTransform(296, 100) }, + Fill = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }; + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(drawable); + + Assert.That(measuredSize, Is.EqualTo(new PixelSize(48, 32))); + } + + private static void AssertOpaqueRedCenter(Bitmap bitmap, string label) + { + Assert.That(bitmap.ColorType, Is.EqualTo(BitmapColorType.RgbaF16), label); + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int offset = (((bitmap.Height / 2) * bitmap.Width) + (bitmap.Width / 2)) * 4; + float red = (float)BitConverter.UInt16BitsToHalf(pixels[offset]); + float green = (float)BitConverter.UInt16BitsToHalf(pixels[offset + 1]); + float blue = (float)BitConverter.UInt16BitsToHalf(pixels[offset + 2]); + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3]); + + Assert.Multiple(() => + { + Assert.That(red, Is.EqualTo(1).Within(0.001), $"{label} center red"); + Assert.That(green, Is.EqualTo(0).Within(0.001), $"{label} center green"); + Assert.That(blue, Is.EqualTo(0).Within(0.001), $"{label} center blue"); + Assert.That(alpha, Is.EqualTo(1).Within(0.001), $"{label} center alpha"); + }); + } + + [AvaloniaTest] + public async Task Empty_selected_drawable_measure_matches_empty_rasterization() + { + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-empty"); + var drawable = new RectShape + { + Width = { CurrentValue = 0 }, + Height = { CurrentValue = 32 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(37, 29) }, + }; + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(drawable); + (RenderNodeMeasurement measurement, RenderNodeRasterization rasterization) = + RenderSelectedDrawable(drawable, editor.Renderer.Value.FrameSize); + + try + { + Assert.Multiple(() => + { + Assert.That(measuredSize, Is.EqualTo(PixelSize.Empty)); + Assert.That(measurement.OutputBounds, Is.EqualTo(Rect.Empty)); + Assert.That(rasterization.Bounds, Is.EqualTo(measurement.OutputBounds)); + Assert.That(rasterization.IsEmpty, Is.True); + Assert.That(rasterization.Bitmap, Is.Null); + }); + + // Assert.ThrowsAsync blocks the Avalonia UI thread and deadlocks the headless dispatcher, + // so await the empty-result failure inline with a bounded timeout. + InvalidOperationException? exception = null; + try + { + using Bitmap unexpected = await editor.Player.DrawSelectedDrawable(drawable) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Fail("An empty selected drawable must not produce a bitmap."); + } + catch (InvalidOperationException ex) + { + exception = ex; + } + + Assert.That(exception!.Message, Does.Contain("produced no raster output")); + + rasterization.Dispose(); + Assert.Multiple(() => + { + Assert.That(rasterization.IsDisposed, Is.True); + Assert.That( + () => _ = rasterization.Bitmap, + Throws.TypeOf()); + }); + } + finally + { + rasterization.Dispose(); + } + } + + /// + /// A group used to publish a full-target layer, so a bounds-dependent effect on it was measured + /// against the canvas and the same project rendered differently at a different scene resolution. + /// A group now publishes the bounds of what it holds, and the save path frames that extent rather + /// than the frame. Where the two bounds still diverge — a drawable that writes the target without + /// any query geometry — is covered by + /// . + /// + [AvaloniaTest] + public async Task Group_uses_its_content_extent_for_measurement_and_rasterization() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-full-target-group"); + var group = new DrawableGroup(); + group.Children.Add(new RectShape + { + Width = { CurrentValue = 48 }, + Height = { CurrentValue = 32 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(37, 29) }, + Fill = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }); + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(group); + using Bitmap bitmap = await editor.Player.DrawSelectedDrawable(group); + (RenderNodeMeasurement measurement, RenderNodeRasterization rasterization) = + RenderSelectedDrawable(group, editor.Renderer.Value.FrameSize); + using (rasterization) + { + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(37, 29, 48, 32))); + Assert.That(measurement.QueryBounds, Is.EqualTo(new Rect(37, 29, 48, 32))); + Assert.That(rasterization.Bounds, Is.EqualTo(measurement.OutputBounds)); + Assert.That(measuredSize, Is.EqualTo(new PixelSize(48, 32))); + Assert.That(bitmap.Width, Is.EqualTo(48)); + Assert.That(bitmap.Height, Is.EqualTo(32)); + }); + } + } + + [AvaloniaTest] + public async Task Full_target_only_drawable_renders_when_query_bounds_are_empty() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-full-target-only"); + var drawable = new SelectedDrawableFullTargetDrawable(); + + PixelSize measuredSize = await editor.Player.MeasureSelectedDrawable(drawable); + using Bitmap bitmap = await editor.Player.DrawSelectedDrawable(drawable); + (RenderNodeMeasurement measurement, RenderNodeRasterization rasterization) = + RenderSelectedDrawable(drawable, editor.Renderer.Value.FrameSize); + using (rasterization) + { + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 320, 240))); + Assert.That(measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(rasterization.Bounds, Is.EqualTo(measurement.OutputBounds)); + Assert.That(measuredSize, Is.EqualTo(new PixelSize(320, 240))); + Assert.That(bitmap.Width, Is.EqualTo(320)); + Assert.That(bitmap.Height, Is.EqualTo(240)); + }); + } + } + + [AvaloniaTest] + public async Task Nested_scene_selected_drawable_records_the_requested_output_scale() + { + GpuTestGate.EnsureAvailable(); + await ResetProjectAsync(); + EditViewModel editor = await OpenEditor("selected-drawable-nested-scale"); + string location = NewWorkspace("selected-drawable-nested-scale-source"); + var innerScene = new Scene(64, 48, string.Empty) + { + Uri = new Uri(Path.Combine(location, "inner.scene")) + }; + var capture = new SelectedDrawableScaleCaptureDrawable(); + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + Uri = new Uri(Path.Combine(location, "nested.layer")) + }; + element.AddObject(capture); + element.AddObject(new RectShape + { + Width = { CurrentValue = 64 }, + Height = { CurrentValue = 48 }, + }); + innerScene.Children.Add(element); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = innerScene; + + using Bitmap bitmap = await editor.Player.DrawSelectedDrawable(drawable, outputScale: 2); + + Assert.Multiple(() => + { + Assert.That(capture.ObservedOutputScales, Is.EqualTo(new[] { 2f })); + Assert.That(bitmap.Width, Is.EqualTo(128)); + Assert.That(bitmap.Height, Is.EqualTo(96)); + }); + } + + private static (RenderNodeMeasurement, RenderNodeRasterization) RenderSelectedDrawable( + Drawable drawable, + PixelSize frameSize) + { + return RenderThread.Dispatcher.Invoke(() => + { + using var resource = drawable.ToResource(new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, frameSize.ToSize(1))) + { + drawable.Render(context, resource); + } + + var request = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, frameSize.ToSize(1)), + OutputScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }; + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = request, + }); + RenderNodeMeasurement measurement = renderer.Measure(); + RenderNodeRasterization rasterization = renderer.Rasterize(request with + { + RequestedRegion = measurement.OutputBounds, + }); + return (measurement, rasterization); + }); + } +} + +internal sealed partial class SelectedDrawableFullTargetDrawable : Drawable +{ + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) + => Size.Empty; + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + => context.Clear(Colors.CornflowerBlue); +} + +internal sealed partial class SelectedDrawableScaleCaptureDrawable : Drawable +{ + public List ObservedOutputScales { get; } = []; + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + ObservedOutputScales.Add(context.OutputScale); + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) + => Size.Empty; + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} diff --git a/tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj b/tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj new file mode 100644 index 0000000000..7524ba3e1b --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/Beutl.PublicApiContractTests.csproj @@ -0,0 +1,25 @@ + + + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Beutl.PublicApiContractTests/BrushMaterializationSmokeTests.cs b/tests/Beutl.PublicApiContractTests/BrushMaterializationSmokeTests.cs new file mode 100644 index 0000000000..ef05836e66 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/BrushMaterializationSmokeTests.cs @@ -0,0 +1,257 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class BrushMaterializationSmokeTests +{ + [Test] + public void DrawableBrush_IsMaterializedAtExecution() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 20; + content.Height.CurrentValue = 12; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + using DrawableBrush.Resource brushResource = brush.ToResource(CompositionContext.Default); + using var node = new RectangleRenderNode(new Rect(0, 0, 64, 36), brushResource, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + + Assert.Multiple(() => + { + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 64, 36))); + Assert.That(bitmap, Is.Not.Null); + Assert.That(GetAlpha(bitmap, 32, 18), Is.GreaterThan(0.9f)); + Assert.That(GetAlpha(bitmap, 0, 0), Is.LessThan(0.1f)); + }); + } + + [Test] + public void DrawableOpacityMask_IsMaterializedAtExecution() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 20; + content.Height.CurrentValue = 12; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + using DrawableBrush.Resource brushResource = brush.ToResource(CompositionContext.Default); + using var root = new OpacityMaskRenderNode(brushResource, new Rect(0, 0, 64, 36), false); + root.AddChild(new RectangleRenderNode(new Rect(0, 0, 64, 36), Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + + Assert.Multiple(() => + { + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 64, 36))); + Assert.That(bitmap, Is.Not.Null); + Assert.That(GetAlpha(bitmap, 32, 18), Is.GreaterThan(0.9f)); + Assert.That(GetAlpha(bitmap, 0, 0), Is.LessThan(0.1f)); + }); + } + + [Test] + public void DrawableBrushInsidePresenter_IsMaterializedAtExecution() + { + var content = new EllipseShape + { + Width = { CurrentValue = 20 }, + Height = { CurrentValue = 12 }, + Fill = { CurrentValue = Brushes.White }, + }; + var drawableBrush = new DrawableBrush(content); + var presenter = new BrushPresenter + { + Target = { CurrentValue = drawableBrush }, + }; + using BrushPresenter.Resource presenterResource = presenter.ToResource(CompositionContext.Default); + using var node = new RectangleRenderNode(new Rect(0, 0, 64, 36), presenterResource, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + + Assert.Multiple(() => + { + Assert.That(bitmap, Is.Not.Null); + Assert.That(GetAlpha(bitmap, 32, 18), Is.GreaterThan(0.9f)); + Assert.That(GetAlpha(bitmap, 0, 0), Is.LessThan(0.1f)); + }); + } + + [Test] + public void BrushPresenterCycle_IsRejectedDeterministically() + { + var first = new BrushPresenter(); + var second = new BrushPresenter(); + using BrushPresenter.Resource firstResource = first.ToResource(CompositionContext.Default); + using BrushPresenter.Resource secondResource = second.ToResource(CompositionContext.Default); + firstResource.Target = secondResource; + secondResource.Target = firstResource; + + try + { + using var paint = new SKPaint(); + var constructor = new BrushConstructor( + new Rect(0, 0, 64, 36), + firstResource, + BlendMode.SrcOver, + RenderIntent.Preview, + drawableBrushMaterializer: null); + + Assert.That( + () => constructor.ConfigurePaint(paint), + Throws.InvalidOperationException.With.Message.Contains("BrushPresenter target cycle")); + } + finally + { + firstResource.Target = null; + secondResource.Target = null; + } + } + + // A directly constructed BrushConstructor inherits no canvas, so without a supplied materializer a + // DrawableBrush has nothing to rasterize its content with and the fill silently goes transparent. + [Test] + public void DirectlyConstructedBrush_PaintsADrawableBrushThroughASuppliedMaterializer() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 20; + content.Height.CurrentValue = 12; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + using DrawableBrush.Resource brushResource = brush.ToResource(CompositionContext.Default); + + var bounds = new Rect(0, 0, 64, 36); + DrawableBrushMaterializer materializer = + (_, _, _) => new MaterializedDrawableBrush(CreateOpaqueImage(20, 12), new Rect(0, 0, 20, 12)); + + using var withMaterializer = new SKPaint(); + new BrushConstructor(bounds, brushResource, BlendMode.SrcOver, RenderIntent.Preview, materializer) + .ConfigurePaint(withMaterializer); + using var withoutMaterializer = new SKPaint(); + new BrushConstructor(bounds, brushResource, BlendMode.SrcOver, RenderIntent.Preview, null) + .ConfigurePaint(withoutMaterializer); + + Assert.Multiple(() => + { + Assert.That(withMaterializer.Shader, Is.Not.Null, + "a supplied materializer must let the public path paint drawable content"); + Assert.That(withoutMaterializer.Shader, Is.Null); + }); + } + + [Test] + public void SuppliedMaterializerImage_IsOwnedByTheBrushConstructor() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 20; + content.Height.CurrentValue = 12; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + using DrawableBrush.Resource brushResource = brush.ToResource(CompositionContext.Default); + + SKImage handedOff = CreateOpaqueImage(20, 12); + DrawableBrushMaterializer materializer = + (_, _, _) => new MaterializedDrawableBrush(handedOff, new Rect(0, 0, 20, 12)); + + using var paint = new SKPaint(); + new BrushConstructor( + new Rect(0, 0, 64, 36), + brushResource, + BlendMode.SrcOver, + RenderIntent.Preview, + materializer) + .ConfigurePaint(paint); + + Assert.That(handedOff.Handle, Is.EqualTo(IntPtr.Zero), + "BrushConstructor takes ownership of the materialized image and disposes it before returning"); + } + + [Test] + public void PublicActivator_PaintsADrawableBrushDisplacementMapThroughASuppliedMaterializer() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 24; + content.Height.CurrentValue = 24; + content.Fill.CurrentValue = Brushes.White; + var effect = new DisplacementMapEffect(); + effect.DisplacementMap.CurrentValue = new DrawableBrush(content); + effect.ShowDisplacementMap.CurrentValue = true; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + + var bounds = new Rect(0, 0, 32, 32); + using RenderTarget backing = RenderTarget.Create(32, 32) + ?? throw new InvalidOperationException("Could not create the backing target."); + using var targets = new EffectTargets { new EffectTarget(backing, bounds, EffectiveScale.At(1)) }; + using var builder = new SKImageFilterBuilder(); + using var context = new FilterEffectContext(bounds); + effect.ApplyTo(context, resource); + + DrawableBrushMaterializer materializer = + (_, _, _) => new MaterializedDrawableBrush(CreateOpaqueImage(32, 32), bounds); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + drawableBrushMaterializer: materializer); + activator.Apply(context); + activator.Flush(false); + + using Bitmap bitmap = targets[0].RenderTarget!.Snapshot(); + + Assert.That(GetAlpha(bitmap, 16, 16), Is.GreaterThan(0.9f), + "a materializer supplied to the public activator must reach the custom-effect brush constructor"); + } + + private static SKImage CreateOpaqueImage(int width, int height) + { + var info = new SKImageInfo(width, height, SKColorType.RgbaF16, SKAlphaType.Premul); + using SKSurface surface = SKSurface.Create(info) + ?? throw new InvalidOperationException("Could not create the source surface."); + surface.Canvas.Clear(SKColors.White); + return surface.Snapshot(); + } + + private static float GetAlpha(Bitmap bitmap, int x, int y) + => (float)bitmap.GetRow(y)[(x * 4) + 3]; +} diff --git a/tests/Beutl.PublicApiContractTests/CapturedResourceBorrowContractTests.cs b/tests/Beutl.PublicApiContractTests/CapturedResourceBorrowContractTests.cs new file mode 100644 index 0000000000..77158ab3a7 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/CapturedResourceBorrowContractTests.cs @@ -0,0 +1,66 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class CapturedResourceBorrowContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 4, 3); + private static readonly RenderResourceSlot s_payloadSlot = new(); + private static readonly OpaqueRenderDefinition s_definition = + OpaqueRenderDefinition.Create( + static (session, _) => session.UseResource(s_payloadSlot, payload => + { + payload.Uses++; + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_payloadSlot]); + + [Test] + public void BorrowedResource_IsBoundByItsTypedSlotWithoutAnAuthorIdentity() + { + var payload = new BorrowedPayload(); + using var node = new DelegateSourceNode(context => + { + RenderResource token = context.Borrow(payload); + context.Publish(context.OpaqueSource( + s_definition.Call(default, [s_payloadSlot.Bind(token)]))); + }); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(payload.Uses, Is.EqualTo(1)); + }); + } + + private sealed class DelegateSourceNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class BorrowedPayload + { + public int Uses { get; set; } + } +} diff --git a/tests/Beutl.PublicApiContractTests/DeclaredPlannerTraitContractTests.cs b/tests/Beutl.PublicApiContractTests/DeclaredPlannerTraitContractTests.cs new file mode 100644 index 0000000000..91774f92b6 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/DeclaredPlannerTraitContractTests.cs @@ -0,0 +1,184 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class DeclaredPlannerTraitContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + public void Definitions_ValidateTheirDeclaredDeviceGridTraits() + { + ArgumentOutOfRangeException? opaque = Assert.Throws( + static () => OpaqueRenderDefinition.Create( + static (_, _) => { }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + deviceGridSensitivity: (RenderDeviceGridSensitivity)7)); + ArgumentOutOfRangeException? scopeMapping = Assert.Throws( + static () => TargetScopeDefinition.Create( + static (_, _) => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridMapping: (RenderDeviceGridMapping)7)); + ArgumentOutOfRangeException? scopeSensitivity = Assert.Throws( + static () => TargetScopeDefinition.Create( + static (_, _) => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: (RenderDeviceGridSensitivity)7)); + + Assert.Multiple(() => + { + Assert.That(opaque!.ParamName, Is.EqualTo("deviceGridSensitivity")); + Assert.That(scopeMapping!.ParamName, Is.EqualTo("deviceGridMapping")); + Assert.That(scopeSensitivity!.ParamName, Is.EqualTo("deviceGridSensitivity")); + }); + } + + [Test] + public void PublicTargetScopeCall_RemainsAnEffectBoundary() + { + bool valueEligible = true; + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceCall(Colors.CornflowerBlue)); + RenderFragmentHandle scope = context.TargetScope( + source, + RenderDefinitionCallFactory.TargetScope( + static session => session.ReplayInput(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved)); + valueEligible = scope.CanBeUsedAsValueInput; + context.Publish(scope); + }); + + RenderNodeMeasurement measurement = Measure(node); + + Assert.Multiple(() => + { + Assert.That(valueEligible, Is.False); + Assert.That(measurement.HasTargetEffects, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + }); + } + + [Test] + public void CallStateChange_IsInvalidatedThroughHasChangesWithoutManualCacheControl() + { + using var node = new StatefulSourceNode(Colors.Red); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + for (int i = 0; i < 5; i++) + { + using RenderNodeRasterization _ = renderer.Rasterize(); + } + + int beforeChange = node.ExecutionCount; + node.Color = Colors.Blue; + + using (RenderNodeRasterization stale = renderer.Rasterize()) + { + Assert.That(stale.IsEmpty, Is.False); + } + + Assert.Multiple(() => + { + Assert.That(node.ExecutionCount, Is.EqualTo(beforeChange)); + Assert.That(node.ExecutedColors.Last(), Is.EqualTo(Colors.Red)); + }); + + node.HasChanges = true; + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(node.ExecutionCount, Is.GreaterThan(beforeChange)); + Assert.That(node.ExecutedColors.Last(), Is.EqualTo(Colors.Blue)); + Assert.That(node.HasChanges, Is.False); + }); + } + + private static OpaqueRenderCall SourceCall(Color color) + => OpaqueRenderDefinition.Create( + static (session, current) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(current)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + RenderDeviceGridSensitivity.Insensitive) + .Call(color); + + private static RenderNodeMeasurement Measure(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest { TargetDomain = s_bounds }, + }); + return renderer.Measure(); + } + + private sealed class StatefulSourceNode(Color initialColor) : RenderNode + { + private static readonly OpaqueRenderDefinition s_definition = + OpaqueRenderDefinition.Create( + static (session, node) => node.Execute(session), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + RenderDeviceGridSensitivity.Insensitive); + + public Color Color { get; set; } = initialColor; + + public int ExecutionCount { get; private set; } + + public List ExecutedColors { get; } = []; + + public override void Process(RenderNodeContext context) + => context.Publish(context.OpaqueSource(s_definition.Call(this))); + + private void Execute(OpaqueRenderSession session) + { + ExecutionCount++; + ExecutedColors.Add(Color); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(Color)); + session.Publish(output); + } + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } +} diff --git a/tests/Beutl.PublicApiContractTests/DeclaredResourceAddressingContractTests.cs b/tests/Beutl.PublicApiContractTests/DeclaredResourceAddressingContractTests.cs new file mode 100644 index 0000000000..5e595f15e6 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/DeclaredResourceAddressingContractTests.cs @@ -0,0 +1,227 @@ +using System.Reflection; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class DeclaredResourceAddressingContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + private static readonly RenderResourceSlot s_leftSlot = new(); + private static readonly RenderResourceSlot s_rightSlot = new(); + private static readonly RenderResourceSlot s_unboundSlot = new(); + private static readonly RenderResourceSlot s_missingSlot = new(); + private static readonly OpaqueRenderDefinition s_twoPayloadDefinition = + OpaqueRenderDefinition.Create( + static (session, _) => session.UseResource(s_leftSlot, left => + session.UseResource(s_rightSlot, right => + { + left.Touch(); + right.Touch(); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.Red)); + session.Publish(output); + })), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_leftSlot, s_rightSlot]); + private static readonly OpaqueRenderDefinition s_missingLookupDefinition = + OpaqueRenderDefinition.Create( + static (session, _) => session.UseResource(s_missingSlot, static _ => { }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_leftSlot]); + + [TestCase(false)] + [TestCase(true)] + public void TypedBindingsRemainStableWhenSameTypedResourcesAreReordered(bool reverse) + { + var reached = new List(); + using var node = new DelegateSourceNode(context => + { + RenderResource left = context.Borrow(new Payload("left", reached)); + RenderResource right = context.Borrow(new Payload("right", reached)); + RenderResourceBinding[] bindings = reverse + ? [s_rightSlot.Bind(right), s_leftSlot.Bind(left)] + : [s_leftSlot.Bind(left), s_rightSlot.Bind(right)]; + context.Publish(context.OpaqueSource(s_twoPayloadDefinition.Call(default, bindings))); + }); + + using RenderNodeRasterization rasterization = Rasterize(node, RenderCacheOptions.Disabled); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(reached, Is.EqualTo(new[] { "left", "right" })); + }); + } + + [Test] + public void DefinitionCallsRejectMissingDuplicateAndUnexpectedSlots() + { + ArgumentException? missing = null; + ArgumentException? duplicate = null; + ArgumentException? unexpected = null; + using var node = new DelegateSourceNode(context => + { + RenderResource first = context.Borrow(new Payload()); + RenderResource second = context.Borrow(new Payload()); + missing = Assert.Throws(() => + s_twoPayloadDefinition.Call(default, [s_leftSlot.Bind(first)])); + duplicate = Assert.Throws(() => + s_twoPayloadDefinition.Call( + default, + [s_leftSlot.Bind(first), s_leftSlot.Bind(second)])); + unexpected = Assert.Throws(() => + s_twoPayloadDefinition.Call( + default, + [s_leftSlot.Bind(first), s_unboundSlot.Bind(second)])); + }); + + _ = Measure(node); + + Assert.Multiple(() => + { + Assert.That(missing!.ParamName, Is.EqualTo("bindings")); + Assert.That(duplicate!.ParamName, Is.EqualTo("bindings")); + Assert.That(unexpected!.ParamName, Is.EqualTo("bindings")); + }); + } + + [Test] + public void DefinitionRejectsTheSameSlotTwice() + { + ArgumentException? exception = Assert.Throws(() => + OpaqueRenderDefinition.Create( + static (_, _) => { }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_leftSlot, s_leftSlot])); + + Assert.That(exception!.ParamName, Is.EqualTo("resources")); + } + + [Test] + public void MissingSlotFailsWithoutFallingBackToAnotherSameTypedBinding() + { + using var node = new DelegateSourceNode(context => + { + RenderResource token = context.Borrow(new Payload()); + context.Publish(context.OpaqueSource( + s_missingLookupDefinition.Call(default, [s_leftSlot.Bind(token)]))); + }); + using RenderNodeRenderer renderer = CreateRenderer(node, RenderCacheOptions.Disabled); + + KeyNotFoundException? exception = Assert.Throws(() => renderer.Rasterize()); + + Assert.That(exception!.Message, Does.Contain("slot")); + } + + [Test] + public void PublicResourceAddressingUsesTypedSlotsWithoutCacheIdentityOrNames() + { + Type[] slotSessions = + [ + typeof(OpaqueRenderSession), + typeof(GeometrySession), + typeof(TargetScopeSession), + typeof(TargetCommandSession), + ]; + + MethodInfo? bind = typeof(RenderResourceSlot).GetMethod( + nameof(RenderResourceSlot.Bind), + [typeof(RenderResource)]); + Assert.Multiple(() => + { + Assert.That(bind, Is.Not.Null); + Assert.That(bind!.ReturnType, Is.EqualTo(typeof(RenderResourceBinding))); + Assert.That( + typeof(RenderResourceSlot).GetMethod( + nameof(RenderResourceSlot.Bind), + [typeof(RenderResource)]), + Is.Null, + "A slot can only bind a token of its exact declared resource type."); + Assert.That(typeof(RenderResourceBinding).GetConstructors(), Is.Empty); + Assert.That(typeof(RenderResourceBinding).GetProperties(), Is.Empty); + Assert.That(typeof(RenderResource).GetMethod("Bind"), Is.Null); + Assert.That(typeof(RenderResource).GetProperty("CacheIdentity"), Is.Null); + + foreach (Type session in slotSessions) + { + Assert.That(session.GetMethod("UseDeclaredResource"), Is.Null, session.Name); + MethodInfo[] resourceMethods = session.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(static method => method.Name == "UseResource") + .ToArray(); + Assert.That(resourceMethods, Has.Length.EqualTo(1), session.Name); + ParameterInfo slotParameter = resourceMethods[0].GetParameters()[0]; + Assert.That(slotParameter.ParameterType.IsGenericType, Is.True, session.Name); + Assert.That( + slotParameter.ParameterType.GetGenericTypeDefinition(), + Is.EqualTo(typeof(RenderResourceSlot<>)), + session.Name); + } + + Assert.That( + typeof(RenderNodeContext).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(static method => method.Name is "Own" or "Borrow") + .All(static method => method.GetParameters().Length == 1), + Is.True); + Assert.That( + typeof(FilterEffectContext).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(static method => method.Name is "Own" or "Borrow") + .All(static method => method.GetParameters().Length == 1), + Is.True); + }); + } + + private static RenderNodeMeasurement Measure(RenderNode node) + { + using RenderNodeRenderer renderer = CreateRenderer(node, RenderCacheOptions.Disabled); + return renderer.Measure(); + } + + private static RenderNodeRasterization Rasterize(RenderNode node, RenderCacheOptions cacheOptions) + { + using RenderNodeRenderer renderer = CreateRenderer(node, cacheOptions); + return renderer.Rasterize(); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node, RenderCacheOptions cacheOptions) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = cacheOptions, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + private sealed class DelegateSourceNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class Payload(string? name = null, List? reached = null) + { + public void Touch() + { + if (name is not null) + reached!.Add(name); + } + } + + private sealed class OtherPayload; +} diff --git a/tests/Beutl.PublicApiContractTests/DeliveryIntentDeclarationContractTests.cs b/tests/Beutl.PublicApiContractTests/DeliveryIntentDeclarationContractTests.cs new file mode 100644 index 0000000000..fdf2386310 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/DeliveryIntentDeclarationContractTests.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +/// +/// Pins that the surfaces whose intent decides fail-fast versus degrade make the caller say which one it +/// wants. +/// +/// +/// A trailing optional defaulting to reads as a +/// convenience, but it silently rewrites a delivery host's failure policy: an intermediate that cannot be +/// allocated stops failing the render and starts dropping content, so an export ships a frame with a hole in +/// it and nothing in the source says why. The same applies to a brush host's materializer, without which a +/// DrawableBrush fill resolves to transparent. +/// +[TestFixture] +public sealed class DeliveryIntentDeclarationContractTests +{ + [Test] + public void TheRendererConstructor_RequiresAnExplicitIntent() + { + ParameterInfo intent = RequireParameter(typeof(Renderer), "intent"); + + Assert.That(intent.HasDefaultValue, Is.False); + } + + [TestCase("intent")] + [TestCase("drawableBrushMaterializer")] + public void TheBrushConstructor_RequiresAnExplicit(string parameterName) + { + ParameterInfo parameter = RequireParameter(typeof(BrushConstructor), parameterName); + + Assert.That(parameter.HasDefaultValue, Is.False); + } + + [Test] + public void ADeliveryRendererStillDeclaresItsIntent() + { + using var renderer = new Renderer(4, 4, RenderIntent.Delivery); + + Assert.That(renderer.Intent, Is.EqualTo(RenderIntent.Delivery)); + } + + [Test] + public void ABrushConstructorWithoutAMaterializer_StillStatesIt() + { + var constructor = new BrushConstructor( + new Rect(0, 0, 4, 4), + Brushes.Resource.White, + BlendMode.SrcOver, + RenderIntent.Delivery, + drawableBrushMaterializer: null); + + Assert.That(constructor.Intent, Is.EqualTo(RenderIntent.Delivery)); + } + + private static ParameterInfo RequireParameter(Type type, string name) + { + foreach (ConstructorInfo constructor in type.GetConstructors( + BindingFlags.Public | BindingFlags.Instance)) + { + foreach (ParameterInfo parameter in constructor.GetParameters()) + { + if (parameter.Name == name) + return parameter; + } + } + + throw new InvalidOperationException($"No public {type.Name} constructor declares '{name}'."); + } +} diff --git a/tests/Beutl.PublicApiContractTests/DetachedResourceAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/DetachedResourceAuthoringContractTests.cs new file mode 100644 index 0000000000..642b05589e --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/DetachedResourceAuthoringContractTests.cs @@ -0,0 +1,241 @@ +using System.Numerics; +using System.Reflection; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics3D; +using Beutl.Graphics3D.Meshes; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +/// +/// This project is not a friend of Beutl.Engine, so everything here compiles only against the public +/// authoring surface an out-of-tree plugin sees, with the same generator the SDK ships as an analyzer. +/// +[TestFixture] +public sealed class DetachedResourceAuthoringContractTests +{ + [Test] + public void APluginGeometryAuthoredOnTheResource_AnswersWhileDetached() + { + using var detached = new PluginGeometry.Resource { Side = 24 }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(detached.Bounds, Is.EqualTo(new Rect(0, 0, 24, 24))); + Assert.That(detached.FillContains(new Point(12, 12)), Is.True); + } + } + + [Test] + public void APluginSegmentAuthoredOnTheResource_AnswersWhileDetached() + { + using var detached = new PathGeometry.Resource + { + Figures = + { + new PathFigure.Resource + { + StartPoint = new Point(0, 0), + Segments = { new PluginSegment.Resource { To = new Point(40, 15) } }, + }, + }, + }; + + Assert.That(detached.Bounds, Is.EqualTo(new Rect(0, 0, 40, 15))); + } + + [Test] + public void APluginMeshAuthoredOnTheResource_AnswersWhileDetached() + { + using var detached = new PluginMesh.Resource { Extent = 3 }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(detached.VertexCount, Is.EqualTo(3)); + Assert.That(detached.IndexCount, Is.EqualTo(3)); + Assert.That(detached.GetBoundingBox().Max.X, Is.EqualTo(3)); + } + } + + [Test] + public void AnAttachedResource_StillReportsItsBackingObject() + { + var geometry = new PluginGeometry(); + geometry.Side.CurrentValue = 10; + using Geometry.Resource attached = geometry.ToResource(CompositionContext.Default); + + using (Assert.EnterMultipleScope()) + { + Assert.That(attached.GetOriginal(), Is.SameAs(geometry)); + Assert.That(attached.Bounds, Is.EqualTo(new Rect(0, 0, 10, 10))); + } + } + + /// + /// A detached resource is a supported authoring shape, so the accessor that answers "what was this built + /// from" has to admit it has no answer. It used to be declared non-null and return null anyway, which + /// turned every plugin that trusted the declaration into a NullReferenceException. + /// + [Test] + public void ADetachedResource_ReportsNoBackingObject() + { + using var detached = new PluginGeometry.Resource { Side = 24 }; + + EngineObject? original = detached.GetOriginal(); + + Assert.That(original, Is.Null); + } + + [Test] + public void TheBackingObjectAccessor_IsDeclaredNullable() + { + var context = new NullabilityInfoContext(); + MethodInfo baseAccessor = typeof(EngineObject.Resource) + .GetMethod(nameof(EngineObject.Resource.GetOriginal), Type.EmptyTypes)!; + MethodInfo generatedAccessor = typeof(PluginGeometry.Resource) + .GetMethod(nameof(PluginGeometry.Resource.GetOriginal), Type.EmptyTypes)!; + + using (Assert.EnterMultipleScope()) + { + Assert.That( + context.Create(baseAccessor.ReturnParameter).ReadState, + Is.EqualTo(NullabilityState.Nullable)); + Assert.That( + context.Create(generatedAccessor.ReturnParameter).ReadState, + Is.EqualTo(NullabilityState.Nullable), + "The generated typed accessor must carry the same admission as the one it hides."); + } + } + + [Test] + public void TwoDetachedPens_AreNotTreatedAsOneByTheStrokeCache() + { + using var geometry = new PluginGeometry.Resource { Side = 100 }; + using var thin = DetachedPen(thickness: 4); + using var thick = DetachedPen(thickness: 20); + + using (Assert.EnterMultipleScope()) + { + Assert.That(geometry.GetRenderBounds(thin), Is.EqualTo(new Rect(-2, -2, 104, 104))); + Assert.That(geometry.GetRenderBounds(thick), Is.EqualTo(new Rect(-10, -10, 120, 120))); + } + } + + [Test] + public void AResourcePropertySetter_ReplacesWithoutDisposingThePreviousValue() + { + using var resource = new PluginObjectDefaultOwner.Resource(); + using var initial = new PluginObjectDefault.Resource(); + using var replacement = new PluginObjectDefault.Resource(); + resource.Child = initial; + + resource.Child = replacement; + + using (Assert.EnterMultipleScope()) + { + Assert.That(initial.IsDisposed, Is.False, + "a resource property setter is a plain assignment and must not dispose the displaced value"); + Assert.That(replacement.IsDisposed, Is.False); + } + + resource.Dispose(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(initial.IsDisposed, Is.False); + Assert.That(replacement.IsDisposed, Is.True, + "disposing the owner must still release the resource currently stored in the property"); + } + } + + private static Pen.Resource DetachedPen(float thickness) + { + return new Pen.Resource + { + Brush = new SolidColorBrush.Resource { Color = Colors.Black }, + Thickness = thickness, + MiterLimit = 10, + TrimEnd = 100, + }; + } +} + +public sealed partial class PluginGeometry : Geometry +{ + public PluginGeometry() + { + ScanProperties(); + } + + public IProperty Side { get; } = Property.CreateAnimatable(); + + public partial class Resource + { + public override void ApplyTo(IGeometryContext context) + { + base.ApplyTo(context); + context.MoveTo(new Point(0, 0)); + context.LineTo(new Point(Side, 0)); + context.LineTo(new Point(Side, Side)); + context.LineTo(new Point(0, Side)); + context.Close(); + } + } +} + +public sealed partial class PluginSegment : PathSegment +{ + public PluginSegment() + { + ScanProperties(); + } + + public IProperty To { get; } = Property.CreateAnimatable(); + + public override IProperty GetEndPoint() => To; + + public partial class Resource + { + public override void ApplyTo(IGeometryContext context) + { + context.LineTo(To); + } + + public override Point? GetEndPoint() => To; + } +} + +public sealed partial class PluginMesh : Mesh +{ + public PluginMesh() + { + ScanProperties(); + } + + public IProperty Extent { get; } = Property.CreateAnimatable(); + + public partial class Resource + { + public override void ApplyTo(out Vertex3D[] vertices, out uint[] indices) + { + vertices = + [ + new Vertex3D(Vector3.Zero, Vector3.UnitY, Vector2.Zero), + new Vertex3D(new Vector3(Extent, 0, 0), Vector3.UnitY, Vector2.UnitX), + new Vertex3D(new Vector3(0, 0, Extent), Vector3.UnitY, Vector2.UnitY), + ]; + indices = [0, 1, 2]; + } + } +} + +public sealed partial class PluginObjectDefault : EngineObject +{ +} + +public sealed partial class PluginObjectDefaultOwner : EngineObject +{ + public IProperty Child { get; } = Property.Create(); +} diff --git a/tests/Beutl.PublicApiContractTests/EngineResourceIdentityContractTests.cs b/tests/Beutl.PublicApiContractTests/EngineResourceIdentityContractTests.cs new file mode 100644 index 0000000000..83d69fcdb2 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/EngineResourceIdentityContractTests.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Beutl.Graphics.Rendering; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class EngineResourceIdentityContractTests +{ + private const string EngineResourceIdentityName = "Beutl.Graphics.Rendering.EngineResourceIdentity"; + + [Test] + public void ResourceAndRuntimeIdentityTypes_AreNotAuthoringSurface() + { + Assembly engine = typeof(RenderNode).Assembly; + string?[] exportedTypes = engine.GetExportedTypes().Select(static type => type.FullName).ToArray(); + + Assert.Multiple(() => + { + // Anchored on the live type: a guard spelled only as a string keeps passing after a rename or a + // namespace move, which is exactly when the surface is most likely to slip out. + Assert.That( + engine.GetType(EngineResourceIdentityName, throwOnError: false), + Is.Not.Null, + "The engine-only resource identity helper was renamed or moved; retarget this guard."); + Assert.That(exportedTypes, Does.Not.Contain(EngineResourceIdentityName)); + Assert.That( + exportedTypes, + Does.Not.Contain("Beutl.Graphics.Rendering.RenderRuntimeIdentity"), + "RenderRuntimeIdentity was removed from the authoring surface and must not return."); + }); + } +} diff --git a/tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs b/tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs new file mode 100644 index 0000000000..c3fa1e7b3d --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs @@ -0,0 +1,645 @@ +using System.Buffers.Binary; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class FilterEffectCompatibilityContractTests +{ + private static readonly Rect s_bounds = new(3, 5, 12, 8); + private static readonly ShaderDefinition s_identityShader = + ShaderDefinition.CurrentPixel("half4 apply(half4 color) { return color; }"); + + [Test] + public void ExecutionContexts_RequireExplicitIntentAndPurpose() + { + using var targets = new EffectTargets(); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + using var unboundedCeilingActivator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.CacheWarmup, + maxWorkingScale: float.PositiveInfinity); + using var deliveryActivator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + maxWorkingScale: 2); + + Type[] expectedParameterTypes = + [ + typeof(EffectTargets), + typeof(SKImageFilterBuilder), + typeof(RenderIntent), + typeof(RenderRequestPurpose), + typeof(float), + typeof(float), + typeof(float), + typeof(DrawableBrushMaterializer), + ]; + System.Reflection.ParameterInfo[] constructorParameters = typeof(FilterEffectActivator) + .GetConstructors() + .Single() + .GetParameters(); + Type[] actualParameterTypes = constructorParameters + .Select(static parameter => parameter.ParameterType) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(activator.Intent, Is.EqualTo(RenderIntent.Preview)); + Assert.That(activator.Purpose, Is.EqualTo(RenderRequestPurpose.Frame)); + Assert.That(unboundedCeilingActivator.Intent, Is.EqualTo(RenderIntent.Preview), + "an unbounded working-scale ceiling must not promote a caller to delivery fail-fast"); + Assert.That(unboundedCeilingActivator.Purpose, Is.EqualTo(RenderRequestPurpose.CacheWarmup)); + Assert.That(deliveryActivator.Intent, Is.EqualTo(RenderIntent.Delivery), + "a finite working-scale ceiling must not demote an explicit delivery intent"); + Assert.That(deliveryActivator.Purpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + Assert.That(actualParameterTypes, Is.EqualTo(expectedParameterTypes), + "the only public constructor must require both execution classifications"); + Assert.That(constructorParameters[2].IsOptional, Is.False); + Assert.That(constructorParameters[3].IsOptional, Is.False); + Assert.That(constructorParameters.Skip(4).All(static parameter => parameter.IsOptional), Is.True); + Assert.That(typeof(FilterEffectActivator).GetProperty(nameof(FilterEffectActivator.Intent))!.CanWrite, + Is.False); + Assert.That(typeof(FilterEffectActivator).GetProperty(nameof(FilterEffectActivator.Purpose))!.CanWrite, + Is.False); + Assert.That(typeof(CustomFilterEffectContext).GetProperty(nameof(CustomFilterEffectContext.Intent))!.CanWrite, + Is.False); + Assert.That(typeof(CustomFilterEffectContext).GetProperty(nameof(CustomFilterEffectContext.Purpose))!.CanWrite, + Is.False); + }); + } + + [Test] + public void ExplicitPurpose_ReachesTheCustomEffectCallback() + { + var bounds = new Rect(0, 0, 4, 4); + RenderRequestPurpose? observedPurpose = null; + using var renderTarget = new CpuRenderTarget(new PixelSize(4, 4)); + using var targets = new EffectTargets + { + new EffectTarget(renderTarget, bounds, EffectiveScale.At(1)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.CacheWarmup); + using var context = new FilterEffectContext(bounds); + context.CustomEffect( + 0, + (_, execution) => observedPurpose = execution.Purpose, + static (_, value) => value); + + activator.Apply(context); + + Assert.That(observedPurpose, Is.EqualTo(RenderRequestPurpose.CacheWarmup)); + } + + // Once bounds go symbolic the context defers further operations to a second list, but they still + // execute, so an authoring decision made from the public count has to see them. + [Test] + public void CountItems_IncludesOperationsAppendedAfterBoundsBecameSymbolic() + { + using var context = new FilterEffectContext(s_bounds, outputScale: 1, workingScale: 1); + + context.CustomEffect(0, static (_, _) => { }); + int beforeDeferral = context.CountItems(); + context.CustomEffect(0, static (_, _) => { }); + + Assert.That(context.CountItems(), Is.EqualTo(beforeDeferral + 1)); + } + + [Test] + public void ExistingApplyToEffect_RetainsLegacyMembersAndDeferredExecution() + { + var executionOrder = new List(); + var effect = new LegacyPluginEffect(executionOrder, "single"); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds, outputScale: 2, workingScale: 1.5f); + + effect.ApplyTo(context, resource); + bool hasWorkingScale = context.TryGetWorkingScale(out float workingScale); + + Assert.Multiple(() => + { + Assert.That(context.OriginalBounds, Is.EqualTo(s_bounds)); + Assert.That(context.OutputScale, Is.EqualTo(2)); + Assert.That(context.WorkingScale, Is.EqualTo(1.5f)); + Assert.That(hasWorkingScale, Is.True); + Assert.That(workingScale, Is.EqualTo(1.5f)); + // Brightness(1) is an exact identity colour matrix and records no stage, so the legacy + // fixture contributes four items rather than five. + Assert.That(context.CountItems(), Is.EqualTo(4)); + Assert.That(executionOrder, Is.Empty, + "ApplyTo must record legacy custom work rather than execute it."); + }); + } + + [Test] + public void ExistingApplyToEffect_RendersUnchangedAcrossGroupBoundariesInAuthoredOrder() + { + var executionOrder = new List(); + var group = new FilterEffectGroup(); + group.Children.Add(new LegacyPluginEffect(executionOrder, "first")); + group.Children.Add(new LegacyPluginEffect(executionOrder, "second")); + + using RenderNode unfiltered = new SolidSourceNode(s_bounds, Colors.CornflowerBlue); + using RenderNode filtered = CreateEffectNode(group, new SolidSourceNode(s_bounds, Colors.CornflowerBlue)); + using RenderNodeRasterization baseline = Rasterize(unfiltered); + + Assert.That(executionOrder, Is.Empty, + "Constructing and recording the legacy group must not invoke CustomEffect callbacks."); + + using RenderNodeRasterization actual = Rasterize(filtered); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(baseline.Bounds)); + Assert.That(actual.IsEmpty, Is.False); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(baseline.Bitmap, Is.Not.Null); + Assert.That(executionOrder, Is.EqualTo(new[] + { + "first:after-color", + "first:after-skia-transform", + "second:after-color", + "second:after-skia-transform", + })); + AssertBitmapsEqual(baseline.Bitmap!, actual.Bitmap!); + }); + } + + [Test] + public void WorkingScaleHook_ReusesBaseIsolationForMixedSymbolicFullInput() + { + var targetDomain = new Rect(10, 20, 48, 32); + var sourceBounds = new Rect(14, 24, 12, 8); + bool? hasWorkingScale = null; + float probedWorkingScale = float.NaN; + InvalidOperationException? getterFailure = null; + var effect = new WorkingScaleProbeEffect( + context => + { + hasWorkingScale = context.TryGetWorkingScale(out probedWorkingScale); + try + { + _ = context.WorkingScale; + } + catch (InvalidOperationException ex) + { + getterFailure = ex; + } + }, + static context => context.Brightness(0.75f)); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var root = new SymbolicFullFilterInputNode( + new BoundsDependentWorkingScaleFilterNode(resource), + sourceBounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasTargetEffects, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(targetDomain), + "the symbolic Full input must resolve against the owning target after base isolation"); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2)), + "the protected working-scale hook must be reevaluated through the base lowering path"); + Assert.That(hasWorkingScale, Is.False, + "ApplyTo must not observe a provisional scale for a symbolic owning-target domain"); + Assert.That(probedWorkingScale, Is.Zero); + Assert.That(getterFailure, Is.Not.Null); + Assert.That(getterFailure!.Message, Does.Contain("unavailable")); + }); + } + + [Test] + public void WorkingScaleAvailability_IsFalseForConcreteMultiInputTypedLowering() + { + bool? hasWorkingScale = null; + float probedWorkingScale = float.NaN; + InvalidOperationException? getterFailure = null; + var effect = new WorkingScaleProbeEffect( + context => + { + hasWorkingScale = context.TryGetWorkingScale(out probedWorkingScale); + try + { + _ = context.WorkingScale; + } + catch (InvalidOperationException ex) + { + getterFailure = ex; + } + }, + static context => context.Shader(s_identityShader.Call(default))); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var root = new ConcreteMultiFilterInputNode( + new BranchSensitiveWorkingScaleFilterNode(resource)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(hasWorkingScale, Is.False, + "one aggregate hint is not a final density when typed lowering keeps independent branches"); + Assert.That(probedWorkingScale, Is.Zero); + Assert.That(getterFailure, Is.Not.Null); + Assert.That(getterFailure!.Message, Does.Contain("different branches")); + }); + } + + [Test] + public void WorkingScaleAvailability_VectorHook_NormalizesCurrentPixelAtOutputScale() + { + bool? hasWorkingScale = null; + float probedWorkingScale = float.NaN; + float getterWorkingScale = float.NaN; + var effect = new WorkingScaleProbeEffect( + context => + { + hasWorkingScale = context.TryGetWorkingScale(out probedWorkingScale); + getterWorkingScale = context.WorkingScale; + }, + static context => context.Shader(s_identityShader.Call(default))); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var root = new ConcreteSingleFilterInputNode( + new VectorWorkingScaleFilterNode(resource)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(hasWorkingScale, Is.True); + Assert.That(probedWorkingScale, Is.EqualTo(2)); + Assert.That(getterWorkingScale, Is.EqualTo(2)); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2)), + "a Vector hook must not leave the first CurrentPixel shader unbounded after exposing w = 2"); + }); + } + + private static RenderNode CreateEffectNode(FilterEffect effect, RenderNode child) + { + FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + var node = resource.CreateRenderNode(); + node.AddChild(child); + return new OwnedEffectNode(node, resource); + } + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + return renderer.Rasterize(); + } + + private static void AssertBitmapsEqual(Bitmap expected, Bitmap actual) + { + Assert.Multiple(() => + { + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + Assert.That(actual.ColorType, Is.EqualTo(expected.ColorType)); + Assert.That(actual.AlphaType, Is.EqualTo(expected.AlphaType)); + }); + + ReadOnlySpan expectedPixels = expected.GetPixelSpan(); + ReadOnlySpan actualPixels = actual.GetPixelSpan(); + float maximumChannelError = 0; + float maximumAlphaError = 0; + for (int offset = 0; offset < expectedPixels.Length; offset += sizeof(ushort)) + { + float expectedValue = (float)BitConverter.UInt16BitsToHalf( + BinaryPrimitives.ReadUInt16LittleEndian(expectedPixels[offset..])); + float actualValue = (float)BitConverter.UInt16BitsToHalf( + BinaryPrimitives.ReadUInt16LittleEndian(actualPixels[offset..])); + float error = MathF.Abs(expectedValue - actualValue); + maximumChannelError = MathF.Max(maximumChannelError, error); + if ((offset / sizeof(ushort)) % 4 == 3) + maximumAlphaError = MathF.Max(maximumAlphaError, error); + } + + Assert.That( + maximumChannelError, + Is.LessThanOrEqualTo(0.0025f), + "Identity Skia filters may round RGBA16F channels while crossing legacy custom-effect buffers, " + + "but must remain within a strict sub-visual-error bound."); + Assert.That(maximumAlphaError, Is.Zero, + "Identity legacy operations must preserve premultiplied alpha exactly."); + } + + [SuppressResourceClassGeneration] + private sealed partial class LegacyPluginEffect(List executionOrder, string prefix) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + // This is intentionally an old-style ApplyTo implementation. It does not use any + // feature-004 Shader/Geometry API and therefore exercises source compatibility. + context.Brightness(1); + context.CustomEffect( + new LegacyMarker(executionOrder, $"{prefix}:after-color"), + static (marker, callback) => + { + Assert.That(callback.Targets, Is.Not.Empty); + marker.Order.Add(marker.Name); + }, + static (_, bounds) => bounds); + context.Blur(Size.Empty); + context.Transform(Matrix.Identity, BitmapInterpolationMode.Default); + context.CustomEffect( + new LegacyMarker(executionOrder, $"{prefix}:after-skia-transform"), + static (marker, callback) => + { + Assert.That(callback.Targets, Is.Not.Empty); + marker.Order.Add(marker.Name); + }, + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + private sealed record LegacyMarker(List Order, string Name); + + [SuppressResourceClassGeneration] + private sealed partial class WorkingScaleProbeEffect( + Action observe, + Action record) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + observe(context); + record(context); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + private sealed class BoundsDependentWorkingScaleFilterNode(FilterEffect.Resource resource) + : FilterEffectRenderNode(resource) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => metadata.OutputBounds.Width >= 40 ? 2 : 0.5f); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; + } + + private sealed class BranchSensitiveWorkingScaleFilterNode(FilterEffect.Resource resource) + : FilterEffectRenderNode(resource) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => metadata.InputSupplies.Count == 1 + ? metadata.InputSupplies[0].IsUnbounded + ? metadata.OutputScale + : metadata.InputSupplies[0].Value + : 4); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; + } + + private sealed class VectorWorkingScaleFilterNode(FilterEffect.Resource resource) + : FilterEffectRenderNode(resource) + { + protected override RenderScaleContract? GetWorkingScaleContract() => RenderScaleContract.Vector; + } + + private sealed class SymbolicFullFilterInputNode( + FilterEffectRenderNode filter, + Rect sourceBounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle concrete = context.OpaqueSource(CreateMetadataSource( + sourceBounds, + RenderScaleContract.Custom(static _ => 4), + "concrete")); + RenderFragmentHandle scopedSource = context.OpaqueSource(CreateMetadataSource( + sourceBounds, + RenderScaleContract.Vector, + "scoped")); + RenderFragmentHandle symbolic = context.TargetLayerScope( + [scopedSource], + TargetRegion.Full); + + context.PublishRange(context.RecordNode(filter, [concrete, symbolic])); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + filter.Dispose(); + + base.OnDispose(disposing); + } + } + + private sealed class ConcreteMultiFilterInputNode( + FilterEffectRenderNode filter) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle first = context.OpaqueSource(CreateMetadataSource( + s_bounds, + RenderScaleContract.Custom(static _ => 1), + "concrete-at-one")); + RenderFragmentHandle second = context.OpaqueSource(CreateMetadataSource( + s_bounds.Translate(new Point(20, 0)), + RenderScaleContract.Custom(static _ => 2), + "concrete-at-two")); + + context.PublishRange(context.RecordNode(filter, [first, second])); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + filter.Dispose(); + + base.OnDispose(disposing); + } + } + + private sealed class ConcreteSingleFilterInputNode( + FilterEffectRenderNode filter) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(CreateMetadataSource( + s_bounds, + RenderScaleContract.Custom(static _ => 1), + "single-at-one")); + context.PublishRange(context.RecordNode(filter, [source])); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + filter.Dispose(); + + base.OnDispose(disposing); + } + } + + private static OpaqueRenderCall> CreateMetadataSource( + Rect bounds, + RenderScaleContract scale, + object _) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Measure must not execute opaque callbacks."), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + scale); + } + + private sealed class SolidSourceNode(Rect bounds, Color color) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderCall<(Rect bounds, Color color)> call = RenderDefinitionCallFactory.Opaque( + (bounds, color), + static (session, state) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(state.color)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(call)); + } + } + + private sealed class OwnedEffectNode(FilterEffectRenderNode node, FilterEffect.Resource resource) : RenderNode + { + public override void Process(RenderNodeContext context) + { + IReadOnlyList outputs = context.RecordSubtree(node); + context.PublishRange(outputs); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + { + node.Dispose(); + resource.Dispose(); + } + + base.OnDispose(disposing); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) => + new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public CpuRenderTarget(PixelSize size) + : base(CreateSurface(size), size.Width, size.Height) + { + } + + private static SKSurface CreateSurface(PixelSize size) + { + return SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a CPU contract-test surface."); + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs new file mode 100644 index 0000000000..8762ae248b --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs @@ -0,0 +1,411 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class GeometryAuthoringContractTests +{ + private static readonly RenderResourceSlot s_metadataSlot = new(); + private static readonly GeometryDefinition s_metadataDefinition = + GeometryDefinition.Create( + static (_, _) => { }, + RenderBoundsContract.Create( + static bounds => bounds.Inflate(new Thickness(2)), + static required => required.Inflate(new Thickness(2))), + RenderHitTestContract.Custom(GeometryHitTest), + requiresReadback: true, + resources: [s_metadataSlot]); + + [Test] + public void Geometry_ExposesZeroOrOneBoundsHitTestResourceAndEligibilityContracts() + { + var inputBounds = new Rect(10, 20, 8, 6); + var outputBounds = inputBounds.Inflate(new Thickness(2)); + var resource = new GeometryResource("metadata"); + FragmentSnapshot observedFragment = default; + + using var node = new DelegateNode(context => + { + RenderResource token = context.Borrow(resource); + GeometryCall call = s_metadataDefinition.Call(default, [s_metadataSlot.Bind(token)]); + RenderFragmentHandle source = context.OpaqueSource(MetadataSource(inputBounds)); + RenderFragmentHandle geometry = context.Geometry(source, call); + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => throw new AssertionException("Metadata must not execute target commands."), + TargetRegion.Region(inputBounds), + Rect.Empty, + RenderHitTestContract.None)); + + Assert.That(() => context.Geometry(command, call), Throws.TypeOf()); + context.Drop(command); + observedFragment = FragmentSnapshot.From(geometry); + context.Publish(geometry); + }); + + using var renderer = CreateRenderer(node, outputScale: 2, maxWorkingScale: 3); + RenderNodeMeasurement measurement = renderer.Measure(); + bool hit = renderer.HitTest(new Point(outputBounds.X + 1, outputBounds.Y + 1)); + + Assert.Multiple(() => + { + Assert.That(hit, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(outputBounds)); + Assert.That(measurement.QueryBounds, Is.EqualTo(outputBounds)); + Assert.That(observedFragment.Bounds, Is.EqualTo(outputBounds)); + Assert.That(observedFragment.Cardinality, Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + Assert.That(observedFragment.EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(observedFragment.ContributesValues, Is.True); + Assert.That(observedFragment.CanBeUsedAsValueInput, Is.True); + + Assert.That(resource.DisposeCalls, Is.Zero); + }); + } + + [Test] + public void FilterEffectGeometry_UsesDeclaredReadbackAndResourceShrinksOutputAndRejectsRetainedFacades() + { + var bounds = new Rect(0, 0, 8, 6); + var shrink = new Rect(2, 1, 3, 3); + var declared = new GeometryResource("declared"); + var effect = new PluginGeometryEffect(declared, shrink); + using PluginGeometryEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using FilterEffectRenderNode node = resource.CreateRenderNode(); + node.AddChild(new SolidSourceNode(bounds, Colors.White)); + + using var renderer = CreateRenderer(node, outputScale: 1, maxWorkingScale: 2); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(effect.ExecutionCalls, Is.EqualTo(1)); + Assert.That(effect.ResourceUses, Is.EqualTo(1)); + Assert.That(effect.SnapshotUses, Is.EqualTo(1)); + Assert.That(effect.ObservedResource, Is.SameAs(declared)); + Assert.That(effect.ObservedInputRasterBounds, + Is.EqualTo(PixelRect.FromRect(bounds, 1).ToRect(1))); + Assert.That(effect.ObservedCanvasRasterBounds, + Is.EqualTo(PixelRect.FromRect(bounds, 1).ToRect(1))); + Assert.That(rasterization.Bounds, Is.EqualTo(bounds), + "Runtime shrink does not narrow recording-time conservative bounds."); + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(AlphaAt(rasterization.Bitmap!, 3, 2), Is.GreaterThan(0.9f)); + Assert.That(AlphaAt(rasterization.Bitmap!, 0, 0), Is.LessThan(0.01f)); + Assert.That(AlphaAt(rasterization.Bitmap!, 7, 5), Is.LessThan(0.01f)); + Assert.That(declared.DisposeCalls, Is.Zero, "Borrowed Geometry resources remain externally owned."); + + Assert.That(() => _ = effect.RetainedSession!.OutputBounds, + Throws.TypeOf()); + Assert.That(() => _ = effect.RetainedInput!.Bounds, + Throws.TypeOf()); + Assert.That(() => _ = effect.RetainedCanvas!.Density, + Throws.TypeOf()); + Assert.That(() => effect.RetainedCanvas!.Use(static _ => { }), + Throws.TypeOf()); + Assert.That(() => effect.RetainedInput!.UseSnapshot(static _ => { }), + Throws.TypeOf()); + Assert.That(() => effect.RetainedSnapshot!.GetPixelSpan(), + Throws.TypeOf()); + }); + } + + [Test] + public void Geometry_DiscardOutputWinsOverShrinkAndLeavesAConservativeTransparentResult() + { + var bounds = new Rect(0, 0, 6, 4); + int executionCalls = 0; + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource(bounds, Colors.White)); + GeometryCall> call = RenderDefinitionCallFactory.Geometry( + session => + { + executionCalls++; + session.Canvas.Use(canvas => session.Input.Draw(canvas)); + session.SetOutputBounds(new Rect(1, 1, 2, 2)); + session.DiscardOutput(); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.OutputBounds); + RenderFragmentHandle geometry = context.Geometry(source, call); + Assert.That(geometry.ValueCardinality, Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + context.Publish(geometry); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(executionCalls, Is.EqualTo(1)); + Assert.That(rasterization.Bounds, Is.EqualTo(bounds)); + Assert.That(rasterization.IsEmpty, Is.False, + "A non-empty conservative request returns an owned bitmap even when Geometry discards its value."); + Assert.That(MaxAlpha(rasterization.Bitmap!), Is.LessThan(0.01f)); + }); + } + + [Test] + public void Geometry_InputSnapshotIsUnavailableUnlessReadbackWasDeclared() + { + var bounds = new Rect(0, 0, 4, 3); + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource(bounds, Colors.White)); + GeometryCall> call = RenderDefinitionCallFactory.Geometry( + session => session.Input.UseSnapshot(static _ => { }), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: false); + context.Publish(context.Geometry(source, call)); + }); + + Assert.That( + () => + { + using RenderNodeRasterization _ = Rasterize(node); + }, + Throws.TypeOf() + .With.Message.Contains("readback was not declared")); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + float outputScale, + float maxWorkingScale) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = outputScale, + MaxWorkingScale = maxWorkingScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = CreateRenderer(node, outputScale: 1, maxWorkingScale: 2); + return renderer.Rasterize(); + } + + private static float AlphaAt(Bitmap bitmap, int x, int y) + { + Assert.That(bitmap.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + return (float)bitmap.GetRow(y)[x * 4 + 3]; + } + + private static float MaxAlpha(Bitmap bitmap) + { + float max = 0; + for (int y = 0; y < bitmap.Height; y++) + { + Span row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + max = Math.Max(max, (float)row[x * 4 + 3]); + } + + return max; + } + + private static OpaqueRenderCall> MetadataSource(Rect bounds) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("A metadata request must not execute the source."), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector); + } + + private static bool GeometryHitTest(RenderHitTestContext context, Point point) + { + Assert.Multiple(() => + { + Assert.That(context.OutputBounds, Is.EqualTo(new Rect(8, 18, 12, 10))); + Assert.That(context.Inputs, Has.Count.EqualTo(1)); + Assert.That(context.Inputs[0].Bounds, Is.EqualTo(new Rect(10, 20, 8, 6))); + }); + return context.OutputBounds.Contains(point); + } + + private static OpaqueRenderCall<(Rect bounds, Color color)> ExecutingSource(Rect bounds, Color color) + { + return RenderDefinitionCallFactory.Opaque( + (bounds, color), + static (session, state) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(state.color)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private sealed class SolidSourceNode(Rect bounds, Color color) : RenderNode + { + public override void Process(RenderNodeContext context) + => context.Publish(context.OpaqueSource(ExecutingSource(bounds, color))); + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + [SuppressResourceClassGeneration] + private sealed partial class PluginGeometryEffect( + GeometryResource declaredResource, + Rect shrinkBounds) : FilterEffect + { + private static readonly RenderResourceSlot s_resourceSlot = new(); + private static readonly GeometryDefinition s_definition = + GeometryDefinition.Create( + static (session, effect) => effect.ExecuteGeometry(session), + RenderBoundsContract.Identity, + RenderHitTestContract.OutputBounds, + requiresReadback: true, + resources: [s_resourceSlot]); + + public int ExecutionCalls { get; private set; } + + public int ResourceUses { get; private set; } + + public int SnapshotUses { get; private set; } + + public GeometryResource? ObservedResource { get; private set; } + + public GeometrySession? RetainedSession { get; private set; } + + public RenderExecutionInput? RetainedInput { get; private set; } + + public RenderCallbackCanvas? RetainedCanvas { get; private set; } + + public Bitmap? RetainedSnapshot { get; private set; } + + public Rect ObservedInputRasterBounds { get; private set; } + + public Rect ObservedCanvasRasterBounds { get; private set; } + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + RenderResource token = context.Borrow(declaredResource); + context.Geometry(s_definition.Call(this, [s_resourceSlot.Bind(token)])); + } + + private void ExecuteGeometry(GeometrySession session) + { + ExecutionCalls++; + RetainedSession = session; + RetainedInput = session.Input; + RetainedCanvas = session.Canvas; + ObservedInputRasterBounds = session.Input.RasterBounds; + ObservedCanvasRasterBounds = session.Canvas.RasterBounds; + session.UseResource(s_resourceSlot, value => + { + ResourceUses++; + ObservedResource = value; + }); + session.Input.UseSnapshot(bitmap => + { + SnapshotUses++; + RetainedSnapshot = bitmap; + Assert.That(bitmap.IsDisposed, Is.False); + }); + Assert.That( + () => session.Input.UseSnapshot(static _ => { }), + Throws.TypeOf(), + "A declared snapshot is still a one-shot lease."); + Assert.That( + () => session.SetOutputBounds(new Rect(-1, -1, 20, 20)), + Throws.TypeOf(), + "Geometry may shrink but cannot grow beyond its allocated forward bounds."); + session.Canvas.Use(canvas => session.Input.Draw(canvas)); + session.SetOutputBounds(shrinkBounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + + public override FilterEffectRenderNode CreateRenderNode() => new(this); + } + } + + private sealed class GeometryResource(string name) : IDisposable + { + public string Name { get; } = name; + + public int DisposeCalls { get; private set; } + + public void Dispose() => DisposeCalls++; + } + + private readonly record struct FragmentSnapshot( + Rect Bounds, + EffectiveScale EffectiveScale, + RenderValueCardinality Cardinality, + bool ContributesValues, + bool CanBeUsedAsValueInput) + { + public static FragmentSnapshot From(RenderFragmentHandle handle) + { + Assert.That(handle.TryGetMetadata(out RenderFragmentMetadata metadata), Is.True); + return new FragmentSnapshot( + metadata.Bounds, + metadata.EffectiveScale, + handle.ValueCardinality, + handle.ContributesValuesToTarget, + handle.CanBeUsedAsValueInput); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) => + new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public CpuRenderTarget(PixelSize size) + : base(CreateSurface(size), size.Width, size.Height) + { + } + + private static SKSurface CreateSurface(PixelSize size) + { + return SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a CPU contract-test surface."); + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/MaterialTextureAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/MaterialTextureAuthoringContractTests.cs new file mode 100644 index 0000000000..057780b9f7 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/MaterialTextureAuthoringContractTests.cs @@ -0,0 +1,79 @@ +using System.Numerics; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics.Backend; +using Beutl.Graphics3D; +using Beutl.Graphics3D.Materials; +using Beutl.Graphics3D.Textures; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class MaterialTextureAuthoringContractTests : PublicApiContractTestBase +{ + [Test] + public void MaterialResource_TextureEnumerationRemainsAnOptionalExtensionPoint() + { + AssertDoesNotHaveFriendAccess(typeof(Material3D).Assembly); + var material = new PluginMaterial(); + using PluginMaterial.Resource evaluated = material.ToResource(CompositionContext.Default); + using var texture = new DrawableTextureSource.Resource(); + using var resource = new PluginMaterial.Resource(texture); + + Assert.Multiple(() => + { + Assert.That(typeof(MaterialResourceUsingDefaultTextureEnumeration).IsAbstract, Is.True); + Assert.That(typeof(PluginMaterial).IsAbstract, Is.False); + Assert.That(typeof(PluginMaterial.Resource).IsAbstract, Is.False); + Assert.That(evaluated, Is.TypeOf()); + Assert.That(resource.DeclaredTextures, Is.EqualTo(new TextureSource.Resource[] { texture })); + }); + } + + private abstract class MaterialResourceUsingDefaultTextureEnumeration : Material3D.Resource + { + protected MaterialResourceUsingDefaultTextureEnumeration() + { + } + } + + [SuppressResourceClassGeneration] + private sealed partial class PluginMaterial : Material3D + { + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(null); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : Material3D.Resource + { + private readonly DrawableTextureSource.Resource? _texture; + + public Resource(DrawableTextureSource.Resource? texture) + { + _texture = texture; + } + + protected override IPipeline3D? Pipeline => null; + + public TextureSource.Resource[] DeclaredTextures => [.. EnumerateTextureSources()]; + + protected override IEnumerable EnumerateTextureSources() + { + if (_texture is not null) + yield return _texture; + } + + public override void EnsurePipeline(RenderContext3D context) + { + } + + public override void Bind(RenderContext3D context, Object3D.Resource obj, Matrix4x4 worldMatrix) + { + } + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/OrphanedTargetEffectContractTests.cs b/tests/Beutl.PublicApiContractTests/OrphanedTargetEffectContractTests.cs new file mode 100644 index 0000000000..7391e2e585 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/OrphanedTargetEffectContractTests.cs @@ -0,0 +1,351 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class OrphanedTargetEffectContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 4, 3); + + public enum TargetEffectKind + { + TargetCommand, + TargetScope, + TargetLayerScope, + } + + [TestCase(TargetEffectKind.TargetCommand)] + [TestCase(TargetEffectKind.TargetScope)] + [TestCase(TargetEffectKind.TargetLayerScope)] + public void UnpublishedTargetEffect_FailsTheRecordingInsteadOfSilentlyDoingNothing( + TargetEffectKind kind) + { + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("orphan-source")); + _ = RecordTargetEffect(context, kind, source); + context.Publish(source); + }); + + Assert.That( + () => Rasterize(node), + Throws.TypeOf() + .With.Message.StartsWith( + "A recorded target-effect fragment was neither published nor consumed. " + + "Publish it, wrap it in a fragment you publish, or call Drop to abandon it " + + "deliberately.") + .And.Message.Contains($"Fragment kind: {kind}")); + } + + [TestCase(TargetEffectKind.TargetCommand)] + [TestCase(TargetEffectKind.TargetScope)] + [TestCase(TargetEffectKind.TargetLayerScope)] + public void TargetEffectConsumedByAPublishedFragment_Executes(TargetEffectKind kind) + { + int executions = 0; + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("consumed-source")); + RenderFragmentHandle effect = RecordTargetEffect( + context, + kind, + source, + () => executions++); + context.Publish(context.Layer([effect], s_bounds)); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(executions, Is.EqualTo(1)); + }); + } + + [Test] + public void AbandonedBlendAndOpacityWrappers_StayLegal() + { + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("wrapper-source")); + _ = context.Blend(source, Beutl.Graphics.BlendMode.Multiply); + _ = context.Opacity(source, 0.5f); + context.Publish(source); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(rasterization.IsEmpty, Is.False); + } + + [Test] + public void Drop_AbandonsARecordedTargetEffectWithoutExecutingIt() + { + int executions = 0; + bool metadataIsConcrete = false; + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("dropped-source")); + RenderFragmentHandle command = RecordTargetEffect( + context, + TargetEffectKind.TargetCommand, + source, + () => executions++); + metadataIsConcrete = command.TryGetMetadata(out _); + context.Drop(command); + context.Publish(source); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(metadataIsConcrete, Is.True); + Assert.That(executions, Is.Zero); + }); + } + + [Test] + public void Drop_SurvivesAbsorptionIntoTheRecordingParent() + { + using var inner = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("nested-source")); + context.Drop(RecordTargetEffect(context, TargetEffectKind.TargetCommand, source)); + context.Publish(source); + }); + using var outer = new DelegateNode(context => + { + context.PublishRange(context.RecordNode(inner, [])); + }); + + using RenderNodeRasterization rasterization = Rasterize(outer); + + Assert.That(rasterization.IsEmpty, Is.False); + } + + [TestCase(TargetEffectKind.TargetCommand)] + [TestCase(TargetEffectKind.TargetScope)] + [TestCase(TargetEffectKind.TargetLayerScope)] + public void UnpublishedTargetEffectInAChildNode_FailsThatChildsOwnRecording(TargetEffectKind kind) + { + using var inner = new OrphaningChildNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("nested-orphan-source")); + _ = RecordTargetEffect(context, kind, source); + context.Publish(source); + }); + using var outer = new DelegateNode(context => + { + context.PublishRange(context.RecordNode(inner, [])); + }); + + Assert.That( + () => Rasterize(outer), + Throws.TypeOf() + .With.Message.StartsWith( + "A recorded target-effect fragment was neither published nor consumed.") + .And.Message.Contains($"Fragment kind: {kind}") + .And.Message.Contains($"recorded by: {typeof(OrphaningChildNode).FullName}")); + } + + // Drop is not transitive and a parent never receives handles to a child's internal fragments. + [Test] + public void AChildTargetEffectPublicationTheParentNeverConsumes_StaysLegal() + { + int executions = 0; + using var inner = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("nested-abandoned-source")); + RenderFragmentHandle command = RecordTargetEffect( + context, + TargetEffectKind.TargetCommand, + source, + () => executions++); + context.PublishRange([source, command]); + }); + using var outer = new DelegateNode(context => + { + IReadOnlyList outputs = context.RecordNode(inner, []); + context.Publish(outputs[0]); + }); + + using RenderNodeRasterization rasterization = Rasterize(outer); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(executions, Is.Zero); + }); + } + + [Test] + public void AnEntirelyAbandonedChildRecording_StaysLegalAndDrawsNothing() + { + using var inner = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("discarded-source")); + context.Publish(RecordTargetEffect(context, TargetEffectKind.TargetScope, source)); + }); + using var outer = new DelegateNode(context => + { + _ = context.RecordNode(inner, []); + }); + + using RenderNodeRasterization rasterization = Rasterize(outer); + + Assert.That(rasterization.IsEmpty, Is.True); + } + + [Test] + public void AChildDroppingAnInputItWasHanded_AbandonsTheParentsOwnTargetEffect() + { + using var inner = new DelegateNode(context => context.Drop(context.Inputs[0])); + using var outer = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("handed-over-source")); + RenderFragmentHandle command = RecordTargetEffect( + context, + TargetEffectKind.TargetCommand, + source); + context.RecordNode(inner, [command]); + context.Publish(context.OpaqueSource(ExecutingSource("surviving-source"))); + }); + + using RenderNodeRasterization rasterization = Rasterize(outer); + + Assert.That(rasterization.IsEmpty, Is.False); + } + + [Test] + public void Drop_RejectsAHandleFromAnotherTransaction() + { + RenderFragmentHandle? foreign = null; + using var inner = new DelegateNode(context => context.Drop(foreign!)); + using var outer = new DelegateNode(context => + { + foreign = context.OpaqueSource(ExecutingSource("foreign-source")); + context.RecordNode(inner, []); + context.Publish(foreign); + }); + + Assert.That( + () => Rasterize(outer), + Throws.TypeOf() + .With.Message.EqualTo( + "The render fragment handle belongs to a different recording transaction.")); + } + + [Test] + public void Drop_RejectsAnAlreadyPublishedHandle() + { + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("published-source")); + context.Publish(source); + context.Drop(source); + }); + + Assert.That( + () => Rasterize(node), + Throws.TypeOf() + .With.Message.EqualTo( + "The render fragment was already published and cannot be dropped.")); + } + + [Test] + public void Publish_RejectsAnAlreadyDroppedHandle() + { + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource("redeemed-source")); + context.Drop(source); + context.Publish(source); + }); + + Assert.That( + () => Rasterize(node), + Throws.TypeOf() + .With.Message.EqualTo( + "The render fragment was already dropped and cannot be published.")); + } + + private static RenderFragmentHandle RecordTargetEffect( + RenderNodeContext context, + TargetEffectKind kind, + RenderFragmentHandle source, + Action? onExecute = null) + { + return kind switch + { + TargetEffectKind.TargetCommand => context.TargetCommand( + [source], + RenderDefinitionCallFactory.TargetCommand( + _ => onExecute?.Invoke(), + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.None)), + TargetEffectKind.TargetScope => context.TargetScope( + source, + RenderDefinitionCallFactory.TargetScope( + session => + { + onExecute?.Invoke(); + session.Canvas.Use(_ => session.ReplayInput()); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply)), + TargetEffectKind.TargetLayerScope => context.TargetLayerScope( + [ + onExecute is null + ? context.ContributeValues(source) + : RecordTargetEffect(context, TargetEffectKind.TargetCommand, source, onExecute) + ], + TargetRegion.Region(s_bounds)), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + } + + private static OpaqueRenderCall> ExecutingSource(object _) + { + return RenderDefinitionCallFactory.Opaque( + static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + return renderer.Rasterize(); + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class OrphaningChildNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } +} diff --git a/tests/Beutl.PublicApiContractTests/PerspectiveBoundsContractTests.cs b/tests/Beutl.PublicApiContractTests/PerspectiveBoundsContractTests.cs new file mode 100644 index 0000000000..909090e7d0 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/PerspectiveBoundsContractTests.cs @@ -0,0 +1,98 @@ +using System.Reflection; + +using Beutl.Graphics; + +namespace Beutl.PublicApiContractTests; + +/// +/// Every way a plugin author can bound a transformed rectangle survives perspective. The mapped-corner +/// box does not: it is exact only while the rectangle stays on one side of the matrix's w = 0 +/// plane, and it fails silently rather than loudly when it does not — it returns a box on the far side +/// of the image. It is bit-identical to the safe answers everywhere else, so a caller cannot discover +/// the difference by testing, which is why it is not reachable from here. +/// +[TestFixture] +public sealed class PerspectiveBoundsContractTests +{ + // A 124x58 rectangle centred in a 256x144 frame, carrying a divisor that reaches zero across it. + private static readonly Rect s_local = new(0, 0, 124, 58); + + private static Matrix Crossing(float persX) => + Matrix.CreateTranslation(-62, -29) + * new Matrix(1, 0, persX, 0, 1, 0, 0, 0, 1) + * Matrix.CreateTranslation(128, 72); + + [Test] + public void OnlyTheCameraPlaneAwareBoundsAreReachable() + { + MethodInfo[] published = typeof(Rect) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(static method => method.Name.EndsWith("AABB", StringComparison.Ordinal)) + .ToArray(); + + Assert.That( + published.Select(static method => method.Name), + Is.EquivalentTo(new[] + { + nameof(Rect.TransformToAABB), + nameof(Rect.TransformToDeliveredAABB), + }), + "the mapped-corner box must not be published alongside the camera-plane aware ones"); + Assert.That( + published.Single(static method => method.Name == nameof(Rect.TransformToAABB)) + .GetParameters().Select(static p => p.Name), + Is.EqualTo(new[] { "matrix", "nearPlane" }), + "the near plane must stay selectable so a caller can opt into Rect.RasterizerNearPlane"); + Assert.That( + published.Single(static method => method.Name == nameof(Rect.TransformToDeliveredAABB)) + .GetParameters().Select(static p => p.Name), + Is.EqualTo(new[] { "matrix", "deliveredTo" }), + "the delivery region is the whole of what makes the exact near plane affordable, so it is " + + "named rather than defaulted"); + } + + [Test] + public void TheBoundsContainTheImageOfAPlaneCrossingRectangle() + { + Matrix matrix = Crossing(0.05f); + Rect bounds = s_local.TransformToAABB(matrix); + + int sampled = 0; + int outside = 0; + for (float x = 0; x <= s_local.Width; x += 0.5f) + { + for (float y = 0; y <= s_local.Height; y += 0.5f) + { + var source = new Point(x, y); + if (matrix.GetTransformDivisor(source) < Rect.DefaultNearPlane) continue; + + sampled++; + Point image = source.Transform(matrix); + if (image.X < bounds.Left || image.X > bounds.Right + || image.Y < bounds.Top || image.Y > bounds.Bottom) + { + outside++; + } + } + } + + Assert.Multiple(() => + { + Assert.That(sampled, Is.GreaterThan(0), "the fixture must straddle the camera plane"); + Assert.That(outside, Is.Zero, "the bounds must contain everything in front of the near plane"); + Assert.That(bounds.Width, Is.GreaterThan(0).And.LessThan(float.PositiveInfinity)); + }); + } + + [Test] + public void AnAffineTransformIsUnaffectedByTheNearPlane() + { + Matrix affine = Matrix.CreateScale(2, 3) * Matrix.CreateTranslation(10, -4); + + Rect atDefault = s_local.TransformToAABB(affine); + Rect atRasterizer = s_local.TransformToAABB(affine, Rect.RasterizerNearPlane); + + Assert.That(atRasterizer, Is.EqualTo(atDefault)); + Assert.That(atDefault, Is.EqualTo(new Rect(10, -4, 248, 174))); + } +} diff --git a/tests/Beutl.PublicApiContractTests/PipelineSpecializationContractTests.cs b/tests/Beutl.PublicApiContractTests/PipelineSpecializationContractTests.cs new file mode 100644 index 0000000000..c62e0f06fe --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/PipelineSpecializationContractTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using Beutl.Graphics.Backend; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class PipelineSpecializationContractTests +{ + /// + /// A push-constant update must name every shader stage of every declared range it overlaps, and which + /// stages those are is a property of the bound pipeline layout, not of the calling site. Offering the + /// caller a stage argument invited it to name only the stage it reads from — undefined behaviour the + /// driver is not required to diagnose, and which the Vulkan validation gate catches as + /// VUID-vkCmdPushConstants-offset-01796. + /// + [Test] + public void SettingPushConstants_OffersNoWayToNameTheStages() + { + MethodInfo setPushConstants = typeof(IRenderPass3D) + .GetMethods() + .Single(static method => method.Name == nameof(IRenderPass3D.SetPushConstants)); + + Assert.That( + setPushConstants.GetParameters().Select(static parameter => parameter.ParameterType), + Is.EqualTo(new[] { setPushConstants.GetGenericArguments()[0] }), + "the data is the only thing the caller decides"); + } + + [Test] + public void ExternalAuthorCanDescribeImmutableTypedSpecializationConstants() + { + SpecializationConstant direction = SpecializationConstant.Create( + 3, + 1, + ShaderStage.Vertex | ShaderStage.Fragment); + SpecializationConstant ascending = SpecializationConstant.Create( + 4, + true, + ShaderStage.Fragment); + SpecializationConstant opacity = SpecializationConstant.Create( + 5, + 0.625f, + ShaderStage.Fragment); + PipelineOptions options = PipelineOptions.Fullscreen; + options.SpecializationConstants = [direction, ascending, opacity]; + Span directionValue = stackalloc byte[direction.SizeInBytes]; + Span ascendingValue = stackalloc byte[ascending.SizeInBytes]; + Span opacityValue = stackalloc byte[opacity.SizeInBytes]; + direction.CopyValueTo(directionValue); + ascending.CopyValueTo(ascendingValue); + opacity.CopyValueTo(opacityValue); + int copiedDirection = BitConverter.ToInt32(directionValue); + uint copiedAscending = BitConverter.ToUInt32(ascendingValue); + float copiedOpacity = BitConverter.ToSingle(opacityValue); + + Assert.Multiple(() => + { + Assert.That(options.SpecializationConstants, Has.Length.EqualTo(3)); + Assert.That(direction.ConstantId, Is.EqualTo(3)); + Assert.That(direction.Stages, Is.EqualTo(ShaderStage.Vertex | ShaderStage.Fragment)); + Assert.That(direction.SizeInBytes, Is.EqualTo(sizeof(int))); + Assert.That(ascending.SizeInBytes, Is.EqualTo(sizeof(uint))); + Assert.That(copiedDirection, Is.EqualTo(1)); + Assert.That(copiedAscending, Is.EqualTo(1)); + Assert.That(copiedOpacity, Is.EqualTo(0.625f)); + Assert.That(direction, Is.Not.EqualTo(SpecializationConstant.Create(3, 0, direction.Stages))); + }); + } +} diff --git a/tests/Beutl.PublicApiContractTests/PublicApiContractTestBase.cs b/tests/Beutl.PublicApiContractTests/PublicApiContractTestBase.cs new file mode 100644 index 0000000000..53898cf17f --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/PublicApiContractTestBase.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Beutl.Graphics.Rendering; + +namespace Beutl.PublicApiContractTests; + +public abstract class PublicApiContractTestBase +{ + protected static void AssertDoesNotHaveFriendAccess(Assembly targetAssembly) + { + string contractAssemblyName = typeof(PublicApiContractTestBase).Assembly.GetName().Name!; + string?[] friendAssemblyNames = targetAssembly + .GetCustomAttributes() + .Select(static attribute => new AssemblyName(attribute.AssemblyName).Name) + .ToArray(); + + Assert.That(friendAssemblyNames, Does.Not.Contain(contractAssemblyName)); + } +} + +[TestFixture] +public sealed class ProjectShapeContractTests : PublicApiContractTestBase +{ + [Test] + public void Engine_DoesNotGrantFriendAccessToContractAssembly() + { + AssertDoesNotHaveFriendAccess(typeof(RenderNode).Assembly); + } +} diff --git a/tests/Beutl.PublicApiContractTests/RasterFootprintContractTests.cs b/tests/Beutl.PublicApiContractTests/RasterFootprintContractTests.cs new file mode 100644 index 0000000000..fb4d84aeb9 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RasterFootprintContractTests.cs @@ -0,0 +1,164 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class RasterFootprintContractTests +{ + [Test] + public void LegacyEffectTarget_ExposesImmutableDeviceAndTranslatedRasterFootprints() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + PixelRect canonical = PixelRect.FromRect(bounds, density); + using RenderTarget renderTarget = RenderTarget.CreateNull( + canonical.Width + 1, + canonical.Height + 2); + using var target = new EffectTarget( + renderTarget, + bounds, + EffectiveScale.At(density)); + PixelRect allocation = target.DeviceBounds; + Rect initialRasterBounds = target.RasterBounds; + var translation = new Vector(3.25f, -1.5f); + + target.Bounds = target.Bounds.Translate(translation); + + Assert.Multiple(() => + { + Assert.That(allocation.Position, Is.EqualTo(canonical.Position)); + Assert.That(allocation.Size, + Is.EqualTo(new PixelSize(renderTarget.Width, renderTarget.Height))); + Assert.That(target.DeviceBounds, Is.EqualTo(allocation)); + Assert.That(target.RasterBounds, Is.EqualTo(initialRasterBounds.Translate(translation))); + Assert.That(target.RasterBounds.Size, Is.EqualTo(initialRasterBounds.Size)); + Assert.That(initialRasterBounds.Position, Is.EqualTo(bounds.Position)); + Assert.That(target.Bounds.Size, Is.EqualTo(bounds.Size)); + }); + } + + [Test] + public void LegacyCustomEffectBufferSize_RemainsLocalToLogicalDimensions() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + + PixelRect deviceBounds = CustomFilterEffectContext.DeviceBufferBounds(bounds, density); + + Assert.Multiple(() => + { + Assert.That(deviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, density))); + Assert.That(deviceBounds.Size, Is.EqualTo(new PixelSize(17, 13))); + Assert.That(CustomFilterEffectContext.DeviceBufferSize(bounds, density), + Is.EqualTo((16, 12))); + }); + } + + [Test] + public void LegacyCustomShaderApi_SeparatesAllocationMappingAndRendering() + { + Type contextType = typeof(CustomFilterEffectContext); + Type shaderType = typeof(SKSLShader); + Type shaderBuilderType = typeof(SKSLShaderBuilder); + var useMappedInputShader = contextType.GetMethods() + .Single(method => method.Name == nameof(CustomFilterEffectContext.UseMappedInputShader)); + Type stateType = useMappedInputShader.GetGenericArguments().Single(); + Type[] mappedInputParameters = useMappedInputShader.GetParameters() + .Select(static parameter => parameter.ParameterType) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That( + contextType.GetMethod( + nameof(CustomFilterEffectContext.ResolveTargetDensity), + [typeof(Rect)])?.ReturnType, + Is.EqualTo(typeof(float))); + Assert.That( + contextType.GetMethod( + nameof(CustomFilterEffectContext.CreateTargetLike), + [typeof(EffectTarget)]), + Is.Not.Null); + Assert.That( + contextType.GetMethod( + nameof(CustomFilterEffectContext.CreateReplacement), + [typeof(EffectTarget), typeof(RenderTarget)]), + Is.Not.Null); + Assert.That( + contextType.GetMethod( + nameof(CustomFilterEffectContext.CreateMappedInputShader), + [typeof(EffectTarget), typeof(EffectTarget), typeof(SKShader)]), + Is.Not.Null); + Assert.That( + useMappedInputShader.IsGenericMethodDefinition, + Is.True); + Assert.That( + useMappedInputShader.ReturnType, + Is.EqualTo(typeof(bool)), + "The mapped-input readback reports whether the callback ran so a degraded preview keeps its source target."); + Assert.That( + mappedInputParameters, + Is.EqualTo(new[] + { + typeof(EffectTarget), + typeof(EffectTarget), + stateType, + typeof(Action<,>).MakeGenericType(stateType, typeof(SKShader)), + typeof(SKShaderTileMode), + typeof(SKShaderTileMode), + })); + Assert.That( + shaderType.GetMethod( + nameof(SKSLShader.RenderToTarget), + [typeof(CustomFilterEffectContext), shaderBuilderType, typeof(EffectTarget)]), + Is.Not.Null); + Assert.That( + shaderType.GetMethod(nameof(SKSLShader.CreateBuilder), Type.EmptyTypes)?.ReturnType, + Is.EqualTo(shaderBuilderType)); + Assert.That( + shaderBuilderType.GetProperty(nameof(SKSLShaderBuilder.Uniforms))?.PropertyType, + Is.EqualTo(typeof(SKRuntimeEffectUniforms))); + Assert.That( + shaderBuilderType.GetProperty(nameof(SKSLShaderBuilder.Children))?.PropertyType, + Is.EqualTo(typeof(SKRuntimeEffectChildren))); + Assert.That( + shaderBuilderType.GetMethod(nameof(SKSLShaderBuilder.Build), Type.EmptyTypes)?.ReturnType, + Is.EqualTo(typeof(SKShader))); + Assert.That(shaderType.GetProperty("Effect"), Is.Null, + "the owning shader must not expose its disposable runtime effect"); + Assert.That(shaderType.GetMethod("ApplyToNewTarget"), Is.Null, + "the allocation-owning compatibility overload must not remain public"); + }); + } + + [Test] + public void GridAwareRasterFacades_ExposeTheCompositionDeviceTranslation() + { + Assert.Multiple(() => + { + Assert.That( + typeof(EffectTarget).GetProperty(nameof(EffectTarget.DeviceGridOffset)), + Is.Not.Null); + Assert.That( + typeof(CustomFilterEffectContext).GetProperty( + nameof(CustomFilterEffectContext.DeviceGridOffset)), + Is.Not.Null); + Assert.That( + typeof(RenderExecutionInput).GetProperty( + nameof(RenderExecutionInput.DeviceGridOffset)), + Is.Not.Null); + Assert.That( + typeof(RenderCallbackCanvas).GetProperty( + nameof(RenderCallbackCanvas.DeviceGridOffset)), + Is.Not.Null); + Assert.That( + typeof(ShaderExecutionContext).GetProperty( + nameof(ShaderExecutionContext.DeviceGridOffset)), + Is.Not.Null); + }); + } +} diff --git a/tests/Beutl.PublicApiContractTests/RenderDefinitionCallFactory.cs b/tests/Beutl.PublicApiContractTests/RenderDefinitionCallFactory.cs new file mode 100644 index 0000000000..7ad84020db --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RenderDefinitionCallFactory.cs @@ -0,0 +1,192 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.PublicApiContractTests; + +internal static class RenderDefinitionCallFactory +{ + public static OpaqueRenderCall Opaque( + TState state, + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + IEnumerable? bindings = null, + RenderInputDemandContract inputDemand = default) + where TState : notnull + { + return OpaqueRenderDefinition.Create( + execute, + bounds, + hitTest, + valueCardinality, + scale, + deviceGridSensitivity, + inputReadbacks, + resources, + inputDemand) + .Call(state, bindings); + } + + public static OpaqueRenderCall> Opaque( + Action execute, + OpaqueRenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderValueCardinality valueCardinality, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + IEnumerable? bindings = null, + RenderInputDemandContract inputDemand = default) + { + return Opaque( + execute, + static (session, action) => action(session), + bounds, + hitTest, + valueCardinality, + scale, + deviceGridSensitivity, + inputReadbacks, + resources, + bindings, + inputDemand); + } + + public static GeometryCall Geometry( + TState state, + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback = false, + IEnumerable? resources = null, + IEnumerable? bindings = null) + where TState : notnull + { + return GeometryDefinition.Create( + render, + bounds, + hitTest, + requiresReadback, + resources) + .Call(state, bindings); + } + + public static GeometryCall> Geometry( + Action render, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback = false, + IEnumerable? resources = null, + IEnumerable? bindings = null) + { + return Geometry( + render, + static (session, action) => action(session), + bounds, + hitTest, + requiresReadback, + resources, + bindings); + } + + public static TargetScopeCall TargetScope( + TState state, + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + RenderDeviceGridMapping deviceGridMapping = RenderDeviceGridMapping.Remapped, + RenderScopeTransformSpace transformSpace = RenderScopeTransformSpace.AmbientTarget, + IEnumerable? resources = null, + IEnumerable? bindings = null) + where TState : notnull + { + return TargetScopeDefinition.Create( + execute, + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + transformSpace, + resources) + .Call(state, bindings); + } + + public static TargetScopeCall> TargetScope( + Action execute, + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + RenderScaleContract scale, + RenderDeviceGridSensitivity deviceGridSensitivity = RenderDeviceGridSensitivity.PhaseDependent, + RenderDeviceGridMapping deviceGridMapping = RenderDeviceGridMapping.Remapped, + RenderScopeTransformSpace transformSpace = RenderScopeTransformSpace.AmbientTarget, + IEnumerable? resources = null, + IEnumerable? bindings = null) + { + return TargetScope( + execute, + static (session, action) => action(session), + bounds, + hitTest, + scale, + deviceGridSensitivity, + deviceGridMapping, + transformSpace, + resources, + bindings); + } + + public static TargetCommandCall TargetCommand( + TState state, + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access = TargetAccess.ReadWrite, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + IEnumerable? bindings = null) + where TState : notnull + { + return TargetCommandDefinition.Create( + execute, + affectedRegion, + queryBounds, + hitTest, + access, + inputReadbacks, + resources) + .Call(state, bindings); + } + + public static TargetCommandCall> TargetCommand( + Action execute, + TargetRegion affectedRegion, + Rect queryBounds, + RenderHitTestContract hitTest, + TargetAccess access = TargetAccess.ReadWrite, + IEnumerable? inputReadbacks = null, + IEnumerable? resources = null, + IEnumerable? bindings = null) + { + return TargetCommand( + execute, + static (session, action) => action(session), + affectedRegion, + queryBounds, + hitTest, + access, + inputReadbacks, + resources, + bindings); + } +} diff --git a/tests/Beutl.PublicApiContractTests/RenderDefinitionPublicSurfaceContractTests.cs b/tests/Beutl.PublicApiContractTests/RenderDefinitionPublicSurfaceContractTests.cs new file mode 100644 index 0000000000..ffaa8bc108 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RenderDefinitionPublicSurfaceContractTests.cs @@ -0,0 +1,130 @@ +using System.Reflection; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class RenderDefinitionPublicSurfaceContractTests +{ + [Test] + public void DefinitionsAndCalls_AreTheExternalRecordingSurface() + { + AssertDefinitionCallSurface(typeof(OpaqueRenderDefinition<>), typeof(OpaqueRenderCall<>)); + AssertDefinitionCallSurface(typeof(TargetScopeDefinition<>), typeof(TargetScopeCall<>)); + AssertDefinitionCallSurface(typeof(TargetCommandDefinition<>), typeof(TargetCommandCall<>)); + AssertDefinitionCallSurface(typeof(RawTargetScopeDefinition<>), typeof(RawTargetScopeCall<>)); + AssertDefinitionCallSurface(typeof(RawTargetCommandDefinition<>), typeof(RawTargetCommandCall<>)); + AssertDefinitionCallSurface(typeof(GeometryDefinition<>), typeof(GeometryCall<>)); + AssertDefinitionCallSurface(typeof(ShaderDefinition<>), typeof(ShaderCall<>)); + } + + [Test] + public void LegacyDescriptionsAndDescriptionOverloads_AreNotPublic() + { + string[] legacyTypes = + [ + "Beutl.Graphics.Rendering.OpaqueRenderDescription", + "Beutl.Graphics.Rendering.TargetScopeDescription", + "Beutl.Graphics.Rendering.TargetCommandDescription", + "Beutl.Graphics.Rendering.RawTargetScopeDescription", + "Beutl.Graphics.Rendering.RawTargetCommandDescription", + "Beutl.Graphics.Effects.GeometryDescription", + "Beutl.Graphics.Effects.ShaderDescription", + ]; + Assembly engine = typeof(RenderNode).Assembly; + string?[] exportedTypes = engine.GetExportedTypes().Select(static type => type.FullName).ToArray(); + + Assert.Multiple(() => + { + foreach (string legacyType in legacyTypes) + Assert.That(exportedTypes, Does.Not.Contain(legacyType), legacyType); + }); + AssertContextCallSurface(typeof(RenderNodeContext), "OpaqueSource", typeof(OpaqueRenderCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "OpaqueMap", typeof(OpaqueRenderCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "OpaqueCombine", typeof(OpaqueRenderCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "OpaqueExpand", typeof(OpaqueRenderCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "TargetScope", typeof(TargetScopeCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "TargetCommand", typeof(TargetCommandCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "RawTargetScope", typeof(RawTargetScopeCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "RawTargetCommand", typeof(RawTargetCommandCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "Geometry", typeof(GeometryCall<>)); + AssertContextCallSurface(typeof(RenderNodeContext), "Shader", typeof(ShaderCall<>)); + AssertContextCallSurface(typeof(FilterEffectContext), "Geometry", typeof(GeometryCall<>)); + AssertContextCallSurface(typeof(FilterEffectContext), "Shader", typeof(ShaderCall<>)); + } + + // HasChanges stays the only way to invalidate a cached node. DisableRenderCache is not a second + // invalidation signal: it opts a recording out of caching altogether, which a node recording a child + // it does not list in ChildNodes has to be able to do for itself. + [Test] + public void HasChanges_IsTheOnlyPublicNodeInvalidationSignal() + { + PropertyInfo? hasChanges = typeof(RenderNode).GetProperty(nameof(RenderNode.HasChanges)); + string[] excludedMembers = ["Cache", "CacheKey", "RuntimeIdentity", "ChangeVersion"]; + + Assert.Multiple(() => + { + Assert.That(hasChanges, Is.Not.Null); + Assert.That(hasChanges!.CanRead, Is.True); + Assert.That(hasChanges.CanWrite, Is.True); + foreach (string member in excludedMembers) + Assert.That(typeof(RenderNode).GetProperty(member), Is.Null, member); + Assert.That( + typeof(RenderNode).GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Any(static method => method.Name is "ClearCache" or "ResetCache" or "ReportRenderCount"), + Is.False); + }); + } + + [Test] + public void DisableRenderCache_IsReachableByAnOutOfTreeNode() + { + MethodInfo? optOut = typeof(RenderNodeContext).GetMethod( + "DisableRenderCache", + BindingFlags.Public | BindingFlags.Instance); + + Assert.That(optOut, Is.Not.Null, + "a node that records an unlisted child must be able to keep itself out of the cache"); + } + + private static void AssertDefinitionCallSurface(Type definition, Type call) + { + MethodInfo? method = definition.GetMethod("Call", BindingFlags.Public | BindingFlags.Instance); + + Assert.Multiple(() => + { + Assert.That(method, Is.Not.Null, definition.Name); + if (method is null) + return; + Assert.That(method!.ReturnType.IsGenericType, Is.True, definition.Name); + Assert.That(method.ReturnType.GetGenericTypeDefinition(), Is.EqualTo(call), definition.Name); + Assert.That(method.GetParameters(), Has.Length.EqualTo(2), definition.Name); + Assert.That(method.GetParameters()[1].ParameterType, + Is.EqualTo(typeof(IEnumerable)), definition.Name); + Assert.That(method.GetParameters()[1].HasDefaultValue, Is.True, definition.Name); + }); + } + + private static void AssertContextCallSurface(Type context, string methodName, Type call) + { + MethodInfo[] methods = context + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => method.Name == methodName) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(methods, Has.Length.EqualTo(1), $"{context.Name}.{methodName}"); + if (methods.Length != 1) + return; + Assert.That(methods[0].IsGenericMethodDefinition, Is.True, $"{context.Name}.{methodName}"); + Assert.That( + methods[0].GetParameters().Any(parameter => + parameter.ParameterType.IsGenericType + && parameter.ParameterType.GetGenericTypeDefinition() == call), + Is.True, + $"{context.Name}.{methodName}"); + }); + } +} diff --git a/tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs new file mode 100644 index 0000000000..e3cba023b9 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs @@ -0,0 +1,817 @@ +using System.Reactive; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class RenderNodeAuthoringContractTests +{ + private static readonly RenderResourceSlot s_ownedSlot = new(); + private static readonly RenderResourceSlot s_borrowedSlot = new(); + + [Test] + public void PublishingNothing_DropsInputs_WhilePassThroughPreservesOrderAndMetadata() + { + var firstBounds = new Rect(2, 3, 10, 20); + var secondBounds = new Rect(30, 5, 4, 8); + + using var drop = new DelegateContainerNode(context => + { + Assert.Multiple(() => + { + Assert.That(context.Inputs, Has.Count.EqualTo(2)); + Assert.That( + context.Inputs[0].TryGetMetadata(out RenderFragmentMetadata firstMetadata), + Is.True); + Assert.That(firstMetadata.Bounds, Is.EqualTo(firstBounds)); + Assert.That( + context.Inputs[1].TryGetMetadata(out RenderFragmentMetadata secondMetadata), + Is.True); + Assert.That(secondMetadata.Bounds, Is.EqualTo(secondBounds)); + Assert.That(context.TryCalculateInputBounds(out Rect inputBounds), Is.True); + Assert.That(inputBounds, Is.EqualTo(firstBounds.Union(secondBounds))); + }); + + // Returning without publishing is the intentional no-output shape. + }); + drop.AddChild(SourceNode(firstBounds)); + drop.AddChild(SourceNode(secondBounds)); + + RenderNodeMeasurement dropped = Measure(drop); + Assert.Multiple(() => + { + Assert.That(dropped.HasFragments, Is.False); + Assert.That(dropped.ValueCardinality, Is.EqualTo(RenderValueCardinality.None)); + Assert.That(dropped.OutputBounds, Is.EqualTo(default(Rect))); + }); + + using var passThrough = new DelegateContainerNode(context => context.PassThrough()); + passThrough.AddChild(SourceNode(firstBounds)); + passThrough.AddChild(SourceNode(secondBounds)); + + RenderNodeMeasurement passed = Measure(passThrough); + Assert.Multiple(() => + { + Assert.That(passed.HasFragments, Is.True); + Assert.That(passed.HasContributingValues, Is.True); + Assert.That(passed.ValueCardinality, Is.EqualTo(RenderValueCardinality.Exactly(2))); + Assert.That(passed.OutputBounds, Is.EqualTo(firstBounds.Union(secondBounds))); + }); + } + + [Test] + public void PublishMappedInputs_IsAvailableToExternalNodeAuthorsAndPreservesOrderedMetadata() + { + var firstBounds = new Rect(2, 3, 10, 20); + var secondBounds = new Rect(30, 5, 4, 8); + var mappedInputs = new List(); + var mappedOutputs = new List(); + + using var simpleNode = new DelegateContainerNode(context => + { + context.PublishMappedInputs(input => + { + mappedInputs.Add(FragmentSnapshot.From(input)); + RenderFragmentHandle mapped = context.Opacity(input, 0.5f); + mappedOutputs.Add(FragmentSnapshot.From(mapped)); + return mapped; + }); + }); + simpleNode.AddChild(SourceNode(firstBounds)); + simpleNode.AddChild(SourceNode(secondBounds)); + + RenderNodeMeasurement simpleMeasurement = Measure(simpleNode); + + var stateMappedBounds = new List(); + using var stateNode = new DelegateContainerNode(context => + context.PublishMappedInputs( + stateMappedBounds, + static (current, input, observedBounds) => + { + Assert.That(input.TryGetMetadata(out RenderFragmentMetadata metadata), Is.True); + observedBounds.Add(metadata.Bounds); + return current.Opacity(input, 0.75f); + })); + stateNode.AddChild(SourceNode(firstBounds)); + + RenderNodeMeasurement stateMeasurement = Measure(stateNode); + + Assert.Multiple(() => + { + Assert.That( + mappedInputs.Select(static snapshot => snapshot.Bounds), + Is.EqualTo(new[] { firstBounds, secondBounds })); + Assert.That(mappedOutputs, Is.EqualTo(mappedInputs)); + Assert.That(simpleMeasurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Exactly(2))); + Assert.That(simpleMeasurement.HasContributingValues, Is.True); + Assert.That(simpleMeasurement.OutputBounds, Is.EqualTo(firstBounds.Union(secondBounds))); + + Assert.That(stateMappedBounds, Is.EqualTo(new[] { firstBounds })); + Assert.That(stateMeasurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(stateMeasurement.HasContributingValues, Is.True); + Assert.That(stateMeasurement.OutputBounds, Is.EqualTo(firstBounds)); + }); + } + + [Test] + public void PublishMappedInputs_WithNoInputs_DoesNotInvokeMapperOrPublishOutputs() + { + var mapperCalls = new List(); + using var node = new DelegateContainerNode(context => + context.PublishMappedInputs( + mapperCalls, + static (current, input, calls) => + { + calls.Add(0); + return current.Opacity(input, 0.5f); + })); + + RenderNodeMeasurement measurement = Measure(node); + + Assert.Multiple(() => + { + Assert.That(mapperCalls, Is.Empty); + Assert.That(measurement.HasFragments, Is.False); + Assert.That(measurement.HasContributingValues, Is.False); + Assert.That(measurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.None)); + Assert.That(measurement.OutputBounds, Is.EqualTo(default(Rect))); + }); + } + + [Test] + public void PublishMappedInputs_RejectsMapperSidePublication() + { + using var node = new DelegateContainerNode(context => + context.PublishMappedInputs(input => + { + context.Publish(input); + return context.Opacity(input, 0.5f); + })); + node.AddChild(SourceNode(new Rect(0, 0, 10, 10))); + + InvalidOperationException? exception = Assert.Throws(() => Measure(node)); + + Assert.That(exception!.Message, Does.Contain("must return its output without publishing fragments")); + } + + [Test] + public void OpaqueShapes_ExposeTheirApplicableCardinalityContributionAndValueEligibility() + { + var firstBounds = new Rect(0, 0, 10, 10); + var secondBounds = new Rect(20, 5, 8, 12); + var observed = new Dictionary(); + + using var node = new DelegateNode(context => + { + RenderFragmentHandle first = context.OpaqueSource(SourceDescription( + firstBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector)); + RenderFragmentHandle second = context.OpaqueSource(SourceDescription( + secondBounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 2f))); + RenderFragmentHandle map = context.OpaqueMap( + first, + MapDescription(RenderValueCardinality.ZeroOrOne)); + RenderFragmentHandle combine = context.OpaqueCombine( + [first, second], + CombineDescription(RenderValueCardinality.Single)); + RenderFragmentHandle expand = context.OpaqueExpand( + [first, second], + CombineDescription(RenderValueCardinality.Dynamic)); + RenderFragmentHandle emptyCombine = context.OpaqueCombine( + [], + EmptyInputDescription(RenderValueCardinality.ZeroOrOne)); + RenderFragmentHandle emptyExpand = context.OpaqueExpand( + [], + EmptyInputDescription(RenderValueCardinality.Dynamic)); + + observed["source"] = FragmentSnapshot.From(first); + observed["map"] = FragmentSnapshot.From(map); + observed["combine"] = FragmentSnapshot.From(combine); + observed["expand"] = FragmentSnapshot.From(expand); + observed["empty-combine"] = FragmentSnapshot.From(emptyCombine); + observed["empty-expand"] = FragmentSnapshot.From(emptyExpand); + + RenderFragmentHandle contributed = context.ContributeValues(emptyCombine); + observed["contributed"] = FragmentSnapshot.From(contributed); + Assert.That(context.ContributeValues(contributed), Is.SameAs(contributed)); + + context.PublishRange([map, combine, expand, contributed]); + }); + + RenderNodeMeasurement measurement = Measure(node, outputScale: 1, maxWorkingScale: 4); + + Assert.Multiple(() => + { + Assert.That(observed["source"].Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(observed["source"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["source"].ContributesValues, Is.True); + Assert.That(observed["source"].EffectiveScale, Is.EqualTo(EffectiveScale.Unbounded)); + + Assert.That(observed["map"].Cardinality, Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + Assert.That(observed["map"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["map"].ContributesValues, Is.True); + + Assert.That(observed["combine"].Bounds, Is.EqualTo(firstBounds.Union(secondBounds))); + Assert.That(observed["combine"].Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(observed["combine"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["combine"].ContributesValues, Is.True); + + Assert.That(observed["expand"].Cardinality, Is.EqualTo(RenderValueCardinality.Dynamic)); + Assert.That(observed["expand"].CanBeUsedAsValueInput, Is.True); + + Assert.That(observed["empty-combine"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["empty-combine"].ContributesValues, Is.False); + Assert.That(observed["empty-expand"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["empty-expand"].ContributesValues, Is.False); + Assert.That(observed["contributed"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["contributed"].ContributesValues, Is.True); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + }); + } + + [Test] + public void TypedValueAndTargetWrappers_FollowThePublishedEligibilityTable() + { + var bounds = new Rect(4, 6, 20, 10); + var observed = new Dictionary(); + var fallbackBrush = new FallbackBrush(); + using Brush.Resource fallbackMask = + (Brush.Resource)fallbackBrush.ToResource(CompositionContext.Default); + + using var node = new DelegateNode(context => + { + RenderResource whiteMask = context.Borrow((Brush.Resource)Brushes.Resource.White); + RenderResource fallbackMaskToken = context.Borrow(fallbackMask); + RenderFragmentHandle source = context.OpaqueSource(SourceDescription(bounds)); + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => throw new AssertionException("Metadata queries must not execute target commands."), + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.OutputBounds)); + + RenderFragmentHandle opacityValue = context.Opacity(source, 0.5f); + RenderFragmentHandle opacityCommand = context.Opacity(command, 0.5f); + RenderFragmentHandle maskValue = context.OpacityMask( + source, + whiteMask, + bounds); + RenderFragmentHandle maskCommand = context.OpacityMask( + command, + whiteMask, + bounds); + RenderFragmentHandle maskFallback = context.OpacityMask( + source, + fallbackMaskToken, + bounds); + RenderFragmentHandle blend = context.Blend(source, BlendMode.SrcOver); + RenderFragmentHandle targetScope = context.TargetScope( + source, + RenderDefinitionCallFactory.TargetScope( + static _ => throw new AssertionException("Metadata queries must not execute target scopes."), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply)); + RenderFragmentHandle targetLayer = context.TargetLayerScope( + [source, command], + TargetRegion.Region(bounds)); + RenderFragmentHandle layer = context.Layer([source, command], bounds); + + observed["opacity-value"] = FragmentSnapshot.From(opacityValue); + observed["opacity-command"] = FragmentSnapshot.From(opacityCommand); + observed["mask-value"] = FragmentSnapshot.From(maskValue); + observed["mask-command"] = FragmentSnapshot.From(maskCommand); + observed["mask-fallback"] = FragmentSnapshot.From(maskFallback); + observed["blend"] = FragmentSnapshot.From(blend); + observed["target-scope"] = FragmentSnapshot.From(targetScope); + observed["command"] = FragmentSnapshot.From(command); + observed["target-layer"] = FragmentSnapshot.From(targetLayer); + observed["layer"] = FragmentSnapshot.From(layer); + + Assert.That( + () => context.OpaqueMap(command, MapDescription(RenderValueCardinality.Single)), + Throws.TypeOf()); + Assert.That(() => context.ContributeValues(command), Throws.TypeOf()); + + context.Drop(targetLayer); + context.PublishRange( + [opacityValue, maskValue, maskFallback, blend, targetScope, layer]); + }); + + _ = Measure(node, targetDomain: bounds); + + Assert.Multiple(() => + { + Assert.That(observed["opacity-value"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["opacity-command"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["mask-value"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["mask-command"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["mask-fallback"].CanBeUsedAsValueInput, Is.True); + Assert.That(observed["blend"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["target-scope"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["command"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["target-layer"].CanBeUsedAsValueInput, Is.False); + Assert.That(observed["layer"].CanBeUsedAsValueInput, Is.True); + + Assert.That(observed["opacity-value"].Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(observed["blend"].Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(observed["command"].Cardinality, Is.EqualTo(RenderValueCardinality.None)); + Assert.That(observed["target-layer"].Cardinality, Is.EqualTo(RenderValueCardinality.Exactly(1))); + Assert.That(observed["layer"].Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + }); + } + + [Test] + public void MaterializedInputAndResourceTokens_AreUsableWithoutFriendAccessAndHonorOwnership() + { + var bounds = new Rect(10, 20, 10, 20); + using RenderTarget target = RenderTarget.CreateNull(20, 40); + var owned = new TrackingDisposable(); + var borrowed = new TrackingDisposable(); + FragmentSnapshot materialized = default; + + using var node = new DelegateNode(context => + { + RenderResource ownedToken = context.Own(owned); + RenderResource borrowedToken = context.Borrow(borrowed); + RenderResource targetToken = context.Borrow(target); + + RenderFragmentHandle input = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + targetToken, + bounds, + EffectiveScale.At(2), + PixelRect.FromRect(bounds, 2), + default, + RenderHitTestContract.OutputBounds)); + materialized = FragmentSnapshot.From(input); + + RenderFragmentHandle declaredResourceSource = context.OpaqueSource( + RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Measure must not execute opaque callbacks."), + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 1, 1)), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.Vector, + resources: + [ + s_ownedSlot, + s_borrowedSlot, + ], + bindings: + [ + s_ownedSlot.Bind(ownedToken), + s_borrowedSlot.Bind(borrowedToken), + ])); + context.PublishRange([input, declaredResourceSource]); + }); + + RenderNodeMeasurement measurement = Measure(node); + + Assert.Multiple(() => + { + Assert.That(materialized.Bounds, Is.EqualTo(bounds)); + Assert.That(materialized.EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(materialized.Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(materialized.CanBeUsedAsValueInput, Is.True); + Assert.That(materialized.ContributesValues, Is.True); + Assert.That(materialized.HitAtCenter, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(bounds.Union(new Rect(0, 0, 1, 1)))); + + Assert.That(owned.DisposeCalls, Is.EqualTo(1)); + Assert.That(borrowed.DisposeCalls, Is.Zero); + Assert.That(target.IsDisposed, Is.False); + }); + } + + [Test] + public void Publication_AllowsPureFanOut_ButRejectsEffectfulFanOut() + { + var bounds = new Rect(1, 2, 8, 6); + using var pureNode = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceDescription(bounds)); + context.Publish(source); + context.Publish(source); + }); + + RenderNodeMeasurement pureMeasurement = Measure(pureNode); + Assert.Multiple(() => + { + Assert.That(pureMeasurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Exactly(2))); + Assert.That(pureMeasurement.OutputBounds, Is.EqualTo(bounds)); + }); + + using var effectfulNode = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => { }, + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None)); + context.Publish(command); + context.Publish(command); + }); + + Assert.That( + () => Measure(effectfulNode, targetDomain: bounds), + Throws.TypeOf()); + + using var indirectWrapperNode = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => { }, + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None)); + context.PublishRange([ + context.Opacity(command, 0.5f), + context.Opacity(command, 0.75f), + ]); + }); + using var duplicateLayerInputNode = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => { }, + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None)); + context.Publish(context.Layer([command, command], bounds)); + }); + + Assert.Multiple(() => + { + Assert.That( + () => Measure(indirectWrapperNode, targetDomain: bounds), + Throws.TypeOf()); + Assert.That( + () => Measure(duplicateLayerInputNode, targetDomain: bounds), + Throws.TypeOf()); + }); + } + + [Test] + public void CustomScaleAndRenderScaleUtilities_PreserveFeatureThreeDensityRules() + { + var bounds = new Rect(0, 0, 100, 80); + EffectiveScale observedScale = default; + + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceDescription( + bounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static scaleContext => + { + Assert.Multiple(() => + { + Assert.That(scaleContext.InputSupplies, Is.Empty); + Assert.That(scaleContext.OutputBounds, Is.EqualTo(new Rect(0, 0, 100, 80))); + Assert.That(scaleContext.OutputScale, Is.EqualTo(1.5f)); + Assert.That(scaleContext.MaxWorkingScale, Is.EqualTo(4)); + }); + return 6; + }))); + Assert.That(source.TryGetMetadata(out RenderFragmentMetadata metadata), Is.True); + observedScale = metadata.EffectiveScale; + context.Publish(source); + }); + + _ = Measure(node, outputScale: 1.5f, maxWorkingScale: 4); + + EffectiveScale[] inputs = [EffectiveScale.Unbounded, EffectiveScale.At(2), EffectiveScale.At(3)]; + float clamped = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + new Rect(0, 0, 20_000, 10), + 2); + + Assert.Multiple(() => + { + Assert.That(observedScale, Is.EqualTo(EffectiveScale.At(4))); + + Assert.That(RenderScaleUtilities.SanitizeMaxWorkingScale(float.NaN), Is.EqualTo(float.PositiveInfinity)); + Assert.That(RenderScaleUtilities.SanitizeMaxWorkingScale(0), Is.EqualTo(float.PositiveInfinity)); + Assert.That(RenderScaleUtilities.SanitizeMaxWorkingScale(3), Is.EqualTo(3)); + Assert.That( + RenderScaleUtilities.ResolveWorkingScale(inputs, outputScale: 1.5f, maxWorkingScale: 2.5f), + Is.EqualTo(2.5f)); + Assert.That(clamped, Is.GreaterThan(0).And.LessThan(1)); + Assert.That(Math.Ceiling(20_000d * clamped), Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + + using var invalidNode = new DelegateNode(context => + { + _ = context.OpaqueSource(SourceDescription( + bounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => float.NaN))); + }); + Assert.That(() => Measure(invalidNode), Throws.TypeOf()); + } + + [Test] + public void NestedRecording_ReturnsFreshValueEligibleFacadesWithPreservedMetadata() + { + var bounds = new Rect(7, 9, 11, 13); + using var child = SourceNode(bounds); + FragmentSnapshot nested = default; + + using var root = new DelegateNode(context => + { + IReadOnlyList outputs = context.RecordNode(child, []); + Assert.That(outputs, Has.Count.EqualTo(1)); + nested = FragmentSnapshot.From(outputs[0]); + context.PublishRange(outputs); + }); + + RenderNodeMeasurement measurement = Measure(root); + + Assert.Multiple(() => + { + Assert.That(nested.Bounds, Is.EqualTo(bounds)); + Assert.That(nested.Cardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(nested.CanBeUsedAsValueInput, Is.True); + Assert.That(nested.ContributesValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(bounds)); + }); + } + + [Test] + public void NestedRecording_SymbolicMetadataRemainsUnavailableUntilFiniteLayerResolvesIt() + { + var sourceBounds = new Rect(7, 9, 11, 13); + var layerDomain = new Rect(2, 3, 40, 30); + var effect = new UnknownBoundsPluginEffect(); + using FilterEffect.Resource effectResource = effect.ToResource(CompositionContext.Default); + using FilterEffectRenderNode filterNode = effectResource.CreateRenderNode(); + filterNode.AddChild(SourceNode(sourceBounds)); + using var backdropNode = new SnapshotBackdropRenderNode(); + + using var root = new DelegateNode(context => + { + RenderFragmentHandle symbolicFilter = context.RecordSubtree(filterNode).Single(); + RenderFragmentHandle symbolicBackdrop = context.RecordNode(backdropNode, []).Single(); + RenderFragmentHandle symbolicDescendant = context.Opacity(symbolicFilter, 0.5f); + + Assert.Multiple(() => + { + Assert.That( + symbolicFilter.TryGetMetadata(out RenderFragmentMetadata filterMetadata), + Is.False); + Assert.That(filterMetadata, Is.EqualTo(default(RenderFragmentMetadata))); + Assert.That(symbolicFilter.TryHitTest(layerDomain.Center, out bool filterHit), Is.False); + Assert.That(filterHit, Is.False); + Assert.That(symbolicFilter.ValueCardinality, Is.EqualTo(RenderValueCardinality.Dynamic)); + Assert.That(symbolicFilter.ContributesValuesToTarget, Is.True); + Assert.That(symbolicFilter.CanBeUsedAsValueInput, Is.True); + + Assert.That( + symbolicBackdrop.TryGetMetadata(out RenderFragmentMetadata backdropMetadata), + Is.False); + Assert.That(backdropMetadata, Is.EqualTo(default(RenderFragmentMetadata))); + Assert.That(symbolicBackdrop.TryHitTest(layerDomain.Center, out bool backdropHit), Is.False); + Assert.That(backdropHit, Is.False); + Assert.That(symbolicBackdrop.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(symbolicBackdrop.ContributesValuesToTarget, Is.False); + Assert.That(symbolicBackdrop.CanBeUsedAsValueInput, Is.True); + + Assert.That( + symbolicDescendant.TryGetMetadata(out RenderFragmentMetadata descendantMetadata), + Is.False); + Assert.That(descendantMetadata, Is.EqualTo(default(RenderFragmentMetadata))); + Assert.That(symbolicDescendant.TryHitTest(layerDomain.Center, out bool descendantHit), Is.False); + Assert.That(descendantHit, Is.False); + Assert.That(symbolicDescendant.ValueCardinality, Is.EqualTo(RenderValueCardinality.Dynamic)); + Assert.That(symbolicDescendant.ContributesValuesToTarget, Is.True); + Assert.That(symbolicDescendant.CanBeUsedAsValueInput, Is.True); + }); + + RenderFragmentHandle layer = context.Layer([symbolicDescendant], layerDomain); + Assert.Multiple(() => + { + Assert.That(layer.TryGetMetadata(out RenderFragmentMetadata layerMetadata), Is.True); + Assert.That(layerMetadata.Bounds, Is.EqualTo(layerDomain)); + Assert.That(layerMetadata.EffectiveScale, Is.EqualTo(EffectiveScale.Unbounded)); + Assert.That(layer.TryHitTest(layerDomain.Center, out bool layerHit), Is.True); + Assert.That(layerHit, Is.True); + Assert.That(layer.TryHitTest(new Point(-100, -100), out bool outsideHit), Is.True); + Assert.That(outsideHit, Is.False); + Assert.That(layer.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(layer.ContributesValuesToTarget, Is.True); + Assert.That(layer.CanBeUsedAsValueInput, Is.True); + }); + + context.Publish(layer); + }); + + RenderNodeMeasurement measurement = Measure(root, targetDomain: layerDomain); + Assert.That(measurement.OutputBounds, Is.EqualTo(layerDomain)); + } + + [Test] + public void CardinalityFactoriesAndOpaqueTopologyValidation_ArePubliclyEnforced() + { + Assert.Multiple(() => + { + Assert.That(RenderValueCardinality.None, Is.EqualTo(RenderValueCardinality.Exactly(0))); + Assert.That(RenderValueCardinality.Single, Is.EqualTo(RenderValueCardinality.Exactly(1))); + Assert.That(RenderValueCardinality.ZeroOrOne, Is.EqualTo(RenderValueCardinality.Range(0, 1))); + Assert.That(RenderValueCardinality.Dynamic, Is.EqualTo(RenderValueCardinality.Range(0, null))); + Assert.That(() => RenderValueCardinality.Exactly(-1), Throws.TypeOf()); + Assert.That(() => RenderValueCardinality.Range(-1, null), Throws.TypeOf()); + Assert.That(() => RenderValueCardinality.Range(2, 1), Throws.TypeOf()); + Assert.That( + () => RenderDefinitionCallFactory.Opaque( + static _ => { }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 1, 1)), + RenderHitTestContract.None, + default, + RenderScaleContract.Vector), + Throws.TypeOf()); + }); + + using var invalidMap = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceDescription(new Rect(0, 0, 1, 1))); + _ = context.OpaqueMap(source, MapDescription(RenderValueCardinality.Dynamic)); + }); + Assert.That(() => Measure(invalidMap), Throws.TypeOf()); + + using var invalidCombine = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceDescription(new Rect(0, 0, 1, 1))); + _ = context.OpaqueCombine( + [source], + CombineDescription(RenderValueCardinality.Exactly(2))); + }); + Assert.That(() => Measure(invalidCombine), Throws.TypeOf()); + } + + private static DelegateNode SourceNode(Rect bounds) + { + return new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceDescription(bounds)); + context.Publish(source); + }); + } + + private static OpaqueRenderCall> SourceDescription(Rect bounds) + => SourceDescription(bounds, RenderValueCardinality.Single, RenderScaleContract.Vector); + + private static OpaqueRenderCall> SourceDescription( + Rect bounds, + RenderValueCardinality cardinality, + RenderScaleContract scale) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Metadata queries must not execute opaque callbacks."), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + cardinality, + scale); + } + + private static OpaqueRenderCall> MapDescription(RenderValueCardinality cardinality) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Metadata queries must not execute opaque callbacks."), + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + cardinality, + RenderScaleContract.PreserveInputSupply); + } + + private static OpaqueRenderCall> CombineDescription(RenderValueCardinality cardinality) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Metadata queries must not execute opaque callbacks."), + OpaqueRenderBoundsContract.FullInputs(UnionAll), + RenderHitTestContract.AnyInput, + cardinality, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private static OpaqueRenderCall> EmptyInputDescription(RenderValueCardinality cardinality) + { + return RenderDefinitionCallFactory.Opaque( + static _ => throw new AssertionException("Metadata queries must not execute opaque callbacks."), + OpaqueRenderBoundsContract.FullInputs( + static _ => new Rect(40, 50, 3, 2)), + RenderHitTestContract.OutputBounds, + cardinality, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private static Rect UnionAll(IReadOnlyList inputs) + { + Rect result = default; + foreach (Rect input in inputs) + { + result = result.Union(input); + } + + return result; + } + + private static RenderNodeMeasurement Measure( + RenderNode node, + Rect? targetDomain = null, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain, + OutputScale = outputScale, + MaxWorkingScale = maxWorkingScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + return renderer.Measure(); + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class DelegateContainerNode(Action process) : ContainerRenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class TrackingDisposable : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() => DisposeCalls++; + } + + [SuppressResourceClassGeneration] + private sealed partial class UnknownBoundsPluginEffect : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => context.CustomEffect(Unit.Default, static (_, _) => { }); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + private readonly record struct FragmentSnapshot( + Rect Bounds, + EffectiveScale EffectiveScale, + RenderValueCardinality Cardinality, + bool ContributesValues, + bool CanBeUsedAsValueInput, + bool HitAtCenter) + { + public static FragmentSnapshot From(RenderFragmentHandle handle) + { + Assert.That(handle.TryGetMetadata(out RenderFragmentMetadata metadata), Is.True); + Point center = new( + metadata.Bounds.X + metadata.Bounds.Width / 2, + metadata.Bounds.Y + metadata.Bounds.Height / 2); + Assert.That(handle.TryHitTest(center, out bool hitAtCenter), Is.True); + return new FragmentSnapshot( + metadata.Bounds, + metadata.EffectiveScale, + handle.ValueCardinality, + handle.ContributesValuesToTarget, + handle.CanBeUsedAsValueInput, + hitAtCenter); + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs b/tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs new file mode 100644 index 0000000000..4e16a78c3c --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs @@ -0,0 +1,985 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class RenderNodeRendererContractTests +{ + [Test] + public void RenderNodeCache_IsNotExposedToExternalAuthors() + { + Assert.Multiple(() => + { + Assert.That(typeof(RenderNode).GetProperty("Cache"), Is.Null); + Assert.That( + typeof(RenderNode).Assembly.GetExportedTypes() + .Any(static type => type.FullName == "Beutl.Graphics.Rendering.Cache.RenderNodeCache"), + Is.False); + }); + } + + [TestCase(float.NaN, 1f)] + [TestCase(0f, 1f)] + [TestCase(-2f, 1f)] + [TestCase(float.PositiveInfinity, 1f)] + [TestCase(2.5f, 2.5f)] + public void Options_SnapshotAndSanitizeOutputScale(float authored, float expected) + { + using var root = new DelegateNode(static _ => { }); + var supplied = new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + OutputScale = authored, + MaxWorkingScale = 3, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + }; + using var renderer = new RenderNodeRenderer(root, supplied); + + Assert.Multiple(() => + { + Assert.That(renderer.Root, Is.SameAs(root)); + Assert.That(renderer.Options, Is.Not.SameAs(supplied)); + Assert.That(renderer.Options.DefaultRequest.Intent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That(renderer.Options.DefaultRequest.OutputScale, Is.EqualTo(expected)); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(3)); + Assert.That(renderer.Options.DefaultRequest.CacheOptions, Is.EqualTo(RenderCacheOptions.Disabled)); + Assert.That(renderer.Options.DefaultRequest.Purpose, Is.EqualTo(RenderRequestPurpose.Frame)); + }); + } + + [Test] + public void Options_SanitizeMaxWorkingScaleAndRejectInvalidRectangles() + { + using var root = new DelegateNode(static _ => { }); + foreach (float invalid in new[] { float.NaN, 0, -1, float.NegativeInfinity }) + { + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + MaxWorkingScale = invalid, + }, + }); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + } + + using (var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + MaxWorkingScale = float.PositiveInfinity, + }, + })) + { + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + } + + Assert.Multiple(() => + { + Assert.That( + () => new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = Rect.Empty, + }, + }), + Throws.TypeOf()); + Assert.That( + () => new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(float.NaN, 0, 1, 1), + }, + }), + Throws.TypeOf()); + Assert.That( + () => new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = new Rect(0, 0, float.PositiveInfinity, 1), + }, + }), + Throws.TypeOf()); + Assert.That( + () => new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = (RenderIntent)12345, + }, + }), + Throws.TypeOf()); + Assert.That( + () => new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Purpose = (RenderRequestPurpose)12345, + }, + }), + Throws.TypeOf()); + }); + } + + [Test] + public void Rasterize_PropagatesThePublicRequestPurpose() + { + var bounds = new Rect(0, 0, 4, 3); + RenderRequestPurpose observedPurpose = default; + using var root = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource( + bounds, + session => observedPurpose = session.Purpose, + "public-purpose-source")); + context.Publish(source); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest { CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize( + renderer.Options.DefaultRequest with { Purpose = RenderRequestPurpose.Frame }); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(observedPurpose, Is.EqualTo(RenderRequestPurpose.Frame)); + Assert.That(renderer.Options.DefaultRequest.Purpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + }); + } + + [TestCase(RenderRequestPurpose.Bounds)] + [TestCase(RenderRequestPurpose.HitTest)] + public void Rasterize_RejectsMetadataOnlyRequestPurposes(RenderRequestPurpose purpose) + { + using var root = new DelegateNode(static _ => { }); + using var renderer = new RenderNodeRenderer(root); + + Assert.That( + () => renderer.Rasterize(new RenderNodeRenderRequest { Purpose = purpose }), + Throws.TypeOf() + .With.Property("ParamName").EqualTo("purpose")); + } + + [Test] + public void Operations_AcceptCompletePerCallRequestsOnOnePersistentRenderer() + { + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + using DelegateNode root = SourceNode(new Rect(0, 0, 8, 6)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest { CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled }, + TargetFactory = factory, + }); + RenderNodeRenderRequest leftRequest = renderer.Options.DefaultRequest with + { + RequestedRegion = new Rect(0, 0, 4, 6), + OutputScale = 1, + }; + RenderNodeRenderRequest rightRequest = renderer.Options.DefaultRequest with + { + RequestedRegion = new Rect(4, 0, 4, 6), + OutputScale = 2, + }; + + using RenderNodeRasterization left = renderer.Rasterize(leftRequest); + using RenderNodeRasterization right = renderer.Rasterize(rightRequest); + + Assert.Multiple(() => + { + Assert.That(left.Bounds, Is.EqualTo(new Rect(0, 0, 4, 6))); + Assert.That(left.OutputScale, Is.EqualTo(1)); + Assert.That(left.Bitmap, Is.Not.Null); + Assert.That((left.Bitmap!.Width, left.Bitmap.Height), Is.EqualTo((4, 6))); + Assert.That(right.Bounds, Is.EqualTo(new Rect(4, 0, 4, 6))); + Assert.That(right.OutputScale, Is.EqualTo(2)); + Assert.That(right.Bitmap, Is.Not.Null); + Assert.That((right.Bitmap!.Width, right.Bitmap.Height), Is.EqualTo((8, 12))); + Assert.That(renderer.Options.DefaultRequest.RequestedRegion, Is.Null); + Assert.That(renderer.Options.DefaultRequest.OutputScale, Is.EqualTo(1)); + Assert.That(renderer.IsDisposed, Is.False); + }); + } + + [Test] + public void MeasureHitTestAndRender_UseMetadataOrDestinationStateAsRequired() + { + var bounds = new Rect(3, 4, 8, 6); + var requested = new Rect(4, 5, 3, 2); + int executions = 0; + float executionOutputScale = 0; + float executionMaxWorkingScale = 0; + RenderRequestPurpose executionPurpose = default; + RenderIntent executionIntent = default; + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + + using var root = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource( + ExecutingSource( + bounds, + session => + { + executions++; + executionOutputScale = session.OutputScale; + executionMaxWorkingScale = session.MaxWorkingScale; + executionPurpose = session.Purpose; + executionIntent = session.Intent; + }, + "render-state-source")); + context.Publish(source); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + RequestedRegion = requested, + OutputScale = 8, + MaxWorkingScale = 3, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + bool hitInside = renderer.HitTest(new Point(5, 6)); + bool hitOutsideRequested = renderer.HitTest(new Point(3.5f, 4.5f)); + + Assert.Multiple(() => + { + Assert.That(executions, Is.Zero, "Measure and HitTest are metadata-only requests."); + Assert.That(measurement.OutputBounds, Is.EqualTo(bounds)); + Assert.That(measurement.QueryBounds, Is.EqualTo(bounds)); + Assert.That(measurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.HasTargetEffects, Is.False); + Assert.That(hitInside, Is.True); + Assert.That(hitOutsideRequested, Is.False); + }); + + using var destinationTarget = new TrackingRenderTarget(new PixelSize(40, 30)); + using var destination = new ImmediateCanvas( + destinationTarget, + density: 2, + maxWorkingScale: 1.5f, + logicalSize: new Size(20, 15)); + destination.Opacity = 0.4f; + destination.BlendMode = BlendMode.Multiply; + using (destination.PushTransform(Matrix.CreateTranslation(2, 1))) + using (destination.PushClip(new Rect(0, 0, 12, 10))) + { + Matrix transform = destination.Transform; + renderer.Render(destination); + + Assert.Multiple(() => + { + Assert.That(destination.Transform, Is.EqualTo(transform)); + Assert.That(destination.Opacity, Is.EqualTo(0.4f)); + Assert.That(destination.BlendMode, Is.EqualTo(BlendMode.Multiply)); + }); + } + + Assert.Multiple(() => + { + Assert.That(executions, Is.EqualTo(1)); + Assert.That(executionOutputScale, Is.EqualTo(2), "Render uses the destination density, not Options.OutputScale."); + Assert.That(executionMaxWorkingScale, Is.EqualTo(1.5f)); + Assert.That(executionPurpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + Assert.That(executionIntent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That(destination.IsDisposed, Is.False); + Assert.That(destinationTarget.IsDisposed, Is.False); + Assert.That(factory.Allocations, Is.Not.Empty); + Assert.That(factory.Allocations, Has.All.Matches(allocation => + allocation.PixelFormat == RenderTargetPixelFormat.LinearPremultipliedRgba16Float + && allocation.GraphicsContext is null + && allocation.GraphicsContextHandle == 0 + && allocation.GraphicsBackend is null)); + }); + } + + [Test] + public void Render_InverseMapsTranslatedDestinationViewportAndIgnoresOptionTargetDomain() + { + AssertRenderedTargetDomain( + Matrix.CreateTranslation(10, 5), + new Rect(-10, -5, 40, 30)); + } + + [Test] + public void Render_InverseMapsScaledDestinationViewportAndIgnoresOptionTargetDomain() + { + AssertRenderedTargetDomain( + Matrix.CreateScale(2, 3), + new Rect(0, 0, 20, 10)); + } + + [Test] + public void Render_ConservativelyInverseMapsRotatedDestinationViewportAndIgnoresOptionTargetDomain() + { + AssertRenderedTargetDomain( + Matrix.CreateRotation(MathF.PI / 2), + new Rect(0, -40, 30, 40)); + } + + [Test] + public void Render_InverseMapsTheLogicalViewportAtTheActiveDestinationDensity() + { + AssertRenderedTargetDomain( + Matrix.CreateTranslation(10, 5), + new Rect(-10, -5, 35, 25), + new PixelSize(80, 60), + density: 2, + logicalSize: new Size(35, 25)); + } + + [Test] + public void Render_SingularDestinationTransformIsASuccessfulNoOp() + { + var bounds = new Rect(0, 0, 10, 10); + int recordings = 0; + int executions = 0; + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + using var root = new DelegateNode(context => + { + recordings++; + RenderFragmentHandle source = context.OpaqueSource( + ExecutingSource(bounds, _ => executions++, "singular-transform-source")); + context.Publish(source); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + using var target = new TrackingRenderTarget(new PixelSize(20, 20)); + using var destination = new ImmediateCanvas(target); + + using (destination.PushTransform(Matrix.CreateScale(0, 1))) + { + Matrix transform = destination.Transform; + Assert.That(() => renderer.Render(destination), Throws.Nothing); + Assert.That(destination.Transform, Is.EqualTo(transform)); + } + + Assert.Multiple(() => + { + Assert.That(recordings, Is.EqualTo(1)); + Assert.That(executions, Is.Zero); + Assert.That(factory.Requests, Is.Empty); + Assert.That(destination.IsDisposed, Is.False); + Assert.That(target.IsDisposed, Is.False); + }); + } + + [Test] + public void Render_SingularDestinationTransformRejectsAFullTargetAccess() + { + int executions = 0; + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + using var root = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + _ => executions++, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None)); + context.Publish(command); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + using var target = new TrackingRenderTarget(new PixelSize(20, 20)); + using var destination = new ImmediateCanvas(target); + + using (destination.PushTransform(Matrix.CreateScale(0, 1))) + { + Assert.That( + () => renderer.Render(destination), + Throws.TypeOf() + .With.Message.Contains("requires a finite owning target domain")); + } + + Assert.Multiple(() => + { + Assert.That(executions, Is.Zero); + Assert.That(factory.Requests, Is.Empty); + Assert.That(destination.IsDisposed, Is.False); + Assert.That(target.IsDisposed, Is.False); + }); + } + + [Test] + public void Render_SingularDestinationTransformPreservesAnEmptyTargetCommand() + { + int executions = 0; + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + using var root = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + _ => executions++, + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None)); + context.Publish(command); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + using var target = new TrackingRenderTarget(new PixelSize(20, 20)); + using var destination = new ImmediateCanvas(target); + + using (destination.PushTransform(Matrix.CreateScale(0, 1))) + { + Assert.That(() => renderer.Render(destination), Throws.Nothing); + } + + Assert.Multiple(() => + { + Assert.That(executions, Is.EqualTo(1)); + Assert.That(factory.Requests, Is.Empty); + Assert.That(destination.IsDisposed, Is.False); + Assert.That(target.IsDisposed, Is.False); + }); + } + + [TestCase(0, 8, 30, 44)] + [TestCase(8, 0, 34, 40)] + [TestCase(0, 0, 30, 40)] + public void HitTest_DegenerateRequestedRegionHasNoHits( + float width, + float height, + float pointX, + float pointY) + { + var bounds = new Rect(0, 0, 100, 100); + using var root = SourceNode(bounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = new Rect(30, 40, width, height), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + Assert.That(renderer.HitTest(new Point(pointX, pointY)), Is.False); + } + + [Test] + public void CommandAndCaptureMeasurements_KeepValueContributionQueryAndTargetEffectsIndependent() + { + var domain = new Rect(10, 20, 50, 30); + var query = new Rect(20, 24, 7, 5); + + using var commandNode = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + static _ => throw new AssertionException("Measure must not execute commands."), + TargetRegion.Full, + query, + RenderHitTestContract.OutputBounds)); + context.Publish(command); + }); + RenderNodeMeasurement command = Measure(commandNode, targetDomain: domain); + + using var captureNode = new DelegateNode(context => + { + RenderFragmentHandle capture = context.TargetCapture( + TargetCaptureDescription.Create( + TargetRegion.Full, + query, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + context.Publish(capture); + }); + RenderNodeMeasurement capture = Measure(captureNode, targetDomain: domain); + + Assert.Multiple(() => + { + Assert.That(command.OutputBounds, Is.EqualTo(domain)); + Assert.That(command.QueryBounds, Is.EqualTo(query)); + Assert.That(command.ValueCardinality, Is.EqualTo(RenderValueCardinality.None)); + Assert.That(command.HasFragments, Is.True); + Assert.That(command.HasContributingValues, Is.False); + Assert.That(command.HasTargetEffects, Is.True); + + Assert.That(capture.OutputBounds, Is.EqualTo(default(Rect))); + Assert.That(capture.QueryBounds, Is.EqualTo(default(Rect))); + Assert.That(capture.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(capture.HasFragments, Is.True); + Assert.That(capture.HasContributingValues, Is.False); + Assert.That(capture.HasTargetEffects, Is.True); + }); + } + + [Test] + public void Rasterize_ReportsTheDeviceCoverOfShiftedBoundsAndTransfersBitmapOwnershipToTheResult() + { + var bounds = new Rect(10.25f, 20.25f, 3.5f, 2.5f); + PixelRect expectedDeviceBounds = PixelRect.FromRect(bounds, 2); + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + + using var root = SourceNode(bounds); + var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(expectedDeviceBounds.ToRect(2))); + Assert.That(rasterization.Bounds.Contains(bounds), Is.True); + Assert.That(rasterization.OutputScale, Is.EqualTo(2)); + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(bitmap.Width, Is.EqualTo(expectedDeviceBounds.Width)); + Assert.That(bitmap.Height, Is.EqualTo(expectedDeviceBounds.Height)); + Assert.That(factory.Requests, Does.Contain(expectedDeviceBounds.Size)); + Assert.That(factory.Allocations, Has.All.Matches(allocation => + allocation.PixelFormat == RenderTargetPixelFormat.LinearPremultipliedRgba16Float + && allocation.GraphicsContext is null + && allocation.GraphicsContextHandle is null or 0 + && allocation.GraphicsBackend is null)); + }); + + renderer.Dispose(); + Assert.That(bitmap.IsDisposed, Is.False, "Renderer disposal does not dispose an already returned rasterization."); + Assert.That(factory.Targets, Is.Not.Empty); + Assert.That(factory.Targets, Has.All.Matches(target => target.IsDisposed)); + + rasterization.Dispose(); + rasterization.Dispose(); + Assert.That(bitmap.IsDisposed, Is.True); + } + + [Test] + public void Rasterize_ReturnsNormalEmptyResultsWithoutAllocatingOrExecuting() + { + var factory = new TrackingTargetFactory(static size => new TrackingRenderTarget(size)); + int executions = 0; + + using var emptyRoot = new DelegateNode(static _ => { }); + using (var renderer = new RenderNodeRenderer( + emptyRoot, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + }, + TargetFactory = factory, + })) + using (RenderNodeRasterization result = renderer.Rasterize()) + { + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.True); + Assert.That(result.Bounds, Is.EqualTo(default(Rect))); + Assert.That(result.Bitmap, Is.Null); + }); + } + + var authoredBounds = new Rect(0, 0, 10, 10); + var emptySelection = new Rect(30, 40, 0, 8); + using var sourceRoot = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource( + ExecutingSource(authoredBounds, _ => executions++, "empty-selection-source")); + context.Publish(source); + }); + using (var renderer = new RenderNodeRenderer( + sourceRoot, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = emptySelection, + }, + TargetFactory = factory, + })) + using (RenderNodeRasterization result = renderer.Rasterize()) + { + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.True); + Assert.That(result.Bounds, Is.EqualTo(emptySelection)); + Assert.That(result.Bitmap, Is.Null); + }); + } + + Assert.Multiple(() => + { + Assert.That(executions, Is.Zero); + Assert.That(factory.Requests, Is.Empty); + }); + } + + [Test] + public void TargetFactory_InvalidReturnIsOwnedDisposedAndRejected() + { + var bounds = new Rect(0, 0, 4, 3); + TrackingRenderTarget? invalid = null; + var factory = new TrackingTargetFactory(size => + { + invalid = new TrackingRenderTarget(new PixelSize(size.Width + 1, size.Height)); + return invalid; + }); + + using var root = SourceNode(bounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + Assert.That(() => renderer.Rasterize(), Throws.TypeOf()); + Assert.Multiple(() => + { + Assert.That(invalid, Is.Not.Null); + Assert.That(invalid!.IsDisposed, Is.True); + Assert.That(invalid.DisposeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void TargetFactory_ReusedLiveTargetIsRejectedAndDisposedWithRendererExactlyOnce() + { + var bounds = new Rect(0, 0, 4, 3); + var shared = new TrackingRenderTarget(new PixelSize(4, 3)); + var factory = new TrackingTargetFactory(_ => shared); + + using var root = SourceNode(bounds); + var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + Assert.That(() => renderer.Rasterize(), Throws.TypeOf()); + Assert.That(shared.IsDisposed, Is.False, + "The accepted first allocation remains owned by the renderer pool after request failure."); + renderer.Dispose(); + Assert.Multiple(() => + { + Assert.That(shared.IsDisposed, Is.True); + Assert.That(shared.DisposeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void TargetFactory_BorrowedDestinationAliasIsRejectedWithoutDisposingDestination() + { + var bounds = new Rect(0, 0, 4, 3); + using var destinationTarget = new TrackingRenderTarget(new PixelSize(4, 3)); + using var destination = new ImmediateCanvas(destinationTarget); + var factory = new TrackingTargetFactory(_ => destinationTarget); + + using var root = SourceNode(bounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + Assert.That(() => renderer.Render(destination), Throws.TypeOf()); + Assert.Multiple(() => + { + Assert.That(destinationTarget.IsDisposed, Is.False); + Assert.That(destinationTarget.DisposeCalls, Is.Zero); + }); + } + + [Test] + public void TargetFactory_IncompatibleSurfaceFormatIsOwnedDisposedAndRejected() + { + var bounds = new Rect(0, 0, 4, 3); + TrackingRenderTarget? incompatible = null; + var factory = new TrackingTargetFactory(size => + { + incompatible = new TrackingRenderTarget(size, SKColorType.Rgba8888); + return incompatible; + }); + + using var root = SourceNode(bounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + Assert.That(() => renderer.Rasterize(), Throws.TypeOf()); + Assert.Multiple(() => + { + Assert.That(incompatible, Is.Not.Null); + Assert.That(incompatible!.IsDisposed, Is.True); + Assert.That(incompatible.DisposeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void Dispose_IsIdempotentRejectsLaterCallsAndDoesNotDisposeRootOrDestination() + { + using var root = new DelegateNode(static _ => { }); + var renderer = new RenderNodeRenderer(root); + using var target = new TrackingRenderTarget(new PixelSize(2, 2)); + using var destination = new ImmediateCanvas(target); + + renderer.Dispose(); + renderer.Dispose(); + + Assert.Multiple(() => + { + Assert.That(renderer.IsDisposed, Is.True); + Assert.That(root.IsDisposed, Is.False); + Assert.That(destination.IsDisposed, Is.False); + Assert.That(target.IsDisposed, Is.False); + Assert.That(() => renderer.Measure(), Throws.TypeOf()); + Assert.That(() => renderer.HitTest(default), Throws.TypeOf()); + Assert.That(() => renderer.Rasterize(), Throws.TypeOf()); + Assert.That(() => renderer.Render(destination), Throws.TypeOf()); + }); + } + + private static DelegateNode SourceNode(Rect bounds) + { + return new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(ExecutingSource(bounds, null, ("source", bounds))); + context.Publish(source); + }); + } + + private static void AssertRenderedTargetDomain( + Matrix transform, + Rect expected, + PixelSize deviceSize = default, + float density = 1, + Size logicalSize = default) + { + if (deviceSize == default) + deviceSize = new PixelSize(40, 30); + if (logicalSize.IsDefault) + logicalSize = new Size(40, 30); + + Rect? observed = null; + using var root = new DelegateNode(context => + { + RenderFragmentHandle command = context.TargetCommand( + [], + RenderDefinitionCallFactory.TargetCommand( + session => observed = session.AffectedBounds, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None)); + context.Publish(command); + }); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(100, 200, 10, 20), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using var target = new TrackingRenderTarget(deviceSize); + using var destination = new ImmediateCanvas(target, density, logicalSize: logicalSize); + + using (destination.PushTransform(transform)) + renderer.Render(destination); + + Assert.That(observed, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(observed!.Value.X, Is.EqualTo(expected.X).Within(0.0001f)); + Assert.That(observed.Value.Y, Is.EqualTo(expected.Y).Within(0.0001f)); + Assert.That(observed.Value.Width, Is.EqualTo(expected.Width).Within(0.0001f)); + Assert.That(observed.Value.Height, Is.EqualTo(expected.Height).Within(0.0001f)); + }); + } + + private static OpaqueRenderCall> ExecutingSource( + Rect bounds, + Action? observe, + object _) + { + return RenderDefinitionCallFactory.Opaque( + session => + { + observe?.Invoke(session); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private static RenderNodeMeasurement Measure(RenderNode node, Rect? targetDomain = null) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + return renderer.Measure(); + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class TrackingTargetFactory(Func create) : IRenderTargetFactory + { + public List Requests { get; } = []; + + public List Allocations { get; } = []; + + public List Targets { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + Allocations.Add(allocation); + Requests.Add(deviceSize); + RenderTarget? result = create(deviceSize); + if (result is TrackingRenderTarget tracking) + { + Targets.Add(tracking); + } + + return result; + } + } + + private sealed class TrackingRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public TrackingRenderTarget(PixelSize size, SKColorType colorType = SKColorType.RgbaF16) + : base(CreateSurface(size, colorType), size.Width, size.Height) + { + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + if (!IsDisposed) + { + DisposeCalls++; + } + + base.Dispose(disposing); + } + + private static SKSurface CreateSurface(PixelSize size, SKColorType colorType) + { + return SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + colorType, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create the contract-test render target."); + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/RenderScaleMappingContractTests.cs b/tests/Beutl.PublicApiContractTests/RenderScaleMappingContractTests.cs new file mode 100644 index 0000000000..202aa11abe --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/RenderScaleMappingContractTests.cs @@ -0,0 +1,501 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class RenderScaleMappingContractTests +{ + [TestCase(2, 4)] + public void MapInputSupplyPreservingDemand_IsUsableByExternalRenderNodeAuthors( + float inputDensity, + float expectedDensity) + { + using var node = new SupplyMappingNode(EffectiveScale.At(inputDensity)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(expectedDensity))); + } + + [Test] + public void MapInputSupplyPreservingDemand_AllowsExternalAuthorsToPreserveUnboundedSupply() + { + using var node = new SupplyMappingNode(EffectiveScale.Unbounded); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.Unbounded)); + } + + [Test] + public void MapInputSupply_LetsExternalAuthorsRaiseTheInputDemandOfAnEnlargingMap() + { + var probe = new MaterializationDensityProbe(); + using var node = new EnlargingMapNode(probe, mapsOutputDemand: true); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(probe.ObservedWorkingScale, Is.EqualTo(2f)); + }); + } + + [Test] + public void MapInputSupplyPreservingDemand_PassesOutputDemandThroughUnchanged() + { + var probe = new MaterializationDensityProbe(); + using var node = new EnlargingMapNode(probe, mapsOutputDemand: false); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(probe.ObservedWorkingScale, Is.EqualTo(1f)); + }); + } + + [Test] + public void AWholeSourceShaderCanRaiseTheDemandOnTheInputItEnlarges() + { + var probe = new MaterializationDensityProbe(); + using var node = new EnlargingShaderNode(probe, mapsOutputDemand: true); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(probe.ObservedWorkingScale, Is.EqualTo(2f)); + }); + } + + [Test] + public void AWholeSourceShaderThatDeclaresNoDemandMappingPassesItThroughUnchanged() + { + var probe = new MaterializationDensityProbe(); + using var node = new EnlargingShaderNode(probe, mapsOutputDemand: false); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(probe.ObservedWorkingScale, Is.EqualTo(1f)); + }); + } + + [Test] + public void ACombineCanRaiseTheDemandOfOnlyTheInputItEnlarges() + { + var enlarged = new MaterializationDensityProbe(); + var passedThrough = new MaterializationDensityProbe(); + using var node = new AsymmetricCombineNode(enlarged, passedThrough, mapsPerInputDemand: true); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(enlarged.ObservedWorkingScale, Is.EqualTo(2f), "the enlarged input"); + Assert.That(passedThrough.ObservedWorkingScale, Is.EqualTo(1f), "the input it leaves alone"); + }); + } + + [Test] + public void ACombineThatDeclaresNoPerInputDemandAsksEveryInputForTheSameDensity() + { + var enlarged = new MaterializationDensityProbe(); + var passedThrough = new MaterializationDensityProbe(); + using var node = new AsymmetricCombineNode(enlarged, passedThrough, mapsPerInputDemand: false); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(enlarged.ObservedWorkingScale, Is.EqualTo(1f)); + Assert.That(passedThrough.ObservedWorkingScale, Is.EqualTo(1f)); + }); + } + + [Test] + public void APerInputDemandMappingIsRejectedOnATopologyThatCannotCarryIt() + { + using var node = new PerInputDemandOnAMapNode(); + using var renderer = CreateRenderer(node); + + Assert.That( + () => renderer.Measure(), + Throws.ArgumentException.With.Message.Contains("per-input demand mapping")); + } + + [Test] + public void MapInputSupply_ComposesTheEngineAffineDensityHelpersFromOutsideTheAssembly() + { + var mapper = new AffineDensityMapper(Matrix.CreateScale(2, 2)); + + Assert.Multiple(() => + { + Assert.That( + () => RenderScaleContract.MapInputSupply(mapper.MapSupply, mapper.MapDemand), + Throws.Nothing); + Assert.That(mapper.MapSupply(EffectiveScale.At(4)), Is.EqualTo(EffectiveScale.At(2))); + Assert.That(mapper.MapSupply(EffectiveScale.Unbounded), Is.EqualTo(EffectiveScale.Unbounded)); + Assert.That(mapper.MapDemand(EffectiveScale.At(1)), Is.EqualTo(EffectiveScale.At(2))); + }); + } + + [Test] + public void AffineDensityHelpers_AreNotInversesUnderAnAnisotropicTransform() + { + var mapper = new AffineDensityMapper(Matrix.CreateScale(0.5f, 0.25f)); + + Assert.Multiple(() => + { + Assert.That(mapper.MapSupply(EffectiveScale.At(1)), Is.EqualTo(EffectiveScale.At(4))); + Assert.That(mapper.MapDemand(EffectiveScale.At(1)), Is.EqualTo(EffectiveScale.At(0.5f))); + }); + } + + private readonly record struct AffineDensityMapper(Matrix Transform) + { + public EffectiveScale MapSupply(EffectiveScale inputSupply) + => TransformRenderNode.RescaleDensity(inputSupply, Transform); + + public EffectiveScale MapDemand(EffectiveScale outputDemand) + => TransformRenderNode.RescaleDemand(outputDemand, Transform); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private sealed class MaterializationDensityProbe + { + public float ObservedWorkingScale { get; private set; } = float.NaN; + + public void Execute(OpaqueRenderSession session) + { + ObservedWorkingScale = session.WorkingScale; + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + } + } + + private sealed class EnlargingTargetCommandNode( + MaterializationDensityProbe probe, + bool mapsOutputDemand) : RenderNode + { + private static readonly Rect s_sourceBounds = new(0, 0, 10, 10); + private static readonly Rect s_targetBounds = new(0, 0, 20, 20); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + probe, + static (session, state) => state.Execute(session), + bounds: OpaqueRenderBoundsContract.Source(s_sourceBounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + TargetCommandDefinition definition = TargetCommandDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => + { + using (canvas.PushTransform(Matrix.CreateScale(2, 2))) + { + session.Inputs[0].Draw(canvas); + } + }), + TargetRegion.Region(s_targetBounds), + s_targetBounds, + RenderHitTestContract.OutputBounds, + inputDemand: mapsOutputDemand + ? RenderInputDemandContract.MapOutputDemandToInput(DoubleDemand) + : default); + context.Publish(context.TargetCommand([source], definition.Call(default))); + } + + private static EffectiveScale DoubleDemand(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 2); + } + + private sealed class EnlargingGeometryNode( + MaterializationDensityProbe probe, + bool mapsOutputDemand) : RenderNode + { + private static readonly Rect s_sourceBounds = new(0, 0, 10, 10); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + probe, + static (session, state) => state.Execute(session), + bounds: OpaqueRenderBoundsContract.Source(s_sourceBounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + GeometryDefinition definition = GeometryDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => + { + using (canvas.PushTransform(Matrix.CreateScale(2, 2))) + { + session.Input.Draw(canvas); + } + }), + RenderBoundsContract.Create(Enlarge, Shrink), + RenderHitTestContract.OutputBounds, + inputDemand: mapsOutputDemand + ? RenderInputDemandContract.MapOutputDemandToInput(DoubleDemand) + : default); + context.Publish(context.Geometry(source, definition.Call(default))); + } + + private static Rect Enlarge(Rect inputBounds) + => new(inputBounds.X * 2, inputBounds.Y * 2, inputBounds.Width * 2, inputBounds.Height * 2); + + private static Rect Shrink(Rect outputBounds) + => new(outputBounds.X / 2, outputBounds.Y / 2, outputBounds.Width / 2, outputBounds.Height / 2); + + private static EffectiveScale DoubleDemand(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 2); + } + + private sealed class EnlargingMapNode( + MaterializationDensityProbe probe, + bool mapsOutputDemand) : RenderNode + { + private static readonly Rect s_sourceBounds = new(0, 0, 10, 10); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + probe, + static (session, state) => state.Execute(session), + bounds: OpaqueRenderBoundsContract.Source(s_sourceBounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + RenderScaleContract scale = mapsOutputDemand + ? RenderScaleContract.MapInputSupply(HalveSupply, DoubleDemand) + : RenderScaleContract.MapInputSupplyPreservingDemand(HalveSupply); + RenderFragmentHandle enlarged = context.OpaqueMap(source, RenderDefinitionCallFactory.Opaque( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Create(Enlarge, Shrink)), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: scale)); + context.Publish(enlarged); + } + + private static Rect Enlarge(Rect inputBounds) + => new(inputBounds.X * 2, inputBounds.Y * 2, inputBounds.Width * 2, inputBounds.Height * 2); + + private static Rect Shrink(Rect outputBounds) + => new(outputBounds.X / 2, outputBounds.Y / 2, outputBounds.Width / 2, outputBounds.Height / 2); + + private static EffectiveScale HalveSupply(EffectiveScale inputSupply) + => inputSupply.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(inputSupply.Value / 2); + + private static EffectiveScale DoubleDemand(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 2); + } + + private sealed class EnlargingShaderNode( + MaterializationDensityProbe probe, + bool mapsOutputDemand) : RenderNode + { + private const string EnlargingSource = + "uniform shader src; half4 main(float2 coord) { return src.eval(coord * 0.5); }"; + + private static readonly Rect s_sourceBounds = new(0, 0, 10, 10); + + private static readonly ShaderDefinition s_mapsDemand = + ShaderDefinition.WholeSource( + EnlargingSource, + RenderBoundsContract.Create(Enlarge, Shrink), + inputDemand: RenderInputDemandContract.MapOutputDemandToInput(DoubleDemand)); + + private static readonly ShaderDefinition s_leavesDemandUnchanged = + ShaderDefinition.WholeSource( + EnlargingSource, + RenderBoundsContract.Create(Enlarge, Shrink)); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + probe, + static (session, state) => state.Execute(session), + bounds: OpaqueRenderBoundsContract.Source(s_sourceBounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + ShaderDefinition definition = mapsOutputDemand ? s_mapsDemand : s_leavesDemandUnchanged; + context.Publish(context.Shader(source, definition.Call(0))); + } + + private static Rect Enlarge(Rect inputBounds) + => new(inputBounds.X * 2, inputBounds.Y * 2, inputBounds.Width * 2, inputBounds.Height * 2); + + private static Rect Shrink(Rect outputBounds) + => new(outputBounds.X / 2, outputBounds.Y / 2, outputBounds.Width / 2, outputBounds.Height / 2); + + private static EffectiveScale DoubleDemand(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 2); + } + + private sealed class AsymmetricCombineNode( + MaterializationDensityProbe enlarged, + MaterializationDensityProbe passedThrough, + bool mapsPerInputDemand) : RenderNode + { + private static readonly Rect s_sourceBounds = new(0, 0, 10, 10); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle first = Source(context, enlarged); + RenderFragmentHandle second = Source(context, passedThrough); + context.Publish(context.OpaqueCombine( + [first, second], + RenderDefinitionCallFactory.Opaque( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Combine( + static inputs => inputs[0].Union(inputs[1]), + static (_, inputs) => inputs), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MaterializeAtWorkingScale, + inputDemand: mapsPerInputDemand + ? RenderInputDemandContract.MapOutputDemandPerInput(DoubleTheFirstInput) + : default))); + } + + private static RenderFragmentHandle Source( + RenderNodeContext context, + MaterializationDensityProbe probe) + => context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + probe, + static (session, state) => state.Execute(session), + bounds: OpaqueRenderBoundsContract.Source(s_sourceBounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + + private static EffectiveScale DoubleTheFirstInput(int inputIndex, EffectiveScale outputDemand) + => inputIndex == 0 + ? EffectiveScale.At(outputDemand.Value * 2) + : outputDemand; + } + + private sealed class PerInputDemandOnAMapNode : RenderNode + { + private static readonly Rect s_bounds = new(0, 0, 10, 10); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + execute: static _ => throw new AssertionException("Measurement must not execute opaque callbacks."), + bounds: OpaqueRenderBoundsContract.Source(s_bounds), + hitTest: RenderHitTestContract.None, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Vector)); + context.Publish(context.OpaqueMap(source, RenderDefinitionCallFactory.Opaque( + execute: static _ => throw new AssertionException("Measurement must not execute opaque callbacks."), + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.None, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MaterializeAtWorkingScale, + inputDemand: RenderInputDemandContract.MapOutputDemandToInput(Double)))); + } + + private static EffectiveScale Double(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 2); + } + + private sealed class SupplyMappingNode(EffectiveScale inputSupply) : RenderNode + { + private static readonly Rect s_bounds = new(0, 0, 20, 10); + + public override void Process(RenderNodeContext context) + { + RenderScaleContract sourceScale = inputSupply.IsUnbounded + ? RenderScaleContract.Vector + : RenderScaleContract.Custom( + new FixedScaleResolver(inputSupply.Value).Resolve); + RenderFragmentHandle source = context.OpaqueSource(RenderDefinitionCallFactory.Opaque( + execute: static _ => throw new AssertionException("Measurement must not execute opaque callbacks."), + bounds: OpaqueRenderBoundsContract.Source(s_bounds), + hitTest: RenderHitTestContract.None, + valueCardinality: RenderValueCardinality.Single, + scale: sourceScale)); + RenderFragmentHandle mapped = context.OpaqueMap(source, RenderDefinitionCallFactory.Opaque( + execute: static _ => throw new AssertionException("Measurement must not execute opaque callbacks."), + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.None, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MapInputSupplyPreservingDemand(DoubleSupply))); + context.Publish(mapped); + } + + private static EffectiveScale DoubleSupply(EffectiveScale input) + => input.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(input.Value * 2); + + private readonly record struct FixedScaleResolver(float Value) + { + public float Resolve(RenderScaleContext _) => Value; + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs new file mode 100644 index 0000000000..a8105dee81 --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs @@ -0,0 +1,324 @@ +using System.Reflection; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class ShaderAuthoringContractTests +{ + private const string CurrentPixelSource = + "uniform float amount; half4 apply(half4 color) { return color * amount; }"; + private const string WholeSource = + "uniform shader src; uniform shader tint; half4 main(float2 coord) { return tint.eval(coord); }"; + private static readonly Rect s_bounds = new(0, 0, 8, 6); + private static readonly RenderResourceSlot s_colorSlot = new(); + private static readonly ShaderDefinition s_currentPixelDefinition = + ShaderDefinition.CurrentPixel( + CurrentPixelSource, + static bindings => bindings.Uniform("amount", static state => state)); + private static readonly ShaderDefinition s_wholeSourceDefinition = + ShaderDefinition.WholeSource( + WholeSource, + RenderBoundsContract.Identity, + static bindings => bindings.Resource( + "tint", + s_colorSlot, + ShaderResourceCoordinateSpace.OutputDevice, + static (writer, color, _) => + { + color.Uses++; + writer.Set(SKShader.CreateColor(color.Color)); + })); + + /// + /// A plugin author with many effects over one shader has the same reason the engine does to parse it + /// once. SkslSource, its Kind, and the definition factories that take one were public in name only: + /// nothing reachable from outside the assembly could produce or consume an instance. + /// + [Test] + public void AParsedSourceCanBeSharedAcrossDefinitions() + { + SkslSource parsed = SkslSource.CurrentPixel(CurrentPixelSource); + ShaderDefinition first = ShaderDefinition.CurrentPixel( + parsed, + static bindings => bindings.Uniform("amount", static state => state)); + ShaderDefinition second = ShaderDefinition.CurrentPixel( + parsed, + static bindings => bindings.Uniform("amount", static state => 1f - state)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(parsed.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + Assert.That(parsed.Text.TrimEnd(), Is.EqualTo(CurrentPixelSource), "the text is normalized, not rewritten"); + Assert.That(parsed.IdentityHash, Is.Not.Empty); + Assert.That(first, Is.Not.SameAs(second)); + } + } + + [Test] + public void AParsedWholeSourceCanHeadADefinition() + { + SkslSource parsed = SkslSource.WholeSource(WholeSource); + + ShaderDefinition definition = ShaderDefinition.WholeSource( + parsed, + RenderBoundsContract.Identity, + static bindings => bindings.Resource( + "tint", + s_colorSlot, + ShaderResourceCoordinateSpace.OutputDevice, + static (writer, color, _) => writer.Set(SKShader.CreateColor(color.Color)))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(parsed.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + Assert.That(definition, Is.Not.Null); + } + } + + [Test] + public void AParsedSourceOfTheWrongKind_IsRejectedWhereItIsDeclared() + { + SkslSource currentPixel = SkslSource.CurrentPixel(CurrentPixelSource); + + Assert.That( + () => ShaderDefinition.WholeSource(currentPixel, RenderBoundsContract.Identity), + Throws.ArgumentException); + } + + /// + /// A WholeSource shader's input arrives as the implicit 'src' child, so binding it explicitly is not + /// something the pipeline can honour. Accepting the definition and throwing on every call of it hands the + /// author a shape that builds and is then unusable, with nothing pointing at the declaration that did it. + /// + [Test] + public void AWholeSourceDefinitionBindingSrcExplicitly_IsRejectedWhereItIsDeclared() + { + Assert.That( + () => ShaderDefinition.WholeSource( + WholeSource, + RenderBoundsContract.Identity, + static bindings => bindings.Resource( + "src", + s_colorSlot, + ShaderResourceCoordinateSpace.OutputDevice, + static (writer, color, _) => writer.Set(SKShader.CreateColor(color.Color)))), + Throws.ArgumentException.With.Message.Contains("implicit WholeSource input")); + } + + [Test] + public void CurrentPixelDefinitionCall_MapsAValueEligibleInput() + { + ShaderCall call = s_currentPixelDefinition.Call(0.75f); + FragmentSnapshot output = default; + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(SourceCall(Colors.White)); + RenderFragmentHandle shader = context.Shader(source, call); + output = FragmentSnapshot.From(shader); + context.Publish(shader); + }); + + RenderNodeMeasurement measurement = Measure(node); + + Assert.Multiple(() => + { + Assert.That(call.Definition, Is.SameAs(s_currentPixelDefinition)); + Assert.That(call.State, Is.EqualTo(0.75f)); + Assert.That(output.Bounds, Is.EqualTo(s_bounds)); + Assert.That(output.CanBeUsedAsValueInput, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + }); + } + + [Test] + public void WholeSourceDefinitionCall_UsesItsDeclaredTypedResourceSlot() + { + var color = new ShaderColor(SKColors.MediumPurple); + using var node = new DelegateNode(context => + { + RenderResource token = context.Borrow(color); + RenderFragmentHandle source = context.OpaqueSource(SourceCall(Colors.White)); + context.Publish(context.Shader( + source, + s_wholeSourceDefinition.Call(default, [s_colorSlot.Bind(token)]))); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(color.Uses, Is.EqualTo(1)); + }); + } + + [Test] + public void ShaderDefinitions_RejectIncompleteAndDuplicateBindingShapes() + { + Assert.Multiple(() => + { + Assert.That( + () => ShaderDefinition.CurrentPixel(CurrentPixelSource), + Throws.TypeOf()); + Assert.That( + () => ShaderDefinition.CurrentPixel( + CurrentPixelSource, + static bindings => + { + bindings.Uniform("amount", static _ => 0.5f); + bindings.Uniform("amount", static _ => 0.75f); + }), + Throws.TypeOf()); + }); + } + + [Test] + public void ShaderDefinitions_RejectCapturedUniformValueProviders() + { + float multiplier = 2; + + Assert.That( + () => ShaderDefinition.CurrentPixel( + CurrentPixelSource, + bindings => bindings.Uniform("amount", state => state * multiplier)), + Throws.TypeOf()); + } + + [Test] + public void ShaderDefinitions_RejectCapturedCustomUniformBinders() + { + float multiplier = 2; + + Assert.That( + () => ShaderDefinition.CurrentPixel( + CurrentPixelSource, + bindings => bindings.Uniform( + "amount", + static state => state, + (writer, value, _) => writer.Set(value * multiplier))), + Throws.TypeOf()); + } + + [Test] + public void ShaderDefinitions_RejectCapturedResourceBinders() + { + SKColor tint = SKColors.MediumPurple; + + Assert.That( + () => ShaderDefinition.WholeSource( + WholeSource, + RenderBoundsContract.Identity, + bindings => bindings.Resource( + "tint", + s_colorSlot, + ShaderResourceCoordinateSpace.OutputDevice, + (writer, _, _) => writer.Set(SKShader.CreateColor(tint)))), + Throws.TypeOf()); + } + + [Test] + public void ShaderDescription_IsNotPartOfTheExternalAuthoringSurface() + { + Assembly engine = typeof(RenderNode).Assembly; + MethodInfo[] methods = typeof(RenderNodeContext) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(static method => method.Name == "Shader") + .ToArray(); + + Assert.Multiple(() => + { + Assert.That( + engine.GetExportedTypes().Any(static type => type.FullName == "Beutl.Graphics.Effects.ShaderDescription"), + Is.False); + Assert.That(methods, Has.Length.EqualTo(1)); + Assert.That(methods[0].GetParameters()[1].ParameterType.GetGenericTypeDefinition(), + Is.EqualTo(typeof(ShaderCall<>))); + }); + } + + private static OpaqueRenderCall SourceCall(Color color) + => OpaqueRenderDefinition.Create( + static (session, current) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(current)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale) + .Call(color); + + private static RenderNodeMeasurement Measure(RenderNode node) + { + using var renderer = new RenderNodeRenderer(node); + return renderer.Measure(); + } + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + return renderer.Rasterize(); + } + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class ShaderColor(SKColor color) + { + public SKColor Color { get; } = color; + + public int Uses { get; set; } + } + + private readonly record struct FragmentSnapshot(Rect Bounds, bool CanBeUsedAsValueInput) + { + public static FragmentSnapshot From(RenderFragmentHandle handle) + { + Assert.That(handle.TryGetMetadata(out RenderFragmentMetadata metadata), Is.True); + return new FragmentSnapshot(metadata.Bounds, handle.CanBeUsedAsValueInput); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public CpuRenderTarget(PixelSize size) + : base( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a CPU shader contract-test surface."), + size.Width, + size.Height) + { + } + } +} diff --git a/tests/Beutl.PublicApiContractTests/TargetAuthoringContractTests.cs b/tests/Beutl.PublicApiContractTests/TargetAuthoringContractTests.cs new file mode 100644 index 0000000000..a93baac2ae --- /dev/null +++ b/tests/Beutl.PublicApiContractTests/TargetAuthoringContractTests.cs @@ -0,0 +1,295 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.PublicApiContractTests; + +[TestFixture] +public sealed class TargetAuthoringContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 6); + private static readonly RenderResourceSlot s_payloadSlot = new(); + private static readonly OpaqueRenderDefinition s_sourceDefinition = + OpaqueRenderDefinition.Create( + static (session, color) => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(color)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + private static readonly TargetScopeDefinition s_scopeDefinition = + TargetScopeDefinition.Create( + static (session, _) => session.ReplayInput(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + resources: []); + private static readonly TargetCommandDefinition s_commandDefinition = + TargetCommandDefinition.Create( + static (session, state) => session.UseResource(s_payloadSlot, payload => + { + payload.Uses++; + state.Executions++; + session.ReplaceAffectedRegion(state.Color); + }), + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds, + resources: [s_payloadSlot]); + private static readonly TargetCommandDefinition s_shapeCommandDefinition = + TargetCommandDefinition.Create( + static (session, state) => session.ReplaceAffectedRegion(state.Color), + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds); + + [Test] + public void TargetDefinitionCalls_RecordTheFixedDefinitionShapeAndPerCallState() + { + var commandState = new CommandState(Colors.Red); + OpaqueRenderCall sourceCall = s_sourceDefinition.Call(Colors.CornflowerBlue); + TargetScopeCall scopeCall = s_scopeDefinition.Call(default); + TargetCommandCall commandCall = s_shapeCommandDefinition.Call(commandState); + bool scopeEligible = true; + bool commandEligible = true; + + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(sourceCall); + RenderFragmentHandle scope = context.TargetScope(source, scopeCall); + RenderFragmentHandle command = context.TargetCommand([], commandCall); + scopeEligible = scope.CanBeUsedAsValueInput; + commandEligible = command.CanBeUsedAsValueInput; + context.PublishRange([scope, command]); + }); + + RenderNodeMeasurement measurement = Measure(node); + + Assert.Multiple(() => + { + Assert.That(sourceCall.Definition, Is.SameAs(s_sourceDefinition)); + Assert.That(sourceCall.State, Is.EqualTo(Colors.CornflowerBlue)); + Assert.That(scopeCall.Definition, Is.SameAs(s_scopeDefinition)); + Assert.That(commandCall.Definition, Is.SameAs(s_shapeCommandDefinition)); + Assert.That(commandCall.State, Is.SameAs(commandState)); + Assert.That(scopeEligible, Is.False); + Assert.That(commandEligible, Is.False); + Assert.That(measurement.HasTargetEffects, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + }); + } + + [Test] + public void TargetCommandCall_UsesTheResourceBoundToItsDeclaredSlot() + { + var payload = new CommandPayload(); + var state = new CommandState(Colors.MediumPurple); + using var node = new DelegateNode(context => + { + RenderResource token = context.Borrow(payload); + context.Publish(context.TargetCommand( + [], + s_commandDefinition.Call(state, [s_payloadSlot.Bind(token)]))); + }); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(payload.Uses, Is.EqualTo(1)); + Assert.That(state.Executions, Is.EqualTo(1)); + }); + } + + [Test] + public void TargetCommandCall_RejectsAHitTestForAnEmptyQueryRegion() + { + Assert.That( + () => TargetCommandDefinition.Create( + static (_, _) => { }, + TargetRegion.Region(s_bounds), + Rect.Empty, + RenderHitTestContract.OutputBounds), + Throws.TypeOf().With.Property("ParamName").EqualTo("hitTest")); + } + + [Test] + public void TargetCommandCall_DeclaresReadbackThroughTheDefinition() + { + int snapshots = 0; + TargetCommandDefinition> definition = + TargetCommandDefinition>.Create( + static (session, action) => action(session), + TargetRegion.Region(s_bounds), + Rect.Empty, + RenderHitTestContract.None, + access: TargetAccess.Readback); + using var node = new DelegateNode(context => + context.Publish(context.TargetCommand( + [], + definition.Call(session => session.UseSnapshot(_ => snapshots++))))); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(snapshots, Is.EqualTo(1)); + } + + [Test] + public void RawTargetCommandCall_RemainsAnExplicitDefinitionBasedBoundary() + { + int executions = 0; + RawTargetCommandDefinition> definition = + RawTargetCommandDefinition>.Create( + static (session, action) => action(session), + Rect.Empty, + RenderHitTestContract.None); + using var node = new DelegateNode(context => + context.Publish(context.RawTargetCommand(definition.Call(_ => executions++)))); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(executions, Is.EqualTo(1)); + }); + } + + /// + /// PrepareForRequest is where a node reconciles what depends on the request, and RenderNodePreparation + /// cannot be constructed from outside the engine, so a node reached through RecordNode with explicit + /// inputs has no other way to get its call. Missing it there would leave that node on the state some + /// earlier request left behind. + /// + [Test] + public void ANodeRecordedWithExplicitInputs_IsStillPreparedForTheRequest() + { + var recorded = new PreparationCountingNode(); + using var node = new DelegateNode(context => + { + RenderFragmentHandle source = context.OpaqueSource(s_sourceDefinition.Call(Colors.White)); + foreach (RenderFragmentHandle output in context.RecordNode(recorded, [source])) + context.Publish(output); + }); + + using RenderNodeRasterization first = Rasterize(node); + using RenderNodeRasterization second = Rasterize(node); + + Assert.Multiple(() => + { + Assert.That(recorded.Preparations, Is.EqualTo(2), "one preparation per request"); + Assert.That( + recorded.Preparations, + Is.EqualTo(recorded.Processes), + "every Process must be preceded by exactly one PrepareForRequest"); + }); + } + + /// + /// Whether a scope's replay transform lives in its input's coordinates or against the ambient target is + /// something only the author knows, and it decides whether the declared scale contract can carry an + /// output demand back to the input. An out-of-tree scope has to be able to say it. + /// + [Test] + public void AGuardedScopeDeclaresTheSpaceItsReplayTransformLivesIn() + { + TargetScopeDefinition inputLogical = TargetScopeDefinition.Create( + static (session, _) => session.Canvas.Use(_ => session.ReplayInput()), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.MapInputSupply( + static supply => supply, + static demand => EffectiveScale.At(demand.Value * 2)), + transformSpace: RenderScopeTransformSpace.InputLogical); + using var node = new DelegateNode(context => context.Publish(context.TargetScope( + context.OpaqueSource(s_sourceDefinition.Call(Colors.White)), + inputLogical.Call(0)))); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(rasterization.IsEmpty, Is.False); + } + + /// + /// A raw definition's callback is static and its slots are fixed, so the only thing that changes per call + /// is the binding. Without slot addressing the callback would have to be handed the exact token in its + /// state as well, leaving the declared binding validation-only and the resource named in two places. + /// + [Test] + public void ARawCommandAddressesItsResourceByTheSlotItDeclared() + { + var payload = new CommandPayload(); + RawTargetCommandDefinition definition = RawTargetCommandDefinition.Create( + static (session, _) => session.UseResource(s_payloadSlot, static bound => bound.Uses++), + Rect.Empty, + RenderHitTestContract.None, + resources: [s_payloadSlot]); + using var node = new DelegateNode(context => context.Publish(context.RawTargetCommand( + definition.Call(0, [s_payloadSlot.Bind(context.Borrow(payload))])))); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(payload.Uses, Is.EqualTo(1)); + } + + private static RenderNodeMeasurement Measure(RenderNode node) + { + using var renderer = CreateRenderer(node); + return renderer.Measure(); + } + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = CreateRenderer(node); + return renderer.Rasterize(); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private sealed class DelegateNode(Action process) : RenderNode + { + public override void Process(RenderNodeContext context) => process(context); + } + + private sealed class PreparationCountingNode : RenderNode + { + public int Preparations { get; private set; } + + public int Processes { get; private set; } + + public override void PrepareForRequest(RenderNodePreparation preparation) => Preparations++; + + public override void Process(RenderNodeContext context) + { + Processes++; + context.PassThrough(); + } + } + + private sealed class CommandPayload + { + public int Uses { get; set; } + } + + private sealed class CommandState(Color color) + { + public Color Color { get; } = color; + + public int Executions { get; set; } + } +} diff --git a/tests/Beutl.UnitTests/Benchmarks/Rendering/RenderPipelineBenchmarkNodeTests.cs b/tests/Beutl.UnitTests/Benchmarks/Rendering/RenderPipelineBenchmarkNodeTests.cs new file mode 100644 index 0000000000..07ff8419f6 --- /dev/null +++ b/tests/Beutl.UnitTests/Benchmarks/Rendering/RenderPipelineBenchmarkNodeTests.cs @@ -0,0 +1,252 @@ +using Beutl.Benchmarks.Rendering; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering; + +using SkiaSharp; + +namespace Beutl.UnitTests.Benchmarks.Rendering; + +public sealed class RenderPipelineBenchmarkNodeTests +{ + [TestCase("LayerCustomEffect")] + [TestCase("BlurCustomBlur")] + [TestCase("StaticSpatialPrefixAnimatedBlurTail")] + [NonParallelizable] + public void NewScenes_WarmAndVerifyThroughProductionBenchmarkSession(string caseName) + { + VulkanTestEnvironment.EnsureAvailable(); + RenderThread.Dispatcher.Invoke(() => + { + using var session = new RenderPipelineBenchmarkSession(caseName); + session.WarmAndVerify(); + _ = session.RenderMeasuredFrame(); + RenderPipelineBenchmarkCounterRecord record = session.CreateCounterRecord(); + + Assert.Multiple(() => + { + Assert.That(record.OutputSha256, Is.Not.Empty); + Assert.That(record.MeasuredOutputSha256, Is.Not.Empty); + if (caseName == "StaticSpatialPrefixAnimatedBlurTail") + { + Assert.That(record.MeasuredOutputSha256, Is.Not.EqualTo(record.OutputSha256)); + } + }); + }); + } + + [Test] + public void CustomEffectScenes_ShareSpatialSourceAndDeclareExactTopology() + { + RenderPipelineBenchmarkSceneDefinition spatialGroup = + RenderPipelineBenchmarkScenes.Get("SpatialGroupChain"); + RenderPipelineBenchmarkSceneDefinition spatialNodes = + RenderPipelineBenchmarkScenes.Get("SpatialNodeChain"); + RenderPipelineBenchmarkSceneDefinition custom = + RenderPipelineBenchmarkScenes.Get("LayerCustomEffect"); + RenderPipelineBenchmarkSceneDefinition mixed = + RenderPipelineBenchmarkScenes.Get("BlurCustomBlur"); + var customEffect = (FilterEffectGroup)RenderPipelineBenchmarkSession.CreateCustomEffectForTest(mixed: false); + var mixedEffect = (FilterEffectGroup)RenderPipelineBenchmarkSession.CreateCustomEffectForTest(mixed: true); + + Assert.Multiple(() => + { + Assert.That( + new[] { spatialGroup.Seed, spatialNodes.Seed, custom.Seed, mixed.Seed }.Distinct().Count(), + Is.EqualTo(1)); + Assert.That(custom.SemanticStageCount, Is.EqualTo(1)); + Assert.That(mixed.SemanticStageCount, Is.EqualTo(3)); + Assert.That(custom.Barrier, Is.EqualTo(RenderPipelineBenchmarkBarrier.CustomEffect)); + Assert.That(mixed.Barrier, Is.EqualTo(RenderPipelineBenchmarkBarrier.CustomEffect)); + Assert.That(customEffect.Children, Has.Count.EqualTo(1)); + Assert.That(customEffect.Children[0], Is.TypeOf()); + Assert.That(mixedEffect.Children.Select(static effect => effect.GetType()), Is.EqualTo(new[] + { + typeof(Blur), + typeof(LayerEffect), + typeof(Blur), + })); + Assert.That(CompileBoundaryReasons(customEffect), Does.Contain(ExecutionIslandBoundaryReason.LegacyCustomEffect)); + Assert.That(CompileBoundaryReasons(mixedEffect), Does.Contain(ExecutionIslandBoundaryReason.LegacyCustomEffect)); + }); + } + + [Test] + public void StaticSpatialPrefix_AnimatedBlurTailChangesOutputAndKeepsStaticChildClean() + { + var source = new RectangleRenderNode( + new Rect(12, 10, 40, 28), + Brushes.Resource.White, + null); + var prefixEffect = new Blur { Sigma = { CurrentValue = new Size(3, 3) } }; + using FilterEffect.Resource prefixResource = prefixEffect.ToResource(CompositionContext.Default); + var prefix = new FilterEffectRenderNode(prefixResource); + prefix.AddChild(source); + var boundary = new BenchmarkCacheBoundaryNode(); + boundary.AddChild(prefix); + boundary.SettleConstruction(); + boundary.Cache.RecordStableRequests(); + var tailEffect = new Blur(); + using FilterEffect.Resource tailResource = tailEffect.ToResource(CompositionContext.Default); + using var tail = new FilterEffectRenderNode(tailResource); + tail.AddChild(boundary); + var animation = new BenchmarkAnimatedBlurNode(tailEffect, tailResource, tail); + using var renderer = CreateCpuRenderer(tail); + + animation.Apply(new RenderPipelineBenchmarkFrameState(0.75f, StructuralVariant: false)); + using RenderNodeRasterization first = renderer.Rasterize(); + byte[] firstPixels = first.Bitmap?.GetPixelSpan().ToArray() + ?? throw new InvalidOperationException("The first animated Blur frame produced no pixels."); + Assert.That(boundary.Cache.IsCached, Is.True); + using RenderTarget cachedPrefix = boundary.Cache.UseCache(out Rect cachedBounds); + animation.Apply(new RenderPipelineBenchmarkFrameState(1.25f, StructuralVariant: false)); + + Assert.Multiple(() => + { + Assert.That(tail.HasChanges, Is.True); + Assert.That(boundary.HasChanges, Is.False); + Assert.That(prefix.HasChanges, Is.False); + Assert.That(source.HasChanges, Is.False); + }); + + using RenderNodeRasterization second = renderer.Rasterize(); + using RenderTarget retainedPrefix = boundary.Cache.UseCache(out Rect retainedBounds); + Assert.Multiple(() => + { + Assert.That(second.Bitmap, Is.Not.Null); + Assert.That(second.Bitmap!.GetPixelSpan().SequenceEqual(firstPixels), Is.False); + Assert.That(boundary.Cache.IsCached, Is.True); + Assert.That(retainedPrefix.Value, Is.SameAs(cachedPrefix.Value)); + Assert.That(retainedBounds, Is.EqualTo(cachedBounds)); + Assert.That(tail.HasChanges, Is.False); + Assert.That(prefix.HasChanges, Is.False); + }); + } + + [Test] + public void AnimatedTail_ChangedAmountsInvalidateTailAndPreserveStaticPrefixCache() + { + var prefix = new BenchmarkCacheBoundaryNode(); + var tail = new BenchmarkAnimatedShaderNode(); + tail.AddChild(prefix); + using var root = new BenchmarkShaderNode(BenchmarkShader.ChannelRotate); + root.AddChild(tail); + Rect bounds = new(0, 0, 1, 1); + + RenderNodeCache.PublishAtomically( + [ + RenderCacheTestSupport.CreatePublication( + prefix.Cache, + RenderTarget.CreateNull(1, 1), + bounds, + name: "static-prefix"), + RenderCacheTestSupport.CreatePublication( + tail.Cache, + RenderTarget.CreateNull(1, 1), + bounds, + name: "animated-tail"), + ]); + + var first = new RenderPipelineBenchmarkFrameState(0.75f, StructuralVariant: false); + tail.Apply(first); + RenderNodeCacheLifecycle lifecycle = RenderNodeCacheHelper.BeginLifecycle(root); + + Assert.Multiple(() => + { + Assert.That(tail.Cache.IsCached, Is.False); + Assert.That(prefix.Cache.IsCached, Is.True); + }); + + lifecycle.CompleteSuccessfully(advanceWarmup: true); + RenderNodeCache.PublishAtomically( + [ + RenderCacheTestSupport.CreatePublication( + tail.Cache, + RenderTarget.CreateNull(1, 1), + bounds, + name: "animated-tail"), + ]); + + var second = new RenderPipelineBenchmarkFrameState(0.8f, StructuralVariant: false); + tail.Apply(second); + lifecycle = RenderNodeCacheHelper.BeginLifecycle(root); + + Assert.Multiple(() => + { + Assert.That(tail.Cache.IsCached, Is.False); + Assert.That(prefix.Cache.IsCached, Is.True); + }); + + lifecycle.CompleteSuccessfully(advanceWarmup: true); + tail.Apply(second); + + Assert.That(tail.HasChanges, Is.False); + } + + private static RenderNodeRenderer CreateCpuRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 64, 48), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static IEnumerable CompileBoundaryReasons(FilterEffect effect) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var node = new FilterEffectRenderNode(resource); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 64, 48), + Brushes.Resource.White, + null)); + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: new Rect(0, 0, 64, 48), + cachePolicy: RenderCacheOptions.Disabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + return compiled.ExecutionPlan.Boundaries + .Select(static boundary => boundary.Reason) + .ToArray(); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget(PixelSize size) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU benchmark-test surface."), + size.Width, + size.Height); +} diff --git a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj index 44333793f1..2c1b9ab995 100644 --- a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj +++ b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj @@ -41,6 +41,7 @@ + diff --git a/tests/Beutl.UnitTests/Editor/ExportRendererFactoryTests.cs b/tests/Beutl.UnitTests/Editor/ExportRendererFactoryTests.cs new file mode 100644 index 0000000000..db3b45f15e --- /dev/null +++ b/tests/Beutl.UnitTests/Editor/ExportRendererFactoryTests.cs @@ -0,0 +1,38 @@ +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Models; +using Beutl.ProjectSystem; + +namespace Beutl.UnitTests.Editor; + +public sealed class ExportRendererFactoryTests +{ + [Test] + public void Create_ConfiguresTheRendererForDeliveryGradeOutput() + { + var scene = new Scene(240, 120, "Export"); + + using SceneRenderer renderer = ExportRendererFactory.Create(scene, renderScale: 2f); + + Assert.Multiple(() => + { + Assert.That(renderer.Intent, Is.EqualTo(RenderIntent.Delivery), + "An export must fail on an intermediate allocation failure, not silently drop content."); + Assert.That(renderer.MaxWorkingScale, Is.EqualTo(WorkingScaleCeiling.Export())); + Assert.That(renderer.OutputScale, Is.EqualTo(2f)); + Assert.That(renderer.CacheOptions, Is.SameAs(RenderCacheOptions.Disabled)); + Assert.That(renderer.Compositor.ForceOriginalSource, Is.True); + Assert.That(renderer.Compositor.DisableResourceShare, Is.True); + }); + } + + [Test] + public void PreviewSceneRenderer_StaysOnPreviewIntent() + { + var scene = new Scene(240, 120, "Preview"); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, maxWorkingScale: WorkingScaleCeiling.Preview(1f)); + + Assert.That(renderer.Intent, Is.EqualTo(RenderIntent.Preview)); + } +} diff --git a/tests/Beutl.UnitTests/Editor/ExportSupersamplingTests.cs b/tests/Beutl.UnitTests/Editor/ExportSupersamplingTests.cs index 0283b52040..579c67a389 100644 --- a/tests/Beutl.UnitTests/Editor/ExportSupersamplingTests.cs +++ b/tests/Beutl.UnitTests/Editor/ExportSupersamplingTests.cs @@ -54,8 +54,8 @@ public void FitsBufferLimit_AgainstEngineLimit(int w, int h, int factor, bool ex [Test] public void FitsBufferLimit_DefaultLimit_IsTheEngineConstant() { - var atLimit = new PixelSize(RenderNodeContext.MaxBufferDimension, 1080); - var overLimit = new PixelSize(RenderNodeContext.MaxBufferDimension + 1, 1080); + var atLimit = new PixelSize(RenderScaleUtilities.MaxBufferDimension, 1080); + var overLimit = new PixelSize(RenderScaleUtilities.MaxBufferDimension + 1, 1080); Assert.That(ExportSupersampling.FitsBufferLimit(atLimit, 1), Is.True); Assert.That(ExportSupersampling.FitsBufferLimit(overLimit, 1), Is.False); diff --git a/tests/Beutl.UnitTests/Editor/FrameProviderRenderTargetRetentionTests.cs b/tests/Beutl.UnitTests/Editor/FrameProviderRenderTargetRetentionTests.cs new file mode 100644 index 0000000000..4c90049a5a --- /dev/null +++ b/tests/Beutl.UnitTests/Editor/FrameProviderRenderTargetRetentionTests.cs @@ -0,0 +1,168 @@ +using System.Reactive.Subjects; +using Beutl.Animation; +using Beutl.Animation.Easings; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Models; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Editor; + +[NonParallelizable] +public sealed class FrameProviderRenderTargetRetentionTests +{ + [Test] + public void Checkpoint_ReleasesEveryThirtyRenderedFrames() + { + var checkpoint = new RetainedRenderTargetCheckpoint(); + var releases = new List(); + + for (int frame = 1; frame <= 75; frame++) + { + if (checkpoint.Advance()) + releases.Add(frame); + } + + Assert.That(releases, Is.EqualTo(new[] { 30, 60 })); + } + + [Test] + public async Task SequentialExportFrames_PeriodicallyReleaseRetainedIntermediateTargets() + { + VulkanTestEnvironment.EnsureAvailable(); + RetentionRun disabled = await RunSequentialExport(int.MaxValue); + RetentionRun enabled = await RunSequentialExport(RetainedRenderTargetCheckpoint.DefaultReleaseInterval); + + Assert.Multiple(() => + { + Assert.That(disabled.FrameCount, Is.EqualTo(150)); + Assert.That(enabled.FrameCount, Is.EqualTo(disabled.FrameCount)); + Assert.That(disabled.PeakRetainedBytes, Is.GreaterThan(0)); + Assert.That(disabled.FinalRetainedBytes, Is.GreaterThan(0)); + Assert.That(enabled.PeakRetainedBytes, Is.LessThan(disabled.PeakRetainedBytes / 2), + "Periodic export checkpoints must materially reduce peak retention under the same workload."); + Assert.That(enabled.FinalRetainedBytes, Is.Zero, + "The final periodic export checkpoint must release every idle intermediate target."); + }); + } + + [Test] + public void ReleaseRetainedRenderTargets_DoesNotChangeCurrentFramePixels() + { + VulkanTestEnvironment.EnsureAvailable(); + RenderThread.Dispatcher.Invoke(() => + { + Scene scene = CreateAnimatedBufferedEffectScene(frameRate: 30, frameCount: 150); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); + renderer.CacheOptions = RenderCacheOptions.Disabled; + const int renderedFrameCount = 150; + for (int frame = 0; frame < renderedFrameCount; frame++) + { + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.FromSeconds(frame / 30d))); + } + + using Bitmap before = renderer.Snapshot(); + + long released = renderer.ReleaseRetainedRenderTargets(); + using Bitmap after = renderer.Snapshot(); + + Assert.Multiple(() => + { + Assert.That(released, Is.GreaterThan(0)); + Assert.That(after.GetPixelSpan().ToArray(), Is.EqualTo(before.GetPixelSpan().ToArray())); + }); + }); + } + + private static async Task RunSequentialExport(int releaseInterval) + { + const int frameRate = 30; + const int frameCount = 150; + Scene scene = CreateAnimatedBufferedEffectScene(frameRate, frameCount); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); + renderer.CacheOptions = RenderCacheOptions.Disabled; + using var progress = new Subject(); + using var provider = new FrameProviderImpl( + scene, + new Rational(frameRate, 1), + renderer, + progress, + releaseInterval); + long peakRetainedBytes = 0; + + for (long frame = 0; frame < provider.FrameCount; frame++) + { + using Bitmap bitmap = await provider.RenderFrame(frame); + Assert.That(bitmap.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + peakRetainedBytes = Math.Max( + peakRetainedBytes, + RenderThread.Dispatcher.Invoke(() => renderer.RetainedRenderTargetBytes)); + } + + long retainedBytes = RenderThread.Dispatcher.Invoke(() => renderer.RetainedRenderTargetBytes); + return new RetentionRun(provider.FrameCount, peakRetainedBytes, retainedBytes); + } + + private static Scene CreateAnimatedBufferedEffectScene(int frameRate, int frameCount) + { + TimeSpan duration = TimeSpan.FromSeconds((double)frameCount / frameRate); + var width = new KeyFrameAnimation(); + width.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 24, + Easing = new LinearEasing(), + }); + width.KeyFrames.Add(new KeyFrame + { + KeyTime = duration, + Value = 174, + Easing = new LinearEasing(), + }); + + var shape = new RectShape + { + Height = { CurrentValue = 32 }, + Fill = { CurrentValue = Brushes.White }, + FilterEffect = + { + // A built-in Skia filter fuses onto the destination as a save layer and owns no + // buffer, so the retention this measures needs an effect that opens its own target. + CurrentValue = new InnerShadow + { + Sigma = { CurrentValue = new Size(4, 4) }, + Color = { CurrentValue = Colors.Black }, + }, + }, + }; + shape.Width.Animation = width; + var element = new Element + { + Start = TimeSpan.Zero, + Length = duration, + IsEnabled = true, + }; + element.AddObject(shape); + string root = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + "frame-provider-retention-" + Guid.NewGuid().ToString("N")); + var scene = new Scene(240, 120, "Retention") + { + Duration = duration, + Uri = new Uri(Path.Combine(root, "retention.scene")), + }; + element.Uri = new Uri(Path.Combine(root, "retention.belm")); + scene.Children.Add(element); + return scene; + } + + private readonly record struct RetentionRun( + long FrameCount, + long PeakRetainedBytes, + long FinalRetainedBytes); +} diff --git a/tests/Beutl.UnitTests/Editor/ObjectTemplatePreviewRendererTests.cs b/tests/Beutl.UnitTests/Editor/ObjectTemplatePreviewRendererTests.cs index 14b2f42f69..16012c4875 100644 --- a/tests/Beutl.UnitTests/Editor/ObjectTemplatePreviewRendererTests.cs +++ b/tests/Beutl.UnitTests/Editor/ObjectTemplatePreviewRendererTests.cs @@ -126,6 +126,20 @@ public async Task RenderPngAsync_ElementWithNothingVisible_HasNoPreview() Assert.That(png, Is.Null); } + // A SourceBackdrop records a symbolic full-target capture, which a target-less Measure cannot bound. + // The measurement must fall back to the authored frame rather than lose the whole preview. + [Test] + public async Task RenderPngAsync_DrawableWithFullTargetAccess_StillPreviewsItsVisibleContent() + { + var group = new DrawableGroup(); + group.Children.Add(CreateRedRect()); + group.Children.Add(new SourceBackdrop()); + + byte[]? png = await ObjectTemplatePreviewRenderer.RenderPngAsync(group); + + Assert.That(png, Is.Not.Null); + } + private static RectShape CreateRedRect() { return new RectShape diff --git a/tests/Beutl.UnitTests/Editor/SaveFrameScaleTests.cs b/tests/Beutl.UnitTests/Editor/SaveFrameScaleTests.cs index b1036254c6..c83661e383 100644 --- a/tests/Beutl.UnitTests/Editor/SaveFrameScaleTests.cs +++ b/tests/Beutl.UnitTests/Editor/SaveFrameScaleTests.cs @@ -62,8 +62,8 @@ public void FitsBufferLimit_AgainstEngineLimit(int w, int h, float scale, bool e [Test] public void FitsBufferLimit_DefaultLimit_IsTheEngineConstant() { - var atLimit = new PixelSize(RenderNodeContext.MaxBufferDimension, 1080); - var overLimit = new PixelSize(RenderNodeContext.MaxBufferDimension + 1, 1080); + var atLimit = new PixelSize(RenderScaleUtilities.MaxBufferDimension, 1080); + var overLimit = new PixelSize(RenderScaleUtilities.MaxBufferDimension + 1, 1080); Assert.That(SaveFrameScale.FitsBufferLimit(atLimit, 1f), Is.True); Assert.That(SaveFrameScale.FitsBufferLimit(overLimit, 1f), Is.False); diff --git a/tests/Beutl.UnitTests/Engine/FormattedTextDisposalTests.cs b/tests/Beutl.UnitTests/Engine/FormattedTextDisposalTests.cs index 3aba4dd35c..7a0fa46734 100644 --- a/tests/Beutl.UnitTests/Engine/FormattedTextDisposalTests.cs +++ b/tests/Beutl.UnitTests/Engine/FormattedTextDisposalTests.cs @@ -122,11 +122,11 @@ public void Dispose_DisposesOwnedGlyphPaths() FormattedText ft = CreateText("AB"); IReadOnlyList geometries = ft.ToGeometies(); - // The per-glyph SKPath lives on the SKPathGeometry the resource wraps, not in the resource's - // cached render path; capture those handles so we can assert they were released by Dispose. + // The per-glyph SKPath is owned separately from the resource's cached render path; capture those + // handles so we can assert they were released by Dispose. List glyphPaths = geometries .OfType() - .Select(r => r.GetOriginal().Path) + .Select(r => r.Path) .Where(p => p is not null) .Select(p => p!) .ToList(); @@ -168,7 +168,7 @@ public void MeasureCore_ShrinkingGlyphCount_DisposesTruncatedTrailingResources() List trailingGlyphPaths = trailingResources .OfType() - .Select(r => r.GetOriginal().Path) + .Select(r => r.Path) .Where(p => p is not null) .Select(p => p!) .ToList(); diff --git a/tests/Beutl.UnitTests/Engine/FormattedTextGeometryCacheTests.cs b/tests/Beutl.UnitTests/Engine/FormattedTextGeometryCacheTests.cs index e4100fc508..4dd34819d9 100644 --- a/tests/Beutl.UnitTests/Engine/FormattedTextGeometryCacheTests.cs +++ b/tests/Beutl.UnitTests/Engine/FormattedTextGeometryCacheTests.cs @@ -67,6 +67,30 @@ public void ReMeasure_ReusingGlyphSlot_RebuildsCachedGeometryPath() "the reused slot's cached path must match a freshly measured 'W'."); } + // Version keys the render nodes' (resource, Version) snapshots as well as the path cache, so the bump + // SetSKPath performs has to stay one per reassignment. + [Test] + public void ReMeasure_ReusingGlyphSlot_BumpsVersionExactlyOncePerReassignment() + { + using FormattedText text = CreateText("I"); + Geometry.Resource glyph = text.ToGeometies()[0]; + int before = glyph.Version; + + text.Text = "W"; + Geometry.Resource reused = text.ToGeometies()[0]; + int afterFirst = reused.Version; + + text.Text = "I"; + int afterSecond = text.ToGeometies()[0].Version; + + using (Assert.EnterMultipleScope()) + { + Assert.That(reused, Is.SameAs(glyph)); + Assert.That(afterFirst, Is.EqualTo(before + 1)); + Assert.That(afterSecond, Is.EqualTo(before + 2)); + } + } + // The stroke-path cache shares the same Version gate; invalidation must clear it too. [Test] public void ReMeasure_ReusingGlyphSlot_RebuildsCachedStrokePath() diff --git a/tests/Beutl.UnitTests/Engine/FormattedTextRasterBoundsTests.cs b/tests/Beutl.UnitTests/Engine/FormattedTextRasterBoundsTests.cs new file mode 100644 index 0000000000..d569691d0c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/FormattedTextRasterBoundsTests.cs @@ -0,0 +1,291 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Media; +using Beutl.Media.TextFormatting; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine; + +[TestFixture] +public class FormattedTextRasterBoundsTests +{ + private static readonly float[] s_sizes = [12f, 16f, 20f, 24f, 32f, 40f, 48f, 64f, 96f, 144f]; + + private static IEnumerable RasterBoundsCases() + { + foreach (float size in s_sizes) + { + yield return new TestCaseData(size, false) + .SetName($"RasterBounds_FillOnly_{size:g}_ContainsMaskWithFourSideHeadroom"); + yield return new TestCaseData(size, true) + .SetName($"RasterBounds_ThickStroke_{size:g}_ContainsMaskWithFourSideHeadroom"); + } + } + + private static FormattedText CreateText(string text, float size, Pen.Resource? pen = null) + => new() + { + Text = new StringSpan(text, 0, text.Length), + Font = FontFamily.Default, + Size = size, + Pen = pen, + }; + + [TestCaseSource(nameof(RasterBoundsCases))] + public void RasterBounds_ContainsEveryRasterizedGlyphPixelWithHeadroom( + float size, + bool useThickStroke) + { + using Pen.Resource? pen = useThickStroke ? CreateThickPen(size) : null; + using FormattedText text = CreateText("AV glyph jog", size, pen); + Rect actual = text.ActualBounds; + Rect raster = text.RasterBounds; + Assert.That(raster.IsEmpty, Is.False); + + var device = PixelRect.FromRect(raster, 1); + using var surface = SKSurface.Create( + new SKImageInfo(device.Width, device.Height, SKColorType.Rgba8888, SKAlphaType.Premul)); + SKCanvas canvas = surface.Canvas; + canvas.Clear(SKColors.Transparent); + using (var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }) + { + canvas.Save(); + canvas.Translate(-device.X, -device.Y); + if (useThickStroke) + { + canvas.DrawPath( + text.GetStrokePath() + ?? throw new InvalidOperationException("The thick-stroke fixture did not create a stroke path."), + paint); + } + else + { + canvas.DrawText(text.GetTextBlob(), 0, 0, paint); + } + + canvas.Restore(); + } + + canvas.Flush(); + using SKImage image = surface.Snapshot(); + using SKBitmap bitmap = SKBitmap.FromImage(image); + + int touchedLeft = bitmap.Width; + int touchedTop = bitmap.Height; + int touchedRight = -1; + int touchedBottom = -1; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.GetPixel(x, y).Alpha == 0) + continue; + + touchedLeft = Math.Min(touchedLeft, x); + touchedTop = Math.Min(touchedTop, y); + touchedRight = Math.Max(touchedRight, x); + touchedBottom = y; + } + } + + Assert.Multiple(() => + { + Assert.That(touchedRight, Is.GreaterThanOrEqualTo(0), + "the fixture must actually rasterize glyphs"); + Assert.That(touchedLeft, Is.GreaterThan(0), + "a mask touching column 0 means RasterBounds did not leave room left of the glyphs"); + Assert.That(touchedTop, Is.GreaterThan(0), + "a mask touching row 0 means RasterBounds did not leave room above the glyphs"); + Assert.That(touchedRight, Is.LessThan(bitmap.Width - 1), + "a mask touching the last column means RasterBounds did not leave room right of the glyphs"); + Assert.That(touchedBottom, Is.LessThan(bitmap.Height - 1), + "a mask touching the last row means RasterBounds did not leave room below the glyphs"); + Assert.That(raster.X, Is.LessThanOrEqualTo(actual.X)); + Assert.That(raster.Y, Is.LessThanOrEqualTo(actual.Y)); + Assert.That(raster.Right, Is.GreaterThanOrEqualTo(actual.Right)); + Assert.That(raster.Bottom, Is.GreaterThanOrEqualTo(actual.Bottom)); + }); + } + + private static IEnumerable ScaledRasterBoundsCases() + { + foreach (float size in s_sizes) + { + foreach (float scale in new[] { 0.25f, 0.5f, 0.75f }) + { + yield return new TestCaseData(size, scale) + .SetName($"GetRasterBounds_{size:g}_At{scale:g}_ContainsMaskWithFourSideHeadroom"); + } + } + } + + [TestCaseSource(nameof(ScaledRasterBoundsCases))] + public void GetRasterBounds_ContainsEveryRasterizedGlyphPixelAtADownscale(float size, float scale) + { + using FormattedText text = CreateText("AV glyph jog", size); + Rect raster = text.GetRasterBounds(scale); + Assert.That(raster.IsEmpty, Is.False); + + var device = PixelRect.FromRect(raster, scale); + using var surface = SKSurface.Create( + new SKImageInfo(device.Width, device.Height, SKColorType.Rgba8888, SKAlphaType.Premul)); + SKCanvas canvas = surface.Canvas; + canvas.Clear(SKColors.Transparent); + using (var paint = new SKPaint { Color = SKColors.White, IsAntialias = true }) + { + canvas.Save(); + canvas.Translate(-device.X, -device.Y); + canvas.DrawText(text.GetTextBlob(scale), 0, 0, paint); + canvas.Restore(); + } + + canvas.Flush(); + using SKImage image = surface.Snapshot(); + using SKBitmap bitmap = SKBitmap.FromImage(image); + + int touchedLeft = bitmap.Width; + int touchedTop = bitmap.Height; + int touchedRight = -1; + int touchedBottom = -1; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.GetPixel(x, y).Alpha == 0) + continue; + + touchedLeft = Math.Min(touchedLeft, x); + touchedTop = Math.Min(touchedTop, y); + touchedRight = Math.Max(touchedRight, x); + touchedBottom = y; + } + } + + Assert.Multiple(() => + { + Assert.That(touchedRight, Is.GreaterThanOrEqualTo(0), + "the fixture must actually rasterize glyphs"); + Assert.That(touchedLeft, Is.GreaterThan(0), + "a mask touching column 0 means the downscaled footprint clips the glyphs on the left"); + Assert.That(touchedTop, Is.GreaterThan(0), + "a mask touching row 0 means the downscaled footprint clips the glyphs above"); + Assert.That(touchedRight, Is.LessThan(bitmap.Width - 1), + "a mask touching the last column means the downscaled footprint clips the glyphs on the right"); + Assert.That(touchedBottom, Is.LessThan(bitmap.Height - 1), + "a mask touching the last row means the downscaled footprint clips the glyphs below"); + }); + } + + [TestCase(0.25f)] + [TestCase(0.5f)] + [TestCase(0.75f)] + [TestCase(2f)] + public void GetRasterBounds_NeverNarrowsTheUnscaledFootprint(float scale) + { + using FormattedText text = CreateText("Your model", 48f); + Rect raster = text.RasterBounds; + Rect scaled = text.GetRasterBounds(scale); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scaled.X, Is.LessThanOrEqualTo(raster.X)); + Assert.That(scaled.Y, Is.LessThanOrEqualTo(raster.Y)); + Assert.That(scaled.Right, Is.GreaterThanOrEqualTo(raster.Right)); + Assert.That(scaled.Bottom, Is.GreaterThanOrEqualTo(raster.Bottom)); + } + } + + [TestCase(1f)] + [TestCase(0f)] + [TestCase(-1f)] + [TestCase(float.NaN)] + public void GetRasterBounds_FallsBackToTheUnscaledFootprintForAScaleItCannotMeasure(float scale) + { + using FormattedText text = CreateText("Your model", 48f); + + Assert.That(text.GetRasterBounds(scale), Is.EqualTo(text.RasterBounds)); + } + + // Only the allocated footprint may widen: brush mapping and layout read the semantic bounds, and + // moving them shifts gradients and alignment. + [Test] + public void RasterBounds_ContainsActualBounds_WithoutChangingIt() + { + using FormattedText text = CreateText("Your model", 48f); + Rect actual = text.ActualBounds; + Rect raster = text.RasterBounds; + Rect expectedActual = text.GetFillPath().TightBounds.ToGraphicsRect(); + + Assert.Multiple(() => + { + Assert.That(actual, Is.EqualTo(expectedActual), + "ActualBounds must remain the semantic fill-path bounds without the raster apron."); + Assert.That(raster.X, Is.LessThanOrEqualTo(actual.X)); + Assert.That(raster.Y, Is.LessThanOrEqualTo(actual.Y)); + Assert.That(raster.Right, Is.GreaterThanOrEqualTo(actual.Right)); + Assert.That(raster.Bottom, Is.GreaterThanOrEqualTo(actual.Bottom)); + }); + } + + [Test] + public void Bounds_ExtremeNegativeSpacingNeverPublishesNegativeWidth() + { + using FormattedText text = CreateText("Spacing", 48f); + text.Spacing = -10_000; + + Rect bounds = text.Bounds; + + Assert.Multiple(() => + { + Assert.That(bounds.Width, Is.GreaterThanOrEqualTo(0)); + Assert.That(bounds.IsInvalid, Is.False); + }); + } + + // The current SkiaSharp runtime leaves SKTextBlobBuilder run storage readable after Build(), so the + // lifetime hazard this guards is not observably red before the production reorder. Repeated measurement + // still verifies that moving mask-bound calculation before Build() preserves the published footprint. + [Test] + public void RasterBounds_RemainsStableAcrossRepeatedMeasurement() + { + using FormattedText text = CreateText("Builder span lifetime", 48f); + Rect expected = text.RasterBounds; + + for (int i = 0; i < 8; i++) + { + text.Size = 49f; + _ = text.RasterBounds; + text.Size = 48f; + + Assert.That(text.RasterBounds, Is.EqualTo(expected), $"RasterBounds changed after measurement cycle {i + 1}."); + } + } + + [Test] + public void AddToSKPath_RemainsStableWhenRunStorageIsConsumedBeforeBuild() + { + using FormattedText text = CreateText("Outline", 48f); + using var first = new SKPath(); + using var second = new SKPath(); + + text.AddToSKPath(first, new Point(10, 20)); + text.AddToSKPath(second, new Point(10, 20)); + + Assert.Multiple(() => + { + Assert.That(first.IsEmpty, Is.False, "AddToSKPath must produce outline geometry."); + Assert.That(first.TightBounds.Width, Is.GreaterThan(0)); + Assert.That(first.TightBounds.Height, Is.GreaterThan(0)); + Assert.That(second.TightBounds, Is.EqualTo(first.TightBounds)); + }); + } + + private static Pen.Resource CreateThickPen(float textSize) + { + var pen = new Pen(); + pen.Brush.CurrentValue = Brushes.White; + pen.Thickness.CurrentValue = MathF.Max(4, textSize / 3); + pen.StrokeAlignment.CurrentValue = StrokeAlignment.Outside; + return pen.ToResource(CompositionContext.Default); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/AudioVisualizerDrawableTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/AudioVisualizerDrawableTests.cs index 98673e920f..4a27561f80 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/AudioVisualizerDrawableTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/AudioVisualizerDrawableTests.cs @@ -3,6 +3,7 @@ using Beutl.Graphics; using Beutl.Graphics.AudioVisualizers; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Transformation; using Beutl.Media; using Beutl.Media.Source; using Beutl.UnitTests.Engine.Graphics.Backend; @@ -227,6 +228,55 @@ private static Bitmap RenderSpectrogramWithSamples(float scale) return GoldenImageHarness.RenderAtScale(resource, new PixelSize(320, 80), scale); } + [TestCase(500f, 0.25f)] + [TestCase(250f, 0.5f)] + [TestCase(125f, 1f)] + [TestCase(50f, 2.5f)] + public void Waveform_TransformAndOutputScaleSplits_ArePixelIdentical( + float transformPercent, + float outputScale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var drawable = new AudioWaveformDrawable + { + Width = { CurrentValue = 240f }, + Height = { CurrentValue = 120f }, + Fill = { CurrentValue = new SolidColorBrush(Colors.White) }, + Shape = { CurrentValue = new MinMaxBarWaveformShape() }, + BarCount = { CurrentValue = 64 }, + WindowSeconds = { CurrentValue = 0.1f }, + }; + AttachSyntheticSource(drawable); + + drawable.Transform.CurrentValue = new ScaleTransform(transformPercent, transformPercent); + var transformedFrame = new PixelSize( + (int)(400 / outputScale), + (int)(225 / outputScale)); + using Bitmap transformed = RenderWaveformRoute(drawable, transformedFrame, outputScale); + + drawable.Transform.CurrentValue = new ScaleTransform(100f, 100f); + using Bitmap reference = RenderWaveformRoute(drawable, new PixelSize(320, 180), 1.25f); + + GoldenImageHarness.AssertByteIdentical(reference, transformed); + }); + } + + private static Bitmap RenderWaveformRoute( + AudioWaveformDrawable drawable, + PixelSize logicalFrame, + float outputScale) + { + var context = new CompositionContext(TimeSpan.FromSeconds(0.5)); + using Drawable.Resource resource = drawable.ToResource(context); + Assert.That( + ((AudioWaveformDrawable.Resource)resource).CachedSampleLength, + Is.GreaterThan(0), + "synthetic audio composed no samples — the comparison would be vacuous"); + return GoldenImageHarness.RenderAtScale(resource, logicalFrame, outputScale); + } + // Two fresh instances fed identical samples must rasterize byte-identically at density > 1, // guarding the rowFill loop-hoist against changing device-pixel geometry. [Test] diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/BackdropScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/BackdropScaleTests.cs index 5570394208..ad7e612c82 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/BackdropScaleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/BackdropScaleTests.cs @@ -72,8 +72,16 @@ public void SnapshotBackdropRenderNode_CapturedOnNestedFlushCanvas_NotDoubleScal const float w = 2f; const int dev = 200; // ceil(100 logical x w) - var snapshot = new SnapshotBackdropRenderNode(); - RenderNodeOperation[] captureOps = snapshot.Process(new RenderNodeContext([])); + using var snapshot = new SnapshotBackdropRenderNode(); + using var renderer = new RenderNodeRenderer( + snapshot, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); // 1. Capture on a flush-style canvas (SurfaceDensity = w). using RenderTarget captureTarget = RenderTarget.Create(dev, dev)!; @@ -81,10 +89,7 @@ public void SnapshotBackdropRenderNode_CapturedOnNestedFlushCanvas_NotDoubleScal { capCanvas.Clear(Colors.Black); capCanvas.DrawRectangle(new Rect(25, 25, 50, 50), Brushes.Resource.White, null); - foreach (RenderNodeOperation op in captureOps) - { - op.Render(capCanvas); - } + renderer.Render(capCanvas); } // 2. Replay on a separate density-w canvas. @@ -102,12 +107,6 @@ public void SnapshotBackdropRenderNode_CapturedOnNestedFlushCanvas_NotDoubleScal Assert.That(cx, Is.EqualTo(100.0).Within(5.0), $"backdrop double-scaled horizontally (cx={cx})"); Assert.That(cy, Is.EqualTo(100.0).Within(5.0), $"backdrop double-scaled vertically (cy={cy})"); - foreach (RenderNodeOperation op in captureOps) - { - op.Dispose(); - } - - snapshot.Dispose(); }); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GLSLShaderTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GLSLShaderTests.cs index 4d3736ac47..d70237a80b 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GLSLShaderTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GLSLShaderTests.cs @@ -1,8 +1,11 @@ using System.Runtime.InteropServices; using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; using Beutl.Media; +using Beutl.Media.Pixel; namespace Beutl.UnitTests.Engine.Graphics.Backend; @@ -32,6 +35,20 @@ void main() { } """; + private const string DiscardLeftHalfFragment = """ + #version 450 + layout(location = 0) in vec2 fragCoord; + layout(location = 0) out vec4 outColor; + layout(set = 0, binding = 0) uniform sampler2D srcTexture; + layout(push_constant) uniform PC { float dummy; } pc; + void main() { + if (fragCoord.x < 0.5) { + discard; + } + outColor = vec4(0.0, 1.0, 0.0, 1.0); + } + """; + [StructLayout(LayoutKind.Sequential)] private struct DummyPush { public float Dummy; } @@ -160,6 +177,215 @@ public void Apply_OverwritesTargetWithShaderOutput() }); } - private static CustomFilterEffectContext CreateCustomContext(EffectTargets targets) - => new CustomFilterEffectContext(targets); + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void ConsecutiveEffects_SubmitEachEffectAndWaitOnlyAtTheReadbackBoundary() + { + IGraphicsContext graphicsContext = VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var targets = new EffectTargets(); + using RenderTarget source = RenderTarget.Create(4, 4) + ?? throw new InvalidOperationException("Could not create the GLSL source target."); + using (var canvas = new ImmediateCanvas(source)) + { + canvas.Clear(Colors.Red); + } + + targets.Add(new EffectTarget(source, new Rect(0, 0, 4, 4))); + var customContext = CreateCustomContext(targets); + using var shader = GLSLShader.Create(ConstantBlueFragment); + + // Exclude setup transitions and shader creation from the measured chain. + graphicsContext.WaitIdle(); + var events = new List(); + var allocations = new List(); + Bitmap result; + using (VulkanContext.ObserveTextureAllocations(allocations.Add)) + using (VulkanCommandPool.Observe(events.Add)) + { + shader.Apply(customContext, new DummyPush()); + shader.Apply(customContext, static _ => new DummyPush()); + shader.ApplyMultiPass(customContext, 3, static (_, _) => new DummyPush()); + result = targets[0].RenderTarget!.Snapshot(); + } + using (result) + { + Assert.Multiple(() => + { + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.EqualTo(3), + "Each native effect must submit its output, while multi-pass work stays in one batch."); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.FenceWait), + Is.EqualTo(1), + "Only the CPU readback boundary may wait for the native effect chain."); + Assert.That( + allocations, + Does.Contain(TextureFormat.RGBA16Float), + "The allocation observer must see the filter destinations."); + Assert.That( + allocations, + Has.None.EqualTo(TextureFormat.Depth32Float), + "Fullscreen filter passes must not allocate unused depth textures."); + + RgbaF16 pixel = result.GetPixelSpan()[0]; + Assert.That((float)pixel.R, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)pixel.G, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)pixel.B, Is.EqualTo(1).Within(0.01f)); + Assert.That((float)pixel.A, Is.EqualTo(1).Within(0.01f)); + }); + } + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void RepeatedNativeEffectChain_AllocatesOnlyWhileWarmingTheTargetPool() + { + IGraphicsContext graphicsContext = VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget source = CreateSolidTarget(4, 4, Colors.Red); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using var shader = GLSLShader.Create(ConstantBlueFragment); + + graphicsContext.WaitIdle(); + List firstAllocations = RunPooledEffectChain(source, registry, shader); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0)); + GpuResourceReclaimQueue.FlushAndDrain(); + List secondAllocations = RunPooledEffectChain(source, registry, shader); + + Assert.Multiple(() => + { + Assert.That( + firstAllocations.Count(static format => format == TextureFormat.RGBA16Float), + Is.EqualTo(4), + "The first chain must allocate its two destinations, two ping-pong buffers, and final destination with one intra-chain reuse."); + Assert.That( + secondAllocations, + Has.None.EqualTo(TextureFormat.RGBA16Float), + "An identical warmed chain must use only retained pool slots."); + Assert.That(registry.Statistics.Creates, Is.EqualTo(4)); + Assert.That(registry.Statistics.Reuses, Is.GreaterThanOrEqualTo(4)); + }); + GpuResourceReclaimQueue.FlushAndDrain(); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void DiscardingShader_ClearsAReusedTargetBeforeRendering() + { + VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreateSolidTarget(4, 4, Colors.Red); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using var warmupShader = GLSLShader.Create(ConstantBlueFragment); + using var discardingShader = GLSLShader.Create(DiscardLeftHalfFragment); + + using (RenderTargetLeaseSession warmup = registry.BeginSession( + RenderIntent.Delivery, + source)) + using (var warmupTargets = new EffectTargets + { + new EffectTarget(source, new Rect(0, 0, 4, 4)), + }) + { + var warmupContext = CreateCustomContext(warmupTargets, warmup); + warmupShader.Apply(warmupContext, new DummyPush()); + using Bitmap completedWarmup = warmupTargets[0].RenderTarget!.Snapshot(); + } + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0)); + GpuResourceReclaimQueue.FlushAndDrain(); + + var reuseAllocations = new List(); + using RenderTargetLeaseSession reuse = registry.BeginSession( + RenderIntent.Delivery, + source); + using var targets = new EffectTargets + { + new EffectTarget(source, new Rect(0, 0, 4, 4)), + }; + var context = CreateCustomContext(targets, reuse); + using (VulkanContext.ObserveTextureAllocations(reuseAllocations.Add)) + discardingShader.Apply(context, new DummyPush()); + using Bitmap result = targets[0].RenderTarget!.Snapshot(); + + ReadOnlySpan pixels = result.GetPixelSpan(); + RgbaF16 discarded = pixels[0]; + RgbaF16 written = pixels[3]; + Assert.Multiple(() => + { + Assert.That(reuseAllocations, Is.Empty, "The discard pass must reuse the warmed slot."); + Assert.That(registry.Statistics.Reuses, Is.EqualTo(1)); + Assert.That((float)discarded.R, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)discarded.G, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)discarded.B, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)discarded.A, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)written.R, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)written.G, Is.EqualTo(1).Within(0.01f)); + Assert.That((float)written.B, Is.EqualTo(0).Within(0.01f)); + Assert.That((float)written.A, Is.EqualTo(1).Within(0.01f)); + }); + targets.Dispose(); + reuse.Dispose(); + GpuResourceReclaimQueue.FlushAndDrain(); + }); + } + + private static List RunPooledEffectChain( + RenderTarget source, + RenderTargetLeaseRegistry registry, + GLSLShader shader) + { + using RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Delivery, + source); + using var targets = new EffectTargets + { + new EffectTarget(source, new Rect(0, 0, 4, 4)), + }; + var context = CreateCustomContext(targets, session); + var allocations = new List(); + using (VulkanContext.ObserveTextureAllocations(allocations.Add)) + { + shader.Apply(context, new DummyPush()); + shader.Apply(context, static _ => new DummyPush()); + shader.ApplyMultiPass(context, 3, static (_, _) => new DummyPush()); + using Bitmap result = targets[0].RenderTarget!.Snapshot(); + } + + return allocations; + } + + private static RenderTarget CreateSolidTarget(int width, int height, Color color) + { + RenderTarget target = RenderTarget.Create(width, height) + ?? throw new InvalidOperationException("Could not create the GLSL source target."); + using (var canvas = new ImmediateCanvas(target)) + { + canvas.Clear(color); + } + + return target; + } + + private static CustomFilterEffectContext CreateCustomContext( + EffectTargets targets, + RenderTargetLeaseSession? session = null) + => new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + renderTargetLeaseSession: session); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextFactoryTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextFactoryTests.cs index 311688eb12..71adeb0320 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextFactoryTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextFactoryTests.cs @@ -5,6 +5,41 @@ namespace Beutl.UnitTests.Engine.Graphics.Backend; [NonParallelizable] public class GraphicsContextFactoryTests { + [TestCase("1", true)] + [TestCase("true", true)] + [TestCase("YES", true)] + [TestCase("on", true)] + [TestCase("0", false)] + [TestCase("false", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void IsVulkanValidationEnabled_ParsesEnvironmentSetting(string? value, bool expected) + { + bool hadPreviousSwitch = AppContext.TryGetSwitch( + GraphicsContextFactory.VulkanValidationAppContextSwitch, + out bool previousSwitch); + string? previous = Environment.GetEnvironmentVariable( + GraphicsContextFactory.VulkanValidationEnvironmentVariable); + try + { + AppContext.SetSwitch(GraphicsContextFactory.VulkanValidationAppContextSwitch, false); + Environment.SetEnvironmentVariable( + GraphicsContextFactory.VulkanValidationEnvironmentVariable, + value); + + Assert.That(GraphicsContextFactory.IsVulkanValidationEnabled(), Is.EqualTo(expected)); + } + finally + { + AppContext.SetSwitch( + GraphicsContextFactory.VulkanValidationAppContextSwitch, + hadPreviousSwitch && previousSwitch); + Environment.SetEnvironmentVariable( + GraphicsContextFactory.VulkanValidationEnvironmentVariable, + previous); + } + } + [Test] public void GetAvailableDevices_ReturnsAtLeastOne() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextResourceTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextResourceTests.cs index b95fed1161..033c5fed52 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextResourceTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/GraphicsContextResourceTests.cs @@ -1,10 +1,100 @@ using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Composite; +using Beutl.Graphics.Backend.Vulkan; namespace Beutl.UnitTests.Engine.Graphics.Backend; [NonParallelizable] public class GraphicsContextResourceTests { + [Test] + [Category("GpuPassFusionGpu")] + public void IsolatedSubmission_DoesNotConsumeTheOpenRecordingBatch() + { + IGraphicsContext context = VulkanTestEnvironment.EnsureAvailable(); + VulkanContext vulkanContext = context switch + { + VulkanContext vulkan => vulkan, + CompositeContext composite => composite.Vulkan, + _ => throw new InvalidOperationException("The shared graphics context has no Vulkan backend."), + }; + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + vulkanContext.WaitIdle(); + int releaseCount = 0; + var events = new List(); + + using (VulkanCommandPool.Observe(events.Add)) + { + vulkanContext.RecordCommands(static _ => { }); + vulkanContext.DeferRelease(() => releaseCount++); + + vulkanContext.SubmitIsolatedCommands(static _ => { }); + + Assert.Multiple(() => + { + Assert.That(releaseCount, Is.Zero, + "The isolated submission must not retire resources owned by the open batch."); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.EqualTo(1)); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.FenceWait), + Is.EqualTo(1)); + }); + + vulkanContext.FlushCommands(waitForCompletion: true); + } + + Assert.Multiple(() => + { + Assert.That(releaseCount, Is.EqualTo(1)); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.EqualTo(2)); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.FenceWait), + Is.EqualTo(2)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void DeferredRelease_CanReenterCommandPoolWithoutDoubleCompletion() + { + IGraphicsContext context = VulkanTestEnvironment.EnsureAvailable(); + VulkanContext vulkanContext = context switch + { + VulkanContext vulkan => vulkan, + CompositeContext composite => composite.Vulkan, + _ => throw new InvalidOperationException("The shared graphics context has no Vulkan backend."), + }; + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + vulkanContext.WaitIdle(); + int outerReleaseCount = 0; + int nestedReleaseCount = 0; + + vulkanContext.RecordCommands(static _ => { }); + vulkanContext.DeferRelease(() => + { + outerReleaseCount++; + vulkanContext.DeferRelease(() => nestedReleaseCount++); + }); + + vulkanContext.FlushCommands(waitForCompletion: true); + + Assert.Multiple(() => + { + Assert.That(outerReleaseCount, Is.EqualTo(1)); + Assert.That(nestedReleaseCount, Is.EqualTo(1)); + }); + }); + } + [Test] public void CreateTexture2D_RGBA8_HasMatchingDimensions() { @@ -137,6 +227,70 @@ public void CompileShader_TrivialFragment_ProducesSpirv() }); } + [Test] + [Category("GpuPassFusionGpu")] + public void ColorOnlyRenderPass_UsesMatchingFramebufferAndPipelineState() + { + var ctx = VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + const string vertexSource = """ + #version 450 + void main() { + vec2 positions[3] = vec2[]( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0)); + gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0); + } + """; + const string fragmentSource = """ + #version 450 + layout(location = 0) out vec4 outColor; + void main() { outColor = vec4(0.0, 0.0, 1.0, 1.0); } + """; + + IShaderCompiler compiler = ctx.CreateShaderCompiler(); + byte[] vertexSpirv = compiler.CompileToSpirv(vertexSource, ShaderStage.Vertex); + byte[] fragmentSpirv = compiler.CompileToSpirv(fragmentSource, ShaderStage.Fragment); + + using ITexture2D color = ctx.CreateTexture2D(8, 8, TextureFormat.RGBA8Unorm); + using IRenderPass3D renderPass = ctx.CreateRenderPass3D( + [TextureFormat.RGBA8Unorm], + depthFormat: null); + using IFramebuffer3D framebuffer = ctx.CreateFramebuffer3D( + renderPass, + [color], + depthTexture: null); + + Assert.That( + () => ctx.CreatePipeline3D( + renderPass, + vertexSpirv, + fragmentSpirv, + [], + VertexInputDescription.Empty), + Throws.ArgumentException.With.Message.Contains("depth attachment")); + + using IPipeline3D pipeline = ctx.CreatePipeline3D( + renderPass, + vertexSpirv, + fragmentSpirv, + [], + VertexInputDescription.Empty, + PipelineOptions.Fullscreen); + + renderPass.Begin(framebuffer, [default]); + renderPass.BindPipeline(pipeline); + renderPass.Draw(3); + renderPass.End(); + ctx.WaitIdle(); + + Assert.That(framebuffer.DepthTexture, Is.Null); + }); + } + [Test] public void CreateSampler_ReturnsSamplerInstance() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/ImmediateCanvasDensityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/ImmediateCanvasDensityTests.cs index 9ca330b8c1..e82b85645f 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/ImmediateCanvasDensityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/ImmediateCanvasDensityTests.cs @@ -12,6 +12,15 @@ namespace Beutl.UnitTests.Engine.Graphics.Backend; [TestFixture] public class ImmediateCanvasDensityTests { + [Test] + public void Constructor_NullRenderTarget_ThrowsArgumentNullException() + { + ArgumentNullException? exception = Assert.Throws( + () => _ = new ImmediateCanvas(null!)); + + Assert.That(exception!.ParamName, Is.EqualTo("renderTarget")); + } + [Test] public void Density1_Construction_IsTrueNoOp() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/PixelSortEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/PixelSortEffectTests.cs index 9c322d92bf..85077d722c 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/PixelSortEffectTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/PixelSortEffectTests.cs @@ -41,7 +41,11 @@ public void ApplyTo_HorizontalLuminance_ReplacesTargetWithSortedOutput() effect.ApplyTo(feCtx, resource); using var builder = new SKImageFilterBuilder(); - using var activator = new FilterEffectActivator(targets, builder); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); activator.Apply(feCtx); activator.Flush(false); @@ -79,23 +83,26 @@ public void ApplyTo_VerticalSaturation_DoesNotThrow() effect.ApplyTo(feCtx, resource); using var builder = new SKImageFilterBuilder(); - using var activator = new FilterEffectActivator(targets, builder); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); Assert.DoesNotThrow(() => activator.Apply(feCtx)); Assert.DoesNotThrow(() => activator.Flush(false)); }); } - [TestCase(float.PositiveInfinity, ExpectedResult = true)] - [TestCase(2f, ExpectedResult = false)] - [TestCase(1f, ExpectedResult = false)] - public bool ShouldRethrowPassFailure_rethrows_shader_failures_only_on_delivery(float maxWorkingScale) - => PixelSortEffect.ShouldRethrowPassFailure(new InvalidOperationException("pass failed"), maxWorkingScale); + [TestCase(RenderIntent.Delivery, ExpectedResult = true)] + [TestCase(RenderIntent.Preview, ExpectedResult = false)] + public bool ShouldRethrowPassFailure_rethrows_shader_failures_only_on_delivery(RenderIntent intent) + => PixelSortEffect.ShouldRethrowPassFailure(new InvalidOperationException("pass failed"), intent); [Test] public void ShouldRethrowPassFailure_always_rethrows_cancellation() { Assert.That( - PixelSortEffect.ShouldRethrowPassFailure(new OperationCanceledException(), 2f), + PixelSortEffect.ShouldRethrowPassFailure(new OperationCanceledException(), RenderIntent.Preview), Is.True); } @@ -105,9 +112,58 @@ public void ThrowIfDeliveryAllocationFailure_fails_delivery_and_degrades_preview Assert.Multiple(() => { Assert.Throws( - () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(float.PositiveInfinity, 0)); + () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(RenderIntent.Delivery, 0)); Assert.DoesNotThrow( - () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(2f, 0)); + () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(RenderIntent.Preview, 0)); + }); + } + + // The working-scale ceiling used to stand in for the intent; the two are now independent inputs. + [TestCase(RenderIntent.Delivery, float.PositiveInfinity)] + [TestCase(RenderIntent.Delivery, 2f)] + public void DeliveryIntent_DecidesFailFast_IndependentlyOfTheWorkingScaleCeiling( + RenderIntent intent, float maxWorkingScale) + { + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + intent, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); + + Assert.Multiple(() => + { + Assert.Throws( + () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(context.Intent, 0)); + Assert.That( + PixelSortEffect.ShouldRethrowPassFailure(new InvalidOperationException(), context.Intent), + Is.True); + }); + } + + [TestCase(RenderIntent.Preview, float.PositiveInfinity)] + [TestCase(RenderIntent.Preview, 2f)] + public void PreviewIntent_DecidesDegradation_IndependentlyOfTheWorkingScaleCeiling( + RenderIntent intent, float maxWorkingScale) + { + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + intent, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); + + Assert.Multiple(() => + { + Assert.DoesNotThrow( + () => PixelSortEffect.ThrowIfDeliveryAllocationFailure(context.Intent, 0)); + Assert.That( + PixelSortEffect.ShouldRethrowPassFailure(new InvalidOperationException(), context.Intent), + Is.False); }); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/RenderTargetVulkanTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/RenderTargetVulkanTests.cs index 08ee885594..5d0f9b7999 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/RenderTargetVulkanTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/RenderTargetVulkanTests.cs @@ -5,6 +5,28 @@ namespace Beutl.UnitTests.Engine.Graphics.Backend; [NonParallelizable] public class RenderTargetVulkanTests { + /// + /// A pooled target is cleared before it is handed out, and the caller that receives it asks whether it + /// is already blank before clearing it itself. The clear goes through Skia, which the backend cannot + /// observe, so without the backend being told the answer stayed "unknown" and every reused + /// intermediate paid for a second GPU clear and submission. + /// + [Test] + public void ClearToTransparent_LeavesTheTargetReportingTransparentContents() + { + VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget? target = RenderTarget.Create(16, 16); + Assert.That(target, Is.Not.Null); + + target!.ClearToTransparent(); + + Assert.That(target.HasTransparentContents, Is.True); + }); + } + [Test] public void Create_OnRenderThread_UsesGraphicsContext() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/SkiaVulkanImageInitializationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/SkiaVulkanImageInitializationTests.cs new file mode 100644 index 0000000000..e2d288834e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/SkiaVulkanImageInitializationTests.cs @@ -0,0 +1,165 @@ +using System.Reflection; + +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Composite; +using Beutl.Graphics.Backend.Vulkan; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.Pixel; + +using Silk.NET.Vulkan; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Backend; + +public class SkiaVulkanImageInitializationTests +{ + [Test] + [Category("GpuPassFusionGpu")] + public void BackendClear_CompletesBeforeSkiaPartiallyOverwritesTarget() + { + IGraphicsContext context = VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + context.WaitIdle(); + using RenderTarget target = RenderTarget.Create(4, 4) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + var events = new List(); + + using (VulkanCommandPool.Observe(events.Add)) + { + target.BeginDraw(); + using var paint = new SKPaint { Color = SKColors.Red }; + target.Value.Canvas.DrawRect(SKRect.Create(0, 0, 2, 2), paint); + } + + using Bitmap snapshot = target.Snapshot(); + RgbaF16 untouched = snapshot.GetPixelSpan()[15]; + Assert.Multiple(() => + { + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.EqualTo(1), + "The backend clear must be submitted before Skia records a partial overwrite."); + // Queue ordering carries the clear only where Skia submits to the same Vulkan queue. + // On the composite backend Skia draws through Metal, which shares no semaphore with + // Beutl's Vulkan submissions, so the hand-off has to complete on the CPU instead. + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.FenceWait), + context.Backend == GraphicsBackend.Vulkan ? Is.Zero : Is.EqualTo(1), + "The backend clear must reach Skia by queue order, or by a completion wait when the " + + "two APIs share no queue."); + Assert.That(untouched, Is.EqualTo(default(RgbaF16))); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void NewRenderTarget_SubmitsInitializationBeforeUntouchedSnapshot() + { + IGraphicsContext context = VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + context.WaitIdle(); + using RenderTarget target = RenderTarget.Create(4, 4) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + var events = new List(); + + Bitmap snapshot; + using (VulkanCommandPool.Observe(events.Add)) + snapshot = target.Snapshot(); + + using (snapshot) + { + Assert.Multiple(() => + { + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.Submission), + Is.EqualTo(1), + "The recorded allocation clear must be submitted before an untouched snapshot."); + Assert.That( + events.Count(static item => item == VulkanCommandPoolEvent.FenceWait), + Is.EqualTo(1)); + Assert.That( + snapshot.GetPixelSpan().ToArray(), + Is.All.EqualTo(default(RgbaF16))); + }); + } + }); + } + + [Test] + [NonParallelizable] + public void Context_InterceptsSkiaImageAllocationFunctions() + { + VulkanTestEnvironment.EnsureAvailable(); + + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + // The shared context is a CompositeContext wherever Skia runs on another API, so the + // Vulkan context that owns the hook has to be reached through it. + IGraphicsContext shared = GraphicsContextFactory.GetOrCreateShared()!; + VulkanContext context = shared as VulkanContext + ?? ((CompositeContext)shared).Vulkan; + MethodInfo getProcedureAddress = typeof(VulkanContext).GetMethod( + "GetVulkanProcAddress", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + foreach (string name in new[] { "vkCreateImage", "vkBindImageMemory", "vkDestroyImage" }) + { + IntPtr native = context.Vk.GetDeviceProcAddr(context.Device, name); + var intercepted = (IntPtr)getProcedureAddress.Invoke( + context, + [name, context.Instance.Handle, context.Device.Handle])!; + Assert.That(intercepted, Is.Not.EqualTo(native), $"{name} must pass through the initializer."); + } + }); + } + + [Test] + public void PrepareCreateInfo_MakesColorAttachmentsClearable() + { + var createInfo = new ImageCreateInfo + { + InitialLayout = ImageLayout.Undefined, + Usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.SampledBit, + MipLevels = 4, + ArrayLayers = 3, + }; + + ImageCreateInfo prepared = VulkanContext.PrepareSkiaImageCreateInfo(createInfo); + + Assert.That((prepared.Usage & ImageUsageFlags.TransferDstBit) != 0, Is.True); + Assert.That(VulkanContext.RequiresTransparentInitialization(prepared), Is.True); + ImageSubresourceRange range = VulkanContext.CreateInitializationRange(prepared); + Assert.Multiple(() => + { + Assert.That(range.AspectMask, Is.EqualTo(ImageAspectFlags.ColorBit)); + Assert.That(range.BaseMipLevel, Is.Zero); + Assert.That(range.LevelCount, Is.EqualTo(4)); + Assert.That(range.BaseArrayLayer, Is.Zero); + Assert.That(range.LayerCount, Is.EqualTo(3)); + }); + } + + [Test] + public void PrepareCreateInfo_DoesNotChangeNonColorImages() + { + var createInfo = new ImageCreateInfo + { + InitialLayout = ImageLayout.Undefined, + Usage = ImageUsageFlags.DepthStencilAttachmentBit, + MipLevels = 1, + ArrayLayers = 1, + }; + + ImageCreateInfo prepared = VulkanContext.PrepareSkiaImageCreateInfo(createInfo); + + Assert.That(prepared.Usage, Is.EqualTo(createInfo.Usage)); + Assert.That(VulkanContext.RequiresTransparentInitialization(prepared), Is.False); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/SpecializationConstantTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/SpecializationConstantTests.cs new file mode 100644 index 0000000000..c3eb96c9ed --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/SpecializationConstantTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Immutable; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; + +namespace Beutl.UnitTests.Engine.Graphics.Backend; + +[TestFixture] +public sealed class SpecializationConstantTests +{ + /// + /// A 64-bit specialization value needs the device's shaderInt64 or shaderFloat64 feature enabled, and + /// which of the two depends on the declared scalar type - which the stored bits alone cannot tell apart. + /// + [TestCase(true, false, TestName = "SixtyFourBitClassification_Int64NeedsTheIntegerFeature")] + [TestCase(false, true, TestName = "SixtyFourBitClassification_Float64NeedsTheFloatFeature")] + public void SixtyFourBitConstants_NameTheFeatureTheyNeed(bool integer, bool floating) + { + SpecializationConstant constant = integer + ? SpecializationConstant.Create(0, 1L, ShaderStage.Fragment) + : SpecializationConstant.Create(0, 1d, ShaderStage.Fragment); + + Assert.Multiple(() => + { + Assert.That(constant.SizeInBytes, Is.EqualTo(8)); + Assert.That(constant.RequiresShaderInt64, Is.EqualTo(integer)); + Assert.That(constant.RequiresShaderFloat64, Is.EqualTo(floating)); + }); + } + + [Test] + public void ThirtyTwoBitConstants_NeedNoSixtyFourBitFeature() + { + SpecializationConstant[] constants = + [ + SpecializationConstant.Create(0, true, ShaderStage.Fragment), + SpecializationConstant.Create(1, -3, ShaderStage.Fragment), + SpecializationConstant.Create(2, 3u, ShaderStage.Fragment), + SpecializationConstant.Create(3, 1.5f, ShaderStage.Fragment), + ]; + + Assert.That(constants.Any(static item => item.RequiresShaderInt64 || item.RequiresShaderFloat64), Is.False); + } + + [Test] + public void AnUnsignedSixtyFourBitConstant_NeedsTheIntegerFeature() + { + SpecializationConstant constant = SpecializationConstant.Create(0, ulong.MaxValue, ShaderStage.Vertex); + + Assert.Multiple(() => + { + Assert.That(constant.RequiresShaderInt64, Is.True); + Assert.That(constant.RequiresShaderFloat64, Is.False); + }); + } + + [Test] + public void ValidateSpecializationConstants_NormalizesDefaultToEmpty() + { + ImmutableArray result = VulkanContext.ValidateSpecializationConstants( + default, + "options"); + + Assert.That(result, Is.Empty); + Assert.That(result.IsDefault, Is.False); + } + + [Test] + public void ValidateSpecializationConstants_AllowsSameIdInDisjointStages() + { + ImmutableArray constants = + [ + SpecializationConstant.Create(0, 1, ShaderStage.Vertex), + SpecializationConstant.Create(0, 2, ShaderStage.Fragment), + ]; + + ImmutableArray result = VulkanContext.ValidateSpecializationConstants( + constants, + "options"); + + Assert.That(result, Is.EqualTo(constants)); + } + + [Test] + public void ValidateSpecializationConstants_RejectsOverlappingStageAndId() + { + ImmutableArray constants = + [ + SpecializationConstant.Create(7, 1, ShaderStage.Vertex | ShaderStage.Fragment), + SpecializationConstant.Create(7, 2, ShaderStage.Fragment), + ]; + + Assert.That( + () => VulkanContext.ValidateSpecializationConstants(constants, "options"), + Throws.ArgumentException.With.Property("ParamName").EqualTo("options")); + } + + [TestCase(ShaderStage.None)] + [TestCase(ShaderStage.Compute)] + [TestCase(ShaderStage.AllGraphics)] + public void ValidateSpecializationConstants_RejectsUnsupportedStages(ShaderStage stages) + { + ImmutableArray constants = + [SpecializationConstant.Create(0, 1, stages)]; + + Assert.That( + () => VulkanContext.ValidateSpecializationConstants(constants, "options"), + Throws.ArgumentException.With.Property("ParamName").EqualTo("options")); + } + + [Test] + public void ValidateSpecializationConstants_RejectsDefaultDescriptor() + { + ImmutableArray constants = [default(SpecializationConstant)]; + + Assert.That( + () => VulkanContext.ValidateSpecializationConstants(constants, "options"), + Throws.ArgumentException.With.Property("ParamName").EqualTo("options")); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanTestEnvironment.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanTestEnvironment.cs index 18ce88af15..6cf5adfdf8 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanTestEnvironment.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanTestEnvironment.cs @@ -1,4 +1,5 @@ using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; using Beutl.Graphics.Rendering; namespace Beutl.UnitTests.Engine.Graphics.Backend; @@ -92,8 +93,43 @@ public static void EnsureInitialized() } public static T InvokeOnRenderThread(Func func) - => RenderThread.Dispatcher.Invoke(func); + { + int before = VulkanValidationErrorLog.Shared.Count; + T result = RenderThread.Dispatcher.CheckAccess() + ? func() + : RenderThread.Dispatcher.InvokeAsync(func).GetAwaiter().GetResult(); + FailOnValidationErrorsSince(before); + return result; + } public static void InvokeOnRenderThread(Action action) - => RenderThread.Dispatcher.Invoke(action); + { + int before = VulkanValidationErrorLog.Shared.Count; + if (RenderThread.Dispatcher.CheckAccess()) + { + action(); + } + else + { + RenderThread.Dispatcher.InvokeAsync(action).GetAwaiter().GetResult(); + } + + FailOnValidationErrorsSince(before); + } + + /// + /// Fails the current test when the work just run reported a Vulkan validation error. + /// + /// + /// Nothing is recorded unless the job enabled validation, so this is inert on an ordinary run. The + /// layer reports some errors at queue submission rather than at the offending call, so an error can + /// land on a later invocation than the one that caused it; it still fails the run, which is what the + /// gate is for. + /// + private static void FailOnValidationErrorsSince(int previousCount) + { + string report = VulkanValidationErrorLog.Shared.DescribeSince(previousCount); + if (report.Length != 0) + Assert.Fail(report); + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanValidationGateTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanValidationGateTests.cs new file mode 100644 index 0000000000..c8de41dd23 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Backend/VulkanValidationGateTests.cs @@ -0,0 +1,145 @@ +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; + +namespace Beutl.UnitTests.Engine.Graphics.Backend; + +/// +/// Covers the record the Vulkan validation gate reads, and confirms that a job asking for validation +/// actually got it. +/// +/// +/// The gate itself lives in VulkanTestEnvironment.InvokeOnRenderThread and its Graphics3D twin: each +/// GPU invocation compares the log's count before and after, so a validation error fails the test that +/// reported it rather than being written to a log nobody reads. +/// +[TestFixture] +public sealed class VulkanValidationGateTests +{ + [Test] + public void ARecordedError_IsCountedAndDescribedAgainstAnEarlierSnapshot() + { + var log = new VulkanValidationErrorLog(); + int before = log.Count; + + log.Record("VUID-vkCmdBeginRenderPass-None: a render pass is already recording"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(log.Count, Is.EqualTo(before + 1)); + Assert.That(log.DescribeSince(before), Does.Contain("VUID-vkCmdBeginRenderPass-None")); + Assert.That(log.DescribeSince(log.Count), Is.Empty, "nothing arrived after the later snapshot"); + } + } + + [Test] + public void AnEmptyMessage_IsStillCounted() + { + var log = new VulkanValidationErrorLog(); + + log.Record(null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(log.Count, Is.EqualTo(1)); + Assert.That(log.DescribeSince(0), Does.Contain("no message text")); + } + } + + /// + /// One mistake inside a draw loop reports on every iteration, so the retained text is bounded. The count + /// is not: a gate that under-reported how much went wrong would be worse than one that quotes less. + /// + [Test] + public void AFloodOfErrors_KeepsAnExactCountAndBoundedText() + { + var log = new VulkanValidationErrorLog(); + + for (int index = 0; index < 200; index++) + log.Record($"error-{index}"); + + string description = log.DescribeSince(0); + + using (Assert.EnterMultipleScope()) + { + Assert.That(log.Count, Is.EqualTo(200)); + Assert.That(log.Messages, Has.Count.LessThan(200)); + Assert.That(log.Messages, Has.Count.GreaterThan(0)); + Assert.That(description, Does.Contain("200 Vulkan validation error(s)")); + Assert.That(description, Does.Contain("error-199"), "the newest error must be quoted"); + Assert.That(description, Does.Not.Contain("error-0"), "the oldest is dropped, not the newest"); + } + } + + [Test] + public void TheDescriptionQuotesOnlyWhatArrivedAfterTheSnapshot() + { + var log = new VulkanValidationErrorLog(); + log.Record("before-the-snapshot"); + int snapshot = log.Count; + log.Record("after-the-snapshot"); + + string description = log.DescribeSince(snapshot); + + using (Assert.EnterMultipleScope()) + { + Assert.That(description, Does.Contain("after-the-snapshot")); + Assert.That(description, Does.Not.Contain("before-the-snapshot")); + } + } + + /// + /// The record is written inside the invocation, which is exactly where a real one would arrive, so this + /// exercises the wiring the CI job depends on rather than the log in isolation. It leaves the shared + /// count higher; every other gate compares against a snapshot it takes later, so nothing else observes + /// it. + /// + [Test] + public void AValidationErrorReportedDuringAnInvocation_FailsThatInvocation() + { + Assert.That( + () => VulkanTestEnvironment.InvokeOnRenderThread( + () => VulkanValidationErrorLog.Shared.Record("VUID-synthetic-gate-probe: undefined behaviour")), + Throws.InstanceOf() + .With.Message.Contains("VUID-synthetic-gate-probe")); + } + + [Test] + public void AnInvocationThatReportsNothing_Passes() + { + Assert.That(() => VulkanTestEnvironment.InvokeOnRenderThread(static () => { }), Throws.Nothing); + } + + /// + /// Without this a job could set the environment variable, fail to provide the layer, and take the + /// silent-skip path — leaving a gate that never observes anything and a green run that proves nothing. + /// + [Test] + public void WhenTheJobAsksForValidation_TheInstanceEnabledIt() + { + if (!GraphicsContextFactory.IsVulkanValidationEnabled()) + { + Assert.Ignore( + "Validation was not requested (BEUTL_VULKAN_VALIDATION is unset), so the gate is idle and " + + "there is nothing to confirm."); + } + + // Deliberately not EnsureAvailable(): that reports an unusable Vulkan as a skip, which is the very + // outcome this test exists to reject once validation has been asked for. + VulkanTestEnvironment.EnsureInitialized(); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + VulkanTestEnvironment.IsAvailable, + Is.True, + "Validation was requested but the Vulkan context could not be created, so every GPU test " + + "would skip and the gate would observe nothing: " + + (VulkanTestEnvironment.UnavailableReason ?? "(no reason recorded)")); + Assert.That( + GraphicsContextFactory.VulkanInstance?.EnableValidation, + Is.True, + "BEUTL_VULKAN_VALIDATION is set, so the instance must carry the validation layer and its " + + "debug messenger; otherwise nothing reports to the gate."); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/DrawableResourceRenderTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/DrawableResourceRenderTests.cs new file mode 100644 index 0000000000..43852235b3 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/DrawableResourceRenderTests.cs @@ -0,0 +1,46 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics; + +/// +/// Rendering a Drawable.Resource dispatches through +/// drawable.GetOriginal().Render(context, drawable), so a drawable — unlike a geometry — is still +/// authored on its engine object rather than on its resource. +/// +[TestFixture] +public sealed class DrawableResourceRenderTests +{ + [Test] + public void RenderingAnAttachedDrawableResource_RecordsItsFragment() + { + var shape = new RectShape + { + Width = { CurrentValue = 40 }, + Height = { CurrentValue = 30 }, + Fill = { CurrentValue = Brushes.White }, + }; + using Drawable.Resource attached = shape.ToResource(CompositionContext.Default); + using var node = new DrawableRenderNode(attached); + using (var context = new GraphicsContext2D(node, new Size(64, 64))) + { + attached.GetOriginal()!.Render(context, attached); + } + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.That(rasterization.Bitmap, Is.Not.Null); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/CurrentPixelFilterEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/CurrentPixelFilterEffectTests.cs new file mode 100644 index 0000000000..e08cb3199f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/CurrentPixelFilterEffectTests.cs @@ -0,0 +1,602 @@ +using System.Numerics; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +public sealed class CurrentPixelFilterEffectTests +{ + private static readonly Rect s_bounds = new(3, 5, 16, 9); + + [Test] + public void MigratedEffects_RecordCurrentPixelStagesWithPreservedUniforms() + { + var invert = new Invert(); + invert.Amount.CurrentValue = 25; + invert.ExcludeAlphaChannel.CurrentValue = false; + ShaderDescription invertShader = Record(invert); + AssertFloatUniform(invertShader, "amount", 0.25f); + AssertIntegerUniform(invertShader, "excludeAlpha", 0); + + var gamma = new Gamma(); + gamma.Amount.CurrentValue = 250; + gamma.Strength.CurrentValue = 40; + ShaderDescription gammaShader = Record(gamma); + AssertFloatUniform(gammaShader, "gamma", 2.5f); + AssertFloatUniform(gammaShader, "strength", 0.4f); + + var threshold = new Threshold(); + threshold.Value.CurrentValue = 33; + threshold.Smoothness.CurrentValue = 7; + threshold.Strength.CurrentValue = 60; + ShaderDescription thresholdShader = Record(threshold); + AssertFloatUniform(thresholdShader, "threshold", 0.33f); + AssertFloatUniform(thresholdShader, "smoothness", 0.07f); + AssertFloatUniform(thresholdShader, "strength", 0.6f); + + var negaposi = new Negaposi(); + negaposi.Red.CurrentValue = 255; + negaposi.Green.CurrentValue = 128; + negaposi.Blue.CurrentValue = 0; + negaposi.Strength.CurrentValue = 75; + ShaderDescription negaposiShader = Record(negaposi); + AssertFloatUniform( + negaposiShader, + "negaColor", + Color.SrgbToLinear(1), + Color.SrgbToLinear(128 / 255f), + Color.SrgbToLinear(0)); + AssertFloatUniform(negaposiShader, "strength", 0.75f); + + var colorKey = new ColorKey(); + colorKey.Color.CurrentValue = new Color(128, 64, 128, 255); + colorKey.Range.CurrentValue = 20; + colorKey.Boundary.CurrentValue = 3; + ShaderDescription colorKeyShader = Record(colorKey); + Vector4 linearKeyColor = colorKey.Color.CurrentValue.ToLinear(); + AssertFloatUniform( + colorKeyShader, + "keyColor", + new Vector3(linearKeyColor.X, linearKeyColor.Y, linearKeyColor.Z)); + AssertFloatUniform(colorKeyShader, "range", 0.2f); + AssertFloatUniform(colorKeyShader, "boundary", 0.03f); + + var chromaKey = new ChromaKey(); + chromaKey.Color.CurrentValue = new Color(128, 64, 128, 255); + chromaKey.HueRange.CurrentValue = 90; + chromaKey.SaturationRange.CurrentValue = 25; + chromaKey.Boundary.CurrentValue = 4; + ShaderDescription chromaKeyShader = Record(chromaKey); + AssertFloatUniform( + chromaKeyShader, + "keyColor", + new Vector3(64 / 255f, 128 / 255f, 1)); + AssertFloatUniform(chromaKeyShader, "hueRange", 0.25f); + AssertFloatUniform(chromaKeyShader, "saturationRange", 0.25f); + AssertFloatUniform(chromaKeyShader, "boundary", 0.04f); + + var grading = new ColorGrading(); + grading.Exposure.CurrentValue = 1.5f; + grading.Contrast.CurrentValue = 25; + grading.ContrastPivot.CurrentValue = 0.25f; + grading.Saturation.CurrentValue = 15; + grading.Vibrance.CurrentValue = -20; + grading.Hue.CurrentValue = 45; + grading.Temperature.CurrentValue = 30; + grading.Tint.CurrentValue = -35; + grading.LowRange.CurrentValue = 80; + grading.HighRange.CurrentValue = 20; + grading.Shadows.CurrentValue = new GradingColor(0.1f, 0.2f, 0.3f); + grading.Midtones.CurrentValue = new GradingColor(0.4f, 0.5f, 0.6f); + grading.Highlights.CurrentValue = new GradingColor(0.7f, 0.8f, 0.9f); + grading.Lift.CurrentValue = new GradingColor(-0.1f, 0, 0.1f); + grading.Gamma.CurrentValue = new GradingColor(-1, 0.5f, 2); + grading.Gain.CurrentValue = new GradingColor(-1, 0.5f, 2); + grading.Offset.CurrentValue = new GradingColor(-0.2f, 0, 0.2f); + ShaderDescription gradingShader = Record(grading); + AssertFloatUniform(gradingShader, "exposure", 1.5f); + AssertFloatUniform(gradingShader, "contrast", 0.25f); + AssertFloatUniform(gradingShader, "contrastPivot", 0.25f); + AssertFloatUniform(gradingShader, "saturation", 0.15f); + AssertFloatUniform(gradingShader, "vibrance", -0.2f); + AssertFloatUniform(gradingShader, "hue", 45); + AssertFloatUniform(gradingShader, "temperature", 0.3f); + AssertFloatUniform(gradingShader, "tint", -0.35f); + AssertFloatUniform(gradingShader, "lowRange", 0.2f); + AssertFloatUniform(gradingShader, "highRange", 0.8f); + AssertFloatUniform(gradingShader, "shadows", 0.1f, 0.2f, 0.3f); + AssertFloatUniform(gradingShader, "midtones", 0.4f, 0.5f, 0.6f); + AssertFloatUniform(gradingShader, "highlights", 0.7f, 0.8f, 0.9f); + AssertFloatUniform(gradingShader, "lift", -0.1f, 0, 0.1f); + AssertFloatUniform(gradingShader, "gamma", 0.001f, 0.5f, 2); + AssertFloatUniform(gradingShader, "gain", 0, 0.5f, 2); + AssertFloatUniform(gradingShader, "offset", -0.2f, 0, 0.2f); + } + + [TestCaseSource(nameof(ConstantShaderEffects))] + public void ConstantShaderEffects_ReuseParsedSource(Func factory) + { + SkslSource first = Record(factory()).Source; + SkslSource second = Record(factory()).Source; + + Assert.That(second, Is.SameAs(first)); + } + + [Test] + public void KeyColorAlpha_DoesNotChangeTheKeyColorUniform() + { + var firstColorKey = new ColorKey + { + Color = { CurrentValue = new Color(32, 64, 128, 255) }, + }; + var secondColorKey = new ColorKey + { + Color = { CurrentValue = new Color(224, 64, 128, 255) }, + }; + var firstChromaKey = new ChromaKey + { + Color = { CurrentValue = new Color(32, 64, 128, 255) }, + }; + var secondChromaKey = new ChromaKey + { + Color = { CurrentValue = new Color(224, 64, 128, 255) }, + }; + + Assert.Multiple(() => + { + Assert.That( + KeyColorUniform(Record(firstColorKey)), + Is.EqualTo(KeyColorUniform(Record(secondColorKey)))); + Assert.That( + KeyColorUniform(Record(firstChromaKey)), + Is.EqualTo(KeyColorUniform(Record(secondChromaKey)))); + }); + } + + [Test] + public void MigratedEffects_CanCompileInFusedPrograms() + { + ShaderDescription[] descriptions = + [ + Record(new Invert()), + Record(new Gamma()), + Record(new Threshold()), + Record(new Negaposi()), + Record(new ColorKey()), + Record(new ChromaKey()), + Record(new ColorGrading()), + ]; + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + descriptions.Select(static description => new SkslSnippetStage(description)).ToArray(), + SkslBackendBudgetResolver.Portable); + + Assert.Multiple(() => + { + Assert.That(programs.Sum(static program => program.StageCount), Is.EqualTo(descriptions.Length)); + Assert.That(programs, Has.Some.Matches(static program => program.StageCount > 1)); + Assert.That(programs, Has.All.Matches( + static program => !program.RequiresStandaloneExecution)); + }); + + foreach (SkslMergedProgram program in programs) + { + using SKRuntimeEffect? effect = SKRuntimeEffect.CreateShader(program.Source, out string? error); + Assert.Multiple(() => + { + Assert.That(error, Is.Null); + Assert.That(effect, Is.Not.Null); + }); + } + } + + [Test] + public void MigratedIdentityConfigurations_PreservePremultipliedLinearPixels() + { + var invert = new Invert(); + invert.Amount.CurrentValue = 0; + + var gamma = new Gamma(); + gamma.Strength.CurrentValue = 0; + + var negaposi = new Negaposi(); + negaposi.Strength.CurrentValue = 0; + + var colorKey = new ColorKey(); + colorKey.Color.CurrentValue = Colors.Black; + colorKey.Range.CurrentValue = 5; + colorKey.Boundary.CurrentValue = 2; + + var chromaKey = new ChromaKey(); + chromaKey.Color.CurrentValue = Colors.Lime; + chromaKey.HueRange.CurrentValue = 1; + chromaKey.SaturationRange.CurrentValue = 1; + chromaKey.Boundary.CurrentValue = 1; + + FilterEffect[] effects = [invert, gamma, negaposi, colorKey, chromaKey, new ColorGrading()]; + foreach (FilterEffect effect in effects) + { + (float[] before, float[] after) = Render(effect, new SKColor(230, 40, 20, 180)); + AssertPixel( + after, + before, + 0.003f, + $"{effect.GetType().Name} changed an identity-configured premultiplied pixel"); + } + } + + [Test] + public void Threshold_ZeroStrength_PreservesLegacyPremultipliedLumaSemantics() + { + var threshold = new Threshold(); + threshold.Strength.CurrentValue = 0; + + (float[] before, float[] after) = Render(threshold, new SKColor(230, 40, 20, 180)); + float luma = before[0] * 0.2126f + before[1] * 0.7152f + before[2] * 0.0722f; + + AssertPixel( + after, + [luma, luma, luma, luma], + 0.003f, + "Threshold no longer matches its premultiplied luma behavior"); + } + + [TestCase(true)] + [TestCase(false)] + public void Invert_ActiveAmount_MatchesPremultipliedReference(bool excludeAlpha) + { + const float amount = 0.35f; + var effect = new Invert + { + Amount = { CurrentValue = amount * 100 }, + ExcludeAlphaChannel = { CurrentValue = excludeAlpha }, + }; + + (float[] before, float[] after) = Render(effect, new SKColor(190, 72, 28, 164)); + float alpha = before[3]; + float outputAlpha = excludeAlpha ? alpha : Mix(alpha, 1 - alpha, amount); + float[] expected = + [ + Mix(before[0] / alpha, 1 - before[0] / alpha, amount) * outputAlpha, + Mix(before[1] / alpha, 1 - before[1] / alpha, amount) * outputAlpha, + Mix(before[2] / alpha, 1 - before[2] / alpha, amount) * outputAlpha, + outputAlpha, + ]; + + AssertPixel(after, expected, 0.003f, "Invert no longer matches its premultiplied reference"); + } + + [Test] + public void Gamma_ActiveAmount_MatchesPremultipliedReference() + { + const float gamma = 1.8f; + const float strength = 0.65f; + var effect = new Gamma + { + Amount = { CurrentValue = gamma * 100 }, + Strength = { CurrentValue = strength * 100 }, + }; + + (float[] before, float[] after) = Render(effect, new SKColor(190, 72, 28, 164)); + float alpha = before[3]; + float[] expected = + [ + Mix(before[0] / alpha, MathF.Pow(before[0] / alpha, 1 / gamma), strength) * alpha, + Mix(before[1] / alpha, MathF.Pow(before[1] / alpha, 1 / gamma), strength) * alpha, + Mix(before[2] / alpha, MathF.Pow(before[2] / alpha, 1 / gamma), strength) * alpha, + alpha, + ]; + + AssertPixel(after, expected, 0.003f, "Gamma no longer matches its premultiplied reference"); + } + + [Test] + public void Negaposi_ActiveStrength_MatchesPremultipliedReference() + { + const float strength = 0.6f; + var effect = new Negaposi + { + Red = { CurrentValue = 224 }, + Green = { CurrentValue = 160 }, + Blue = { CurrentValue = 96 }, + Strength = { CurrentValue = strength * 100 }, + }; + + (float[] before, float[] after) = Render(effect, new SKColor(190, 72, 28, 164)); + float alpha = before[3]; + float[] key = + [ + Color.SrgbToLinear(224 / 255f), + Color.SrgbToLinear(160 / 255f), + Color.SrgbToLinear(96 / 255f), + ]; + float[] expected = + [ + Mix(before[0] / alpha, key[0] - before[0] / alpha, strength) * alpha, + Mix(before[1] / alpha, key[1] - before[1] / alpha, strength) * alpha, + Mix(before[2] / alpha, key[2] - before[2] / alpha, strength) * alpha, + alpha, + ]; + + AssertPixel(after, expected, 0.003f, "Negaposi no longer matches its premultiplied reference"); + } + + [Test] + public void Threshold_ActiveStrength_MatchesPremultipliedReference() + { + const float threshold = 0.15f; + const float smoothness = 0.2f; + const float strength = 0.7f; + var effect = new Threshold + { + Value = { CurrentValue = threshold * 100 }, + Smoothness = { CurrentValue = smoothness * 100 }, + Strength = { CurrentValue = strength * 100 }, + }; + + (float[] before, float[] after) = Render(effect, new SKColor(190, 72, 28, 164)); + float luma = before[0] * 0.2126f + before[1] * 0.7152f + before[2] * 0.0722f; + float thresholdValue = SmoothStep( + threshold - smoothness * 0.5f, + threshold + smoothness * 0.5f, + luma); + float expected = Mix(luma, thresholdValue, strength); + + AssertPixel( + after, + [expected, expected, expected, expected], + 0.003f, + "Threshold no longer matches its active premultiplied reference"); + } + + [Test] + public void KeyEffects_MatchingAndNonMatchingColors_PreserveLegacyMasks() + { + var input = new SKColor(64, 128, 224, 160); + var matchingColor = new Color(input.Alpha, input.Red, input.Green, input.Blue); + FilterEffect[] matching = + [ + new ColorKey + { + Color = { CurrentValue = matchingColor }, + Range = { CurrentValue = 5 }, + Boundary = { CurrentValue = 2 }, + }, + new ChromaKey + { + Color = { CurrentValue = matchingColor }, + HueRange = { CurrentValue = 5 }, + SaturationRange = { CurrentValue = 5 }, + Boundary = { CurrentValue = 2 }, + }, + ]; + FilterEffect[] nonMatching = + [ + new ColorKey + { + Color = { CurrentValue = Colors.Black }, + Range = { CurrentValue = 5 }, + Boundary = { CurrentValue = 2 }, + }, + new ChromaKey + { + Color = { CurrentValue = Colors.Black }, + HueRange = { CurrentValue = 5 }, + SaturationRange = { CurrentValue = 5 }, + Boundary = { CurrentValue = 2 }, + }, + ]; + + foreach (FilterEffect effect in matching) + { + (_, float[] after) = Render(effect, input); + AssertPixel(after, [0, 0, 0, 0], 0.003f, effect.GetType().Name); + } + + foreach (FilterEffect effect in nonMatching) + { + (float[] before, float[] after) = Render(effect, input); + AssertPixel(after, before, 0.003f, effect.GetType().Name); + } + } + + [Test] + public void ColorGrading_ActiveExposure_MatchesPremultipliedReference() + { + var effect = new ColorGrading + { + Exposure = { CurrentValue = 1 }, + }; + + (float[] before, float[] after) = Render(effect, new SKColor(40, 20, 10, 180)); + float[] expected = [before[0] * 2, before[1] * 2, before[2] * 2, before[3]]; + + AssertPixel(after, expected, 0.003f, "ColorGrading no longer applies exposure in linear light"); + } + + private static ShaderDescription Record(FilterEffect effect) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items, Has.Count.EqualTo(1)); + Assert.That(items.OfType(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(s_bounds)); + }); + + var item = (FEItem_Shader)items.Single(); + Assert.That(item.Description.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + return item.Description; + } + + private static IEnumerable ConstantShaderEffects() + { + yield return new TestCaseData((Func)(static () => new ChromaKey())) + .SetName("ChromaKey_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new ColorGrading())) + .SetName("ColorGrading_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new ColorKey())) + .SetName("ColorKey_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new Curves())) + .SetName("Curves_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new Gamma())) + .SetName("Gamma_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new Invert())) + .SetName("Invert_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new Negaposi())) + .SetName("Negaposi_ReusesParsedSource"); + yield return new TestCaseData((Func)(static () => new Threshold())) + .SetName("Threshold_ReusesParsedSource"); + } + + private static void AssertFloatUniform( + ShaderDescription description, + string name, + params float[] expected) + { + ShaderUniformValue actual = Bind(description, name); + Assert.That(actual.IsInteger, Is.False); + Assert.That(actual.Floats, Has.Length.EqualTo(expected.Length)); + for (int index = 0; index < expected.Length; index++) + { + Assert.That( + actual.Floats![index], + Is.EqualTo(expected[index]).Within(1e-6), + $"uniform '{name}' component {index}"); + } + } + + private static void AssertFloatUniform( + ShaderDescription description, + string name, + Vector3 expected) + => AssertFloatUniform(description, name, expected.X, expected.Y, expected.Z); + + private static float[] KeyColorUniform(ShaderDescription description) + => Bind(description, "keyColor").Floats!; + + private static void AssertIntegerUniform( + ShaderDescription description, + string name, + params int[] expected) + { + ShaderUniformValue actual = Bind(description, name); + Assert.Multiple(() => + { + Assert.That(actual.IsInteger, Is.True); + Assert.That(actual.Integers, Is.EqualTo(expected)); + }); + } + + private static ShaderUniformValue Bind(ShaderDescription description, string name) + { + ShaderUniformBinding binding = description.Uniforms.Single(item => item.Name == name); + SkslUniformDeclaration declaration = description.Source.Uniforms[name]; + var token = new RenderExecutionSessionToken(); + var execution = new ShaderExecutionContext( + token, + s_bounds, + s_bounds, + s_bounds, + PixelRect.FromRect(s_bounds, 1), + EffectiveScale.At(1), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + intent: RenderIntent.Preview, + purpose: RenderRequestPurpose.Frame); + try + { + return binding.Bind(declaration, execution); + } + finally + { + token.Complete(); + } + } + + private static (float[] Before, float[] After) Render(FilterEffect effect, SKColor color) + { + using var backing = new CpuRenderTarget(1, 1); + backing.Value.Canvas.Clear(color); + backing.Value.Canvas.Flush(); + using Bitmap beforeBitmap = backing.Snapshot(); + float[] before = ReadPixel(beforeBitmap); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + new Rect(0, 0, 1, 1), + EffectiveScale.At(1), + new PixelRect(0, 0, 1, 1)), + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(new Rect(0, 0, 1, 1)); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + activator.Apply(context); + activator.Flush(false); + + using Bitmap afterBitmap = targets.Single().RenderTarget!.Snapshot(); + return (before, ReadPixel(afterBitmap)); + } + + private static float[] ReadPixel(Bitmap bitmap) + => bitmap.GetPixelSpan()[..4] + .ToArray() + .Select(static bits => (float)BitConverter.UInt16BitsToHalf(bits)) + .ToArray(); + + private static float Mix(float first, float second, float amount) + => first + (second - first) * amount; + + private static float SmoothStep(float edge0, float edge1, float value) + { + float amount = Math.Clamp((value - edge0) / (edge1 - edge0), 0, 1); + return amount * amount * (3 - 2 * amount); + } + + private static void AssertPixel(float[] actual, float[] expected, float tolerance, string message) + { + Assert.That(actual, Has.Length.EqualTo(expected.Length)); + for (int index = 0; index < expected.Length; index++) + { + Assert.That( + actual[index], + Is.EqualTo(expected[index]).Within(tolerance), + $"{message}; channel {index}"); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height) + { + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("A CPU effect-test surface could not be created."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FilterEffectActivatorReentrancyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FilterEffectActivatorReentrancyTests.cs new file mode 100644 index 0000000000..7ee35a8774 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FilterEffectActivatorReentrancyTests.cs @@ -0,0 +1,88 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +/// +/// Pins that a Skia item may use the activator's public surface without breaking the chain around it. +/// +/// +/// and both drop the +/// per-target chain bookkeeping, and Activate keeps it only for a builder that has no filter yet. A Skia item +/// runs author code, so an author calling either one from inside it must not leave the loop that follows +/// reading a map that is no longer there. +/// +[TestFixture] +public sealed class FilterEffectActivatorReentrancyTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 6); + + [Test] + public void ASkiaItemThatReentersActivate_LeavesTheChainUsable() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + using var reentrant = new FilterEffectContext(s_bounds); + using var context = new FilterEffectContext(s_bounds); + context._items.Add(new ReentrantSkiaItem(reentrant)); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + Assert.That(() => activator.Apply(context), Throws.Nothing, + "The activator must re-establish its own bookkeeping after running author code."); + } + + private sealed record ReentrantSkiaItem(FilterEffectContext Reentrant) + : FEItem(Reentrant, static (_, rect) => rect), IFEItem_Skia + { + public bool ResolveBoundsAtExecutionTime => false; + + public bool SupportsDirectReplay => false; + + public bool TryTransformSamplingBounds(Rect output, out Rect input) + { + input = output; + return true; + } + + public void Accepts(FilterEffectActivator activator, SKImageFilterBuilder builder) + { + builder.AppendSKColorFilter( + SKColors.White, + activator, + static (color, _) => SKColorFilter.CreateBlendMode(color, SKBlendMode.Modulate)); + _ = activator.Activate(Reentrant); + } + + public void AcceptsDirect(SKImageFilterBuilder builder) + => throw new InvalidOperationException("The reentrancy fixture has no direct-replay factory."); + } + + private static EffectTargets CreateSolidTargets(Rect bounds) + { + using RenderTarget renderTarget = RenderTarget.Create((int)bounds.Width, (int)bounds.Height) + ?? throw new InvalidOperationException("A CPU render target is required for this test."); + using (var canvas = new ImmediateCanvas( + renderTarget, + density: 1, + maxWorkingScale: 1, + logicalSize: bounds.Size)) + { + canvas.Clear(Colors.Red); + } + + return new EffectTargets + { + new EffectTarget(renderTarget, bounds, EffectiveScale.At(1)), + }; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FiniteCurrentPixelEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FiniteCurrentPixelEffectTests.cs new file mode 100644 index 0000000000..f1cb3262cd --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/FiniteCurrentPixelEffectTests.cs @@ -0,0 +1,81 @@ +using System.Text; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Media.Source; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +public sealed class FiniteCurrentPixelEffectTests +{ + [Test] + public void Gamma_ShaderBoundsPowerAndHalfConversion() + { + string source = RecordSource(new Gamma()); + + Assert.Multiple(() => + { + Assert.That(source, Does.Contain("corrected = min(")); + Assert.That(source, Does.Contain("clamp(result * alpha")); + }); + } + + [Test] + public void ColorGrading_ShaderBoundsPowerBeforeLaterColorMath() + { + string source = RecordSource(new ColorGrading()); + + Assert.Multiple(() => + { + Assert.That(source, Does.Contain("color = min(")); + Assert.That(source, Does.Contain("clamp(color * gn")); + Assert.That(source, Does.Contain("clamp(rgb * alpha")); + }); + } + + [TestCase(CubeFileDimension.OneDimension)] + [TestCase(CubeFileDimension.ThreeDimension)] + public void LutEffect_ShaderBoundsTransferFunctions(CubeFileDimension dimension) + { + var effect = new LutEffect + { + Source = { CurrentValue = CreateLutSource(dimension) }, + }; + + string source = RecordSource(effect); + + Assert.Multiple(() => + { + Assert.That(source, Does.Contain("pow(max(c, float3(0.0))")); + Assert.That(source, Does.Contain("pow(max((c + 0.055) / 1.055, float3(0.0))")); + Assert.That(source, Does.Contain("clamp(result * alpha")); + if (dimension == CubeFileDimension.ThreeDimension) + Assert.That(source, Does.Contain("float3 boundedColor = clamp(inputColor")); + }); + } + + private static string RecordSource(FilterEffect effect) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(new Rect(0, 0, 1, 1)); + context.ApplyTransactional(effect, resource); + var item = (FEItem_Shader)context.GetOrderedItems().Single(); + return item.Description.Source.Text; + } + + private static CubeSource CreateLutSource(CubeFileDimension dimension) + { + string header = dimension == CubeFileDimension.OneDimension + ? "LUT_1D_SIZE 2" + : "LUT_3D_SIZE 2"; + int entries = dimension == CubeFileDimension.OneDimension ? 2 : 8; + string cubeText = $"{header}\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n" + + string.Concat(Enumerable.Repeat("0 0 0\n", entries)); + var source = new CubeSource(); + source.ReadFrom(new Uri( + "data:text/plain;base64," + + Convert.ToBase64String(Encoding.ASCII.GetBytes(cubeText)))); + return source; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/NativeFilterScratchTextureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/NativeFilterScratchTextureTests.cs new file mode 100644 index 0000000000..8dc3437ad9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/NativeFilterScratchTextureTests.cs @@ -0,0 +1,102 @@ +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Moq; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +public sealed class NativeFilterScratchTextureTests +{ + [Test] + public void AcquireNativeScratchTexture_ClearsOwnedTextureBeforeReturningIt() + { + var texture = new ClearableTexture(8, 6); + + var graphicsContext = new Mock(); + graphicsContext + .Setup(x => x.CreateTexture2D(8, 6, TextureFormat.RGBA16Float)) + .Returns(texture); + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); + + using NativeFilterTextureLease lease = context.AcquireNativeScratchTexture( + graphicsContext.Object, + 8, + 6); + + Assert.That(lease.Texture, Is.SameAs(texture)); + Assert.That(texture.ClearCount, Is.EqualTo(1)); + } + + [Test] + public void AcquireNativeScratchTexture_RejectsTextureWithoutOrderedClear() + { + var texture = new Mock(); + texture.SetupGet(x => x.Width).Returns(8); + texture.SetupGet(x => x.Height).Returns(6); + texture.SetupGet(x => x.Format).Returns(TextureFormat.RGBA16Float); + var graphicsContext = new Mock(); + graphicsContext + .Setup(x => x.CreateTexture2D(8, 6, TextureFormat.RGBA16Float)) + .Returns(texture.Object); + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); + + InvalidOperationException? exception = Assert.Throws(() => + context.AcquireNativeScratchTexture(graphicsContext.Object, 8, 6)); + + Assert.That(exception!.Message, Does.Contain("ordered transparent clear")); + texture.Verify(x => x.Dispose(), Times.Once); + } + + private sealed class ClearableTexture(int width, int height) + : ITexture2D, ITransparentClearableTexture + { + public int Width { get; } = width; + + public int Height { get; } = height; + + public TextureFormat Format => TextureFormat.RGBA16Float; + + public IntPtr NativeHandle => IntPtr.Zero; + + public IntPtr NativeViewHandle => IntPtr.Zero; + + public bool RequiresSkiaFlushForBackendInterop => false; + + public bool HasTransparentContents => ClearCount > 0; + + public int ClearCount { get; private set; } + + public void Upload(ReadOnlySpan data) => throw new NotSupportedException(); + + public byte[] DownloadPixels() => throw new NotSupportedException(); + + public SKSurface CreateSkiaSurface() => throw new NotSupportedException(); + + public void PrepareForRender() => throw new NotSupportedException(); + + public void PrepareForSampling() => throw new NotSupportedException(); + + public void PrepareForSkiaRendering() => throw new NotSupportedException(); + + public void PrepareForSkiaSampling(bool requireCompletion) => throw new NotSupportedException(); + + public void ClearToTransparent() => ClearCount++; + + public void MarkContentsTransparent() => MarkedTransparentCount++; + + public int MarkedTransparentCount { get; private set; } + + public void Dispose() + { + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortEffectSynchronizationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortEffectSynchronizationTests.cs new file mode 100644 index 0000000000..27bcdd7736 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortEffectSynchronizationTests.cs @@ -0,0 +1,228 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Backend.Vulkan; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +[NonParallelizable] +public sealed class PixelSortEffectSynchronizationTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + [Category("GpuPassFusionGpu")] + public void PixelSort_WaitsForTheSourceBeforeSamplingItFromVulkan() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the GPU pixel-sort source."); + Assert.That(source.Texture, Is.Not.Null); + // Left unsubmitted on purpose: the effect boundary reuses this buffer instead of + // re-materializing it, so nothing else orders these draws against the Vulkan passes. + DrawUnsortedBars(source); + + var flushes = new List(); + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + ApplyPixelSort(source).Dispose(); + + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling), + Is.EqualTo(1), + "Reading a Skia-owned texture from a separate Vulkan submission must submit and wait."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void PixelSort_SortsAnUnsubmittedSourceInsteadOfReturningIt() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the GPU pixel-sort source."); + DrawUnsortedBars(source); + + using RenderTarget result = ApplyPixelSort(source); + using Bitmap sorted = result.Snapshot(); + using Bitmap original = source.Snapshot(); + + Assert.That( + sorted.GetPixelSpan().SequenceEqual(original.GetPixelSpan()), + Is.False, + "An empty read of the source makes every pixel an anchor, which hands back the unsorted image."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void PixelSort_DoesNotAllocateDepthTextures() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the GPU pixel-sort source."); + DrawUnsortedBars(source); + + var allocations = new List(); + using (VulkanContext.ObserveTextureAllocations(allocations.Add)) + ApplyPixelSort(source).Dispose(); + + Assert.Multiple(() => + { + Assert.That( + allocations, + Does.Contain(TextureFormat.RGBA16Float), + "The allocation observer must see the pixel-sort intermediate textures."); + Assert.That( + allocations, + Has.None.EqualTo(TextureFormat.Depth32Float), + "Pixel-sort fullscreen passes must not allocate unused depth textures."); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void PixelSort_ReusesItsDestinationAndScratchTargetsAfterWarmup() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the GPU pixel-sort source."); + DrawUnsortedBars(source); + using var registry = new RenderTargetLeaseRegistry(factory: null); + + List firstAllocations = ApplyPooledPixelSort(source, registry); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0)); + GpuResourceReclaimQueue.FlushAndDrain(); + List secondAllocations = ApplyPooledPixelSort(source, registry); + + Assert.Multiple(() => + { + Assert.That( + firstAllocations.Count(static format => format == TextureFormat.RGBA16Float), + Is.EqualTo(3), + "PixelSort must warm one destination and two scratch slots."); + Assert.That( + secondAllocations, + Has.None.EqualTo(TextureFormat.RGBA16Float), + "The warmed PixelSort invocation must allocate no additional native targets."); + Assert.That(registry.Statistics.Creates, Is.EqualTo(3)); + Assert.That(registry.Statistics.Reuses, Is.EqualTo(3)); + }); + GpuResourceReclaimQueue.FlushAndDrain(); + }); + } + + // Four opaque bars whose luminance ascends out of order, so any horizontal ascending sort + // has to move pixels. + private static void DrawUnsortedBars(RenderTarget target) + { + target.BeginDraw(); + SKCanvas canvas = target.Value.Canvas; + canvas.Clear(SKColors.Blue); + SKColor[] colors = [SKColors.Blue, SKColors.White, SKColors.Red, SKColors.Green]; + using var paint = new SKPaint { IsAntialias = false }; + for (int i = 0; i < colors.Length; i++) + { + paint.Color = colors[i]; + canvas.DrawRect( + SKRect.Create(i * 4, 0, 4, (float)s_bounds.Height), + paint); + } + } + + private static RenderTarget ApplyPixelSort(RenderTarget source) + { + var effect = new PixelSortEffect(); + effect.Direction.CurrentValue = PixelSortDirection.Horizontal; + effect.SortKey.CurrentValue = PixelSortKey.Luminance; + effect.ThresholdMin.CurrentValue = 0f; + effect.ThresholdMax.CurrentValue = 100f; + effect.Ascending.CurrentValue = true; + + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + context.ApplyTransactional(effect, resource); + using var targets = new EffectTargets + { + new EffectTarget(source, s_bounds, EffectiveScale.At(1)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + deviceGridOffset: default, + useExecutorManagedCanvas: true); + + activator.Apply(context); + + RenderTarget applied = activator.CurrentTargets.Single().RenderTarget + ?? throw new InvalidOperationException("The pixel-sort effect produced no target."); + return applied.ShallowCopy(); + } + + private static List ApplyPooledPixelSort( + RenderTarget source, + RenderTargetLeaseRegistry registry) + { + var effect = new PixelSortEffect(); + effect.Direction.CurrentValue = PixelSortDirection.Horizontal; + effect.SortKey.CurrentValue = PixelSortKey.Luminance; + effect.ThresholdMin.CurrentValue = 0f; + effect.ThresholdMax.CurrentValue = 100f; + effect.Ascending.CurrentValue = true; + + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + context.ApplyTransactional(effect, resource); + using RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Delivery, + source); + using var targets = new EffectTargets + { + new EffectTarget(source, s_bounds, EffectiveScale.At(1)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + deviceGridOffset: default, + useExecutorManagedCanvas: true, + renderTargetLeaseSession: session); + + var allocations = new List(); + using (VulkanContext.ObserveTextureAllocations(allocations.Add)) + { + activator.Apply(context); + using Bitmap completed = activator.CurrentTargets.Single().RenderTarget!.Snapshot(); + } + + return allocations; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortPipelineCacheTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortPipelineCacheTests.cs new file mode 100644 index 0000000000..b88a5dd028 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortPipelineCacheTests.cs @@ -0,0 +1,109 @@ +using Beutl.Graphics.Effects; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +public sealed class PixelSortPipelineCacheTests +{ + [Test] + public void GetOrCreate_KeysEverySpecializationAndReusesRepeatedValues() + { + int prepareCreations = 0; + int rankCreations = 0; + int gatherCreations = 0; + var cache = new PixelSortPipelineCache( + key => new PipelineToken("prepare", (int)key, prepareCreations++), + direction => new PipelineToken("rank", (int)direction, rankCreations++), + (direction, ascending) => new PipelineToken( + "gather", + ((int)direction * 2) + (ascending ? 1 : 0), + gatherCreations++)); + + PixelSortPipelines first = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: true)!.Value; + PixelSortPipelines repeated = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: true)!.Value; + PixelSortPipelines descending = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: false)!.Value; + PixelSortPipelines vertical = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Vertical, + ascending: true)!.Value; + PixelSortPipelines hue = cache.GetOrCreate( + PixelSortKey.Hue, + PixelSortDirection.Vertical, + ascending: true)!.Value; + + Assert.Multiple(() => + { + Assert.That(repeated.Prepare, Is.SameAs(first.Prepare)); + Assert.That(repeated.Rank, Is.SameAs(first.Rank)); + Assert.That(repeated.Gather, Is.SameAs(first.Gather)); + Assert.That(descending.Prepare, Is.SameAs(first.Prepare)); + Assert.That(descending.Rank, Is.SameAs(first.Rank)); + Assert.That(descending.Gather, Is.Not.SameAs(first.Gather)); + Assert.That(vertical.Prepare, Is.SameAs(first.Prepare)); + Assert.That(vertical.Rank, Is.Not.SameAs(first.Rank)); + Assert.That(vertical.Gather, Is.Not.SameAs(first.Gather)); + Assert.That(hue.Prepare, Is.Not.SameAs(first.Prepare)); + Assert.That(hue.Rank, Is.SameAs(vertical.Rank)); + Assert.That(hue.Gather, Is.SameAs(vertical.Gather)); + Assert.That(prepareCreations, Is.EqualTo(2)); + Assert.That(rankCreations, Is.EqualTo(2)); + Assert.That(gatherCreations, Is.EqualTo(3)); + }); + } + + [Test] + public void GetOrCreate_FactoryFailureRetriesOnlyTheUnpublishedSlot() + { + int prepareCreations = 0; + int rankAttempts = 0; + int gatherCreations = 0; + var cache = new PixelSortPipelineCache( + key => new PipelineToken("prepare", (int)key, prepareCreations++), + direction => ++rankAttempts == 1 + ? throw new InvalidOperationException("transient pipeline failure") + : new PipelineToken("rank", (int)direction, rankAttempts), + (direction, ascending) => new PipelineToken( + "gather", + ((int)direction * 2) + (ascending ? 1 : 0), + gatherCreations++)); + + Assert.That( + () => cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: true), + Throws.TypeOf()); + + PixelSortPipelines recovered = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: true)!.Value; + PixelSortPipelines warmed = cache.GetOrCreate( + PixelSortKey.Luminance, + PixelSortDirection.Horizontal, + ascending: true)!.Value; + + Assert.Multiple(() => + { + Assert.That(warmed.Prepare, Is.SameAs(recovered.Prepare)); + Assert.That(warmed.Rank, Is.SameAs(recovered.Rank)); + Assert.That(warmed.Gather, Is.SameAs(recovered.Gather)); + Assert.That(prepareCreations, Is.EqualTo(1), + "a successfully published prerequisite must survive a later slot failure"); + Assert.That(rankAttempts, Is.EqualTo(2), + "the failed slot must retry and then remain warm after success"); + Assert.That(gatherCreations, Is.EqualTo(1)); + }); + } + + private sealed record PipelineToken(string Pass, int Variant, int Creation); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortSpecializationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortSpecializationTests.cs new file mode 100644 index 0000000000..2722985b2b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/PixelSortSpecializationTests.cs @@ -0,0 +1,447 @@ +using System.Runtime.InteropServices; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +[NonParallelizable] +public sealed class PixelSortSpecializationTests +{ + private const string RuntimePrepareShaderSource = """ + #version 450 + + layout(location = 0) in vec2 fragCoord; + layout(location = 0) out vec4 outColor; + + layout(set = 0, binding = 0) uniform sampler2D srcTexture; + + layout(push_constant) uniform PushConstants { + float thresholdMin; + float thresholdMax; + int sortKeyType; + int sortDir; + float width; + float height; + } pc; + + float hue(vec4 c) { + float cMax = max(c.r, max(c.g, c.b)); + float cMin = min(c.r, min(c.g, c.b)); + float delta = cMax - cMin; + if (delta < 1e-5) return 0.0; + float h; + if (cMax == c.r) h = mod((c.g - c.b) / delta, 6.0); + else if (cMax == c.g) h = (c.b - c.r) / delta + 2.0; + else h = (c.r - c.g) / delta + 4.0; + return h / 6.0; + } + + float saturation(vec4 c) { + float cMax = max(c.r, max(c.g, c.b)); + float cMin = min(c.r, min(c.g, c.b)); + return (cMax < 1e-5) ? 0.0 : (cMax - cMin) / cMax; + } + + float computeKey(vec4 c) { + if (pc.sortKeyType == 1) return hue(c); + else if (pc.sortKeyType == 2) return saturation(c); + else if (pc.sortKeyType == 3) return c.r; + else if (pc.sortKeyType == 4) return c.g; + else if (pc.sortKeyType == 5) return c.b; + return dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + } + + void main() { + ivec2 coord = ivec2(fragCoord * vec2(pc.width, pc.height)); + vec4 color = texelFetch(srcTexture, coord, 0); + float key = computeKey(color); + bool isAnchor = (key < pc.thresholdMin || key > pc.thresholdMax); + float encodedKey = isAnchor ? 0.0 : max(1.0 / 255.0, key * 0.998 + 0.001); + outColor = vec4(color.rgb, encodedKey); + } + """; + + private const string RuntimeRankShaderSource = """ + #version 450 + + layout(location = 0) in vec2 fragCoord; + layout(location = 0) out vec4 outColor; + + layout(set = 0, binding = 0) uniform sampler2D srcTexture; + + layout(push_constant) uniform PushConstants { + int sortDir; + float width; + float height; + } pc; + + void main() { + ivec2 coord = ivec2(fragCoord * vec2(pc.width, pc.height)); + int idx = (pc.sortDir == 0) ? coord.x : coord.y; + int lineIdx = (pc.sortDir == 0) ? coord.y : coord.x; + int maxIdx = (pc.sortDir == 0) ? int(pc.width) : int(pc.height); + + float myKey = texelFetch(srcTexture, coord, 0).a; + + if (myKey < 0.0005) { + outColor = vec4(0.0); + return; + } + + int segStart = idx; + for (int s = idx - 1; s >= 0; s--) { + ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + if (texelFetch(srcTexture, c, 0).a < 0.0005) break; + segStart = s; + } + + int segEnd = idx; + for (int s = idx + 1; s < maxIdx; s++) { + ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + if (texelFetch(srcTexture, c, 0).a < 0.0005) break; + segEnd = s; + } + + int rank = 0; + for (int j = segStart; j <= segEnd; j++) { + if (j == idx) continue; + ivec2 c = (pc.sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); + float otherKey = texelFetch(srcTexture, c, 0).a; + if (otherKey < myKey || (otherKey == myKey && j < idx)) { + rank++; + } + } + + outColor = vec4( + float(rank & 255) / 255.0, + float((rank >> 8) & 255) / 255.0, + 1.0, + 0.0 + ); + } + """; + + private const string RuntimeGatherShaderSource = """ + #version 450 + + layout(location = 0) in vec2 fragCoord; + layout(location = 0) out vec4 outColor; + + layout(set = 0, binding = 0) uniform sampler2D rankTexture; + layout(set = 0, binding = 1) uniform sampler2D originalTexture; + + layout(push_constant) uniform PushConstants { + int sortDir; + int ascending; + float width; + float height; + } pc; + + void main() { + ivec2 coord = ivec2(fragCoord * vec2(pc.width, pc.height)); + int idx = (pc.sortDir == 0) ? coord.x : coord.y; + int lineIdx = (pc.sortDir == 0) ? coord.y : coord.x; + int maxIdx = (pc.sortDir == 0) ? int(pc.width) : int(pc.height); + + vec4 rankData = texelFetch(rankTexture, coord, 0); + + if (rankData.b < 0.5) { + outColor = texelFetch(originalTexture, coord, 0); + return; + } + + int segStart = idx; + for (int s = idx - 1; s >= 0; s--) { + ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + if (texelFetch(rankTexture, c, 0).b < 0.5) break; + segStart = s; + } + + int segEnd = idx; + for (int s = idx + 1; s < maxIdx; s++) { + ivec2 c = (pc.sortDir == 0) ? ivec2(s, lineIdx) : ivec2(lineIdx, s); + if (texelFetch(rankTexture, c, 0).b < 0.5) break; + segEnd = s; + } + + int targetRank = (pc.ascending == 1) + ? (idx - segStart) + : (segEnd - idx); + + vec4 originalAtIdx = texelFetch(originalTexture, coord, 0); + + for (int j = segStart; j <= segEnd; j++) { + ivec2 cj = (pc.sortDir == 0) ? ivec2(j, lineIdx) : ivec2(lineIdx, j); + vec4 rd = texelFetch(rankTexture, cj, 0); + int rank = int(rd.r * 255.0 + 0.5) + int(rd.g * 255.0 + 0.5) * 256; + + if (rank == targetRank) { + vec4 srcColor = texelFetch(originalTexture, cj, 0); + outColor = vec4(srcColor.rgb, originalAtIdx.a); + return; + } + } + + outColor = originalAtIdx; + } + """; + + private static readonly Rect s_bounds = new(0, 0, 4, 4); + private GLSLShader? _runtimePrepareShader; + private GLSLShader? _runtimeRankShader; + private GLSLShader? _runtimeGatherShader; + + private static IEnumerable SpecializationCases + { + get + { + foreach (PixelSortKey sortKey in Enum.GetValues()) + { + foreach (PixelSortDirection direction in Enum.GetValues()) + { + yield return new TestCaseData(sortKey, direction, true) + .SetName($"PixelSort_SpecializedMatchesRuntime_{sortKey}_{direction}_Ascending"); + yield return new TestCaseData(sortKey, direction, false) + .SetName($"PixelSort_SpecializedMatchesRuntime_{sortKey}_{direction}_Descending"); + } + } + } + } + + [OneTimeSetUp] + public void CreateRuntimeReferenceShaders() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + _runtimePrepareShader = GLSLShader.CreateBuiltIn(RuntimePrepareShaderSource); + _runtimeRankShader = GLSLShader.CreateBuiltIn(RuntimeRankShaderSource); + _runtimeGatherShader = GLSLShader.CreateBuiltIn( + RuntimeGatherShaderSource, + hasMaskTexture: true); + }); + } + + [OneTimeTearDown] + public void DisposeRuntimeReferenceShaders() + { + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + _runtimePrepareShader?.Dispose(); + _runtimeRankShader?.Dispose(); + _runtimeGatherShader?.Dispose(); + }); + } + + [TestCaseSource(nameof(SpecializationCases))] + [Category("GpuPassFusionGpu")] + public void SpecializedVariants_MatchPreChangeRuntimeBranches( + PixelSortKey sortKey, + PixelSortDirection direction, + bool ascending) + { + IGraphicsContext graphicsContext = VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the pixel-sort specialization source."); + DrawDistinctPixels(source); + + using RenderTarget specialized = ApplySpecializedPixelSort(source, sortKey, direction, ascending); + byte[] specializedPixels = specialized.Texture!.DownloadPixels(); + byte[] runtimePixels = ExecuteRuntimeReference( + graphicsContext, + source, + sortKey, + direction, + ascending); + + Assert.That(specializedPixels, Is.EqualTo(runtimePixels)); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void DifferentSortKeySpecializations_ProduceDifferentRenders() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not create the pixel-sort specialization source."); + DrawDistinctPixels(source); + + using RenderTarget red = ApplySpecializedPixelSort( + source, + PixelSortKey.Red, + PixelSortDirection.Horizontal, + ascending: true); + using RenderTarget blue = ApplySpecializedPixelSort( + source, + PixelSortKey.Blue, + PixelSortDirection.Horizontal, + ascending: true); + byte[] redPixels = red.Texture!.DownloadPixels(); + byte[] bluePixels = blue.Texture!.DownloadPixels(); + + Assert.That( + bluePixels, + Is.Not.EqualTo(redPixels), + "Distinct specialization values must change the executed PixelSort pipeline, not only its cache identity."); + }); + } + + private byte[] ExecuteRuntimeReference( + IGraphicsContext context, + RenderTarget source, + PixelSortKey sortKey, + PixelSortDirection direction, + bool ascending) + { + GLSLShader prepare = _runtimePrepareShader + ?? throw new InvalidOperationException("The runtime prepare shader was not initialized."); + GLSLShader rank = _runtimeRankShader + ?? throw new InvalidOperationException("The runtime rank shader was not initialized."); + GLSLShader gather = _runtimeGatherShader + ?? throw new InvalidOperationException("The runtime gather shader was not initialized."); + ITexture2D original = source.Texture + ?? throw new InvalidOperationException("The pixel-sort source has no GPU texture."); + int width = original.Width; + int height = original.Height; + using ITexture2D prepared = context.CreateTexture2D(width, height, TextureFormat.RGBA16Float); + using ITexture2D ranked = context.CreateTexture2D(width, height, TextureFormat.RGBA16Float); + using ITexture2D result = context.CreateTexture2D(width, height, TextureFormat.RGBA16Float); + + source.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + prepare.ExecuteSingleTarget( + original, + prepared, + new RuntimePreparePushConstants + { + ThresholdMin = 0, + ThresholdMax = 1, + SortKeyType = (int)sortKey, + SortDir = (int)direction, + Width = width, + Height = height, + }); + rank.ExecuteSingleTarget( + prepared, + ranked, + new RuntimeRankPushConstants + { + SortDir = (int)direction, + Width = width, + Height = height, + }); + gather.ExecuteSingleTargetWithMask( + ranked, + original, + result, + new RuntimeGatherPushConstants + { + SortDir = (int)direction, + Ascending = ascending ? 1 : 0, + Width = width, + Height = height, + }); + + return result.DownloadPixels(); + } + + private static RenderTarget ApplySpecializedPixelSort( + RenderTarget source, + PixelSortKey sortKey, + PixelSortDirection direction, + bool ascending) + { + var effect = new PixelSortEffect(); + effect.Direction.CurrentValue = direction; + effect.SortKey.CurrentValue = sortKey; + effect.ThresholdMin.CurrentValue = 0; + effect.ThresholdMax.CurrentValue = 100; + effect.Ascending.CurrentValue = ascending; + + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + context.ApplyTransactional(effect, resource); + using var targets = new EffectTargets + { + new EffectTarget(source, s_bounds, EffectiveScale.At(1)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + deviceGridOffset: default, + useExecutorManagedCanvas: true); + + activator.Apply(context); + + RenderTarget applied = activator.CurrentTargets.Single().RenderTarget + ?? throw new InvalidOperationException("The specialized pixel-sort effect produced no target."); + return applied.ShallowCopy(); + } + + private static void DrawDistinctPixels(RenderTarget target) + { + SKColor[] colors = + [ + new(12, 201, 73), new(231, 42, 118), new(64, 91, 223), new(174, 219, 31), + new(93, 17, 186), new(246, 133, 52), new(28, 168, 211), new(157, 76, 9), + new(204, 187, 99), new(49, 234, 142), new(119, 58, 247), new(222, 105, 164), + new(71, 149, 38), new(188, 26, 214), new(137, 196, 181), new(35, 113, 127), + ]; + target.BeginDraw(); + SKCanvas canvas = target.Value.Canvas; + using var paint = new SKPaint { IsAntialias = false }; + for (int y = 0; y < 4; y++) + { + for (int x = 0; x < 4; x++) + { + paint.Color = colors[(y * 4) + x]; + canvas.DrawRect(SKRect.Create(x, y, 1, 1), paint); + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct RuntimePreparePushConstants + { + public float ThresholdMin; + public float ThresholdMax; + public int SortKeyType; + public int SortDir; + public float Width; + public float Height; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RuntimeRankPushConstants + { + public int SortDir; + public float Width; + public float Height; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RuntimeGatherPushConstants + { + public int SortDir; + public int Ascending; + public float Width; + public float Height; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/ScriptCompilableEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/ScriptCompilableEffectTests.cs index ed61891583..88f7f1e57c 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/ScriptCompilableEffectTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/ScriptCompilableEffectTests.cs @@ -1,4 +1,5 @@ -using Beutl.Graphics.Backend; +using Beutl.Composition; +using Beutl.Graphics.Backend; using Beutl.Graphics.Effects; namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; @@ -57,4 +58,49 @@ public void Glsl_reports_unavailable_when_no_graphics_context() Assert.That(result.Status, Is.EqualTo(ScriptCompilationStatus.Unavailable)); } + + [TestCase("half4 apply(half4 color) { return color; } /* forgot to close")] + [TestCase("half4 /* forgot to close apply(half4 color) { return color; }")] + public void Sksl_unterminated_block_comment_is_reported_as_a_failure(string script) + { + var effect = new SKSLScriptEffect(); + + ScriptCompilationResult result = effect.ValidateScript(script); + + Assert.Multiple(() => + { + Assert.That(result.Status, Is.EqualTo(ScriptCompilationStatus.Failed)); + Assert.That(result.Error, Is.Not.Null.And.Not.Empty); + }); + } + + [Test] + public void Sksl_unterminated_block_comment_does_not_throw_while_building_a_resource() + { + var effect = new SKSLScriptEffect(); + effect.Script.CurrentValue = "half4 apply(half4 color) { return color; } /* forgot to close"; + + Assert.DoesNotThrow(() => effect.ToResource(CompositionContext.Default).Dispose(), + "The resource update runs on the render path, where a lexer throw tears down the frame " + + "instead of surfacing the mistake on the effect."); + } + + [Test] + public void Sksl_current_pixel_apply_script_compiles() + { + var effect = new SKSLScriptEffect(); + + ScriptCompilationResult result = effect.ValidateScript( + """ + half4 apply(half4 color) { + return half4(color.rgb * 0.5, color.a); + } + """); + + Assert.Multiple(() => + { + Assert.That(result.Status, Is.EqualTo(ScriptCompilationStatus.Compiled)); + Assert.That(result.Error, Is.Null); + }); + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/WholeSourceFilterEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/WholeSourceFilterEffectTests.cs new file mode 100644 index 0000000000..77c061d682 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/FilterEffects/WholeSourceFilterEffectTests.cs @@ -0,0 +1,451 @@ +using System.Numerics; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.FilterEffects; + +[TestFixture] +public sealed class WholeSourceFilterEffectTests +{ + private const float UnallocatableScale = 2_000_000f; + + private static readonly Rect s_bounds = new(10, 20, 100, 60); + + [TestCaseSource(nameof(MigratedEffects))] + public void MigratedEffects_RecordWholeSourceWithoutLegacyBoundary( + Func factory, + SKShaderTileMode expectedTileMode, + bool expectedFullInput, + int expectedResourceCount) + { + FilterEffect effect = factory(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items, Has.Count.EqualTo(1)); + Assert.That(items.OfType(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(s_bounds)); + }); + + var item = (FEItem_Shader)items.Single(); + Assert.Multiple(() => + { + Assert.That(item.Description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + Assert.That(item.Description.SourceTileMode, Is.EqualTo(expectedTileMode)); + Assert.That(item.Description.Bounds.RequiresFullInput, Is.EqualTo(expectedFullInput)); + Assert.That(item.Description.Resources, Has.Count.EqualTo(expectedResourceCount)); + }); + } + + [TestCaseSource(nameof(SolidColorEffects))] + public void MigratedEffects_PreservePremultipliedSolidColorOutput(Func factory) + { + FilterEffect effect = factory(); + using var backing = new CpuRenderTarget(3, 2); + backing.Value.Canvas.Clear(new SKColor(230, 40, 20, 180)); + backing.Value.Canvas.Flush(); + using Bitmap beforeBitmap = backing.Snapshot(); + float[] before = ReadPixels(beforeBitmap); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)), + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(new Rect(0, 0, 3, 2)); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + activator.Apply(context); + activator.Flush(false); + + using Bitmap afterBitmap = targets.Single().RenderTarget!.Snapshot(); + float[] after = ReadPixels(afterBitmap); + Assert.That(after, Has.Length.EqualTo(before.Length)); + for (int index = 0; index < before.Length; index++) + { + Assert.That( + after[index], + Is.EqualTo(before[index]).Within(0.003f), + $"{effect.GetType().Name} changed solid-color output channel {index}"); + } + } + + [Test] + public void ColorShift_RecordsForwardAndBackwardOffsetBounds() + { + var effect = new ColorShift + { + RedOffset = { CurrentValue = new PixelPoint(4, 2) }, + GreenOffset = { CurrentValue = new PixelPoint(1, 0) }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + var item = (FEItem_Shader)context.GetOrderedItems().Single(); + Assert.Multiple(() => + { + Assert.That(context.Bounds, Is.EqualTo(new Rect(10, 20, 104, 62))); + Assert.That( + item.Description.Bounds.GetRequiredInputBounds(s_bounds), + Is.EqualTo(new Rect(6, 18, 104, 62))); + }); + } + + [Test] + public void ColorShift_AnimatedOffsets_KeepTheRecordedShaderStructure() + { + var first = new ColorShift + { + RedOffset = { CurrentValue = new PixelPoint(4, 2) }, + GreenOffset = { CurrentValue = new PixelPoint(1, 0) }, + }; + var second = new ColorShift + { + RedOffset = { CurrentValue = new PixelPoint(-3, 5) }, + GreenOffset = { CurrentValue = new PixelPoint(0, -2) }, + }; + + ShaderDescription firstDescription = Record(first); + ShaderDescription secondDescription = Record(second); + + Assert.Multiple(() => + { + Assert.That( + secondDescription.Bounds.StructuralIdentity, + Is.EqualTo(firstDescription.Bounds.StructuralIdentity), + "animated offset values must not create a new bounds-contract shape"); + Assert.That( + secondDescription.StructuralIdentity, + Is.EqualTo(firstDescription.StructuralIdentity), + "animated offset values must reuse the same shader structure"); + }); + + static ShaderDescription Record(ColorShift effect) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + context.ApplyTransactional(effect, resource); + return ((FEItem_Shader)context.GetOrderedItems().Single()).Description; + } + } + + [Test] + public void Mosaic_RelativeOrigin_UsesCompleteCanonicalDeviceFootprint() + { + var outputBounds = new Rect(0.25f, 0.25f, 100, 80); + var requestedRegion = new Rect(20, 10, 30, 20); + using FilterEffect.Resource resource = new MosaicEffect().ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(outputBounds); + context.ApplyTransactional(resource.GetOriginal()!, resource); + ShaderDescription description = ((FEItem_Shader)context.GetOrderedItems().Single()).Description; + ShaderUniformBinding binding = description.Uniforms.Single(static item => item.Name == "origin"); + SkslUniformDeclaration declaration = description.Source.Uniforms["origin"]; + PixelRect requestedDeviceBounds = PixelRect.FromRect(requestedRegion, 1); + PixelRect completeDeviceBounds = PixelRect.FromRect(outputBounds, 1); + var token = new RenderExecutionSessionToken(); + + Vector2 origin = token.RunAndComplete(() => + { + var execution = new ShaderExecutionContext( + token, + outputBounds, + outputBounds, + requestedRegion, + requestedDeviceBounds, + EffectiveScale.At(1), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary); + float[] values = binding.Bind(declaration, execution).Floats!; + return new Vector2(values[0], values[1]); + }); + + Assert.That( + origin, + Is.EqualTo(new Vector2( + completeDeviceBounds.X - requestedDeviceBounds.X + completeDeviceBounds.Width / 2f, + completeDeviceBounds.Y - requestedDeviceBounds.Y + completeDeviceBounds.Height / 2f))); + } + + [TestCase(GradientSpreadMethod.Pad, SKShaderTileMode.Clamp)] + [TestCase(GradientSpreadMethod.Reflect, SKShaderTileMode.Mirror)] + [TestCase(GradientSpreadMethod.Repeat, SKShaderTileMode.Repeat)] + [TestCase(GradientSpreadMethod.Decal, SKShaderTileMode.Decal)] + public void DisplacementMap_PreservesSourceTileMode( + GradientSpreadMethod spreadMethod, + SKShaderTileMode expected) + { + var effect = new DisplacementMapEffect + { + SpreadMethod = { CurrentValue = spreadMethod }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + var item = (FEItem_Shader)context.GetOrderedItems().Single(); + Assert.That(item.Description.SourceTileMode, Is.EqualTo(expected)); + } + + [Test] + public void DisplacementMapPreview_RemainsLegacyCustomBoundary() + { + var effect = new DisplacementMapEffect + { + ShowDisplacementMap = { CurrentValue = true }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items, Has.Count.EqualTo(1)); + Assert.That(items.OfType(), Has.Exactly(1).Items); + Assert.That(items.OfType(), Is.Empty); + }); + } + + [Test] + public void DisplacementMap_DirectCompatibilityExecution_CommitsAndReleasesBorrowedResource() + { + var effect = new DisplacementMapEffect(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + Brush.Resource displacementMap = ((DisplacementMapEffect.Resource)resource).DisplacementMap!; + using var backing = new CpuRenderTarget(3, 2); + backing.Value.Canvas.Clear(SKColors.Red); + backing.Value.Canvas.Flush(); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)), + }; + var context = new FilterEffectContext(new Rect(0, 0, 3, 2)); + RenderResource? token = null; + try + { + context.ApplyTransactional(effect, resource); + token = ((FEItem_Shader)context.GetOrderedItems().Single()) + .Description.Resources.Single().Resource; + Assert.That(token.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Pending)); + + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + activator.Apply(context); + Assert.That(token.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Committed)); + activator.Flush(false); + + using Bitmap bitmap = targets.Single().RenderTarget!.Snapshot(); + Assert.That(bitmap.SKBitmap.GetPixel(1, 1).Red, Is.GreaterThan(239)); + } + finally + { + context.Dispose(); + } + + Assert.That(token, Is.Not.Null); + Assert.That(token!.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + using SKShader? rebound = new BrushConstructor( + new Rect(0, 0, 3, 2), + displacementMap, + BlendMode.SrcOver, + RenderIntent.Preview, + drawableBrushMaterializer: null, + scale: 1, + maxWorkingScale: 1) + .CreateShader(); + Assert.That(rebound, Is.Not.Null); + } + + [Test] + public void DisplacementMapTargetTransaction_DrawFailureDisposesReplacementAndPreservesOriginalSlot() + { + using var originalBacking = new CpuRenderTarget(3, 2); + using var replacementBacking = new CpuRenderTarget(3, 2); + var original = new EffectTarget( + originalBacking, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)); + var replacement = new EffectTarget( + replacementBacking, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)); + using var targets = new EffectTargets { original }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); + var failure = new InvalidOperationException("draw failed"); + + InvalidOperationException? thrown = Assert.Throws(() => + DisplacementMapEffect.RenderAndCommitReplacement( + context, + 0, + original, + replacement, + failure, + static current => throw current)); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(replacement.IsEmpty, Is.True); + Assert.That(targets[0], Is.SameAs(original)); + Assert.That(original.IsEmpty, Is.False); + }); + } + + [Test] + public void DisplacementMapTargetTransaction_DrawSuccessCommitsReplacementAndDisposesOriginal() + { + using var originalBacking = new CpuRenderTarget(3, 2); + using var replacementBacking = new CpuRenderTarget(3, 2); + var original = new EffectTarget( + originalBacking, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)); + var replacement = new EffectTarget( + replacementBacking, + new Rect(0, 0, 3, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 3, 2)); + using var targets = new EffectTargets { original }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); + + DisplacementMapEffect.RenderAndCommitReplacement( + context, + 0, + original, + replacement, + false, + static _ => { }); + + Assert.Multiple(() => + { + Assert.That(targets[0], Is.SameAs(replacement)); + Assert.That(original.IsEmpty, Is.True); + Assert.That(replacement.IsEmpty, Is.False); + }); + } + + private static IEnumerable MigratedEffects() + { + yield return new TestCaseData( + (Func)(() => new MosaicEffect()), + SKShaderTileMode.Clamp, + true, + 0) + .SetName("Mosaic_WholeSource"); + yield return new TestCaseData( + (Func)(() => new ColorShift()), + SKShaderTileMode.Decal, + false, + 0) + .SetName("ColorShift_WholeSource"); + yield return new TestCaseData( + (Func)CreateDisplacementMap, + SKShaderTileMode.Clamp, + true, + 1) + .SetName("DisplacementMapTranslate_WholeSource"); + yield return new TestCaseData( + (Func)CreateDisplacementMap, + SKShaderTileMode.Clamp, + true, + 1) + .SetName("DisplacementMapScale_WholeSource"); + yield return new TestCaseData( + (Func)CreateDisplacementMap, + SKShaderTileMode.Clamp, + true, + 1) + .SetName("DisplacementMapRotation_WholeSource"); + } + + private static IEnumerable SolidColorEffects() + { + yield return new TestCaseData((Func)(() => new MosaicEffect())) + .SetName("Mosaic_SolidColorOutput"); + yield return new TestCaseData((Func)(() => new ColorShift())) + .SetName("ColorShift_SolidColorOutput"); + yield return new TestCaseData( + (Func)CreateDisplacementMap) + .SetName("DisplacementMapTranslate_SolidColorOutput"); + yield return new TestCaseData( + (Func)CreateDisplacementMap) + .SetName("DisplacementMapScale_SolidColorOutput"); + yield return new TestCaseData( + (Func)CreateDisplacementMap) + .SetName("DisplacementMapRotation_SolidColorOutput"); + } + + private static FilterEffect CreateDisplacementMap() + where T : DisplacementMapTransform, new() + => new DisplacementMapEffect + { + Transform = { CurrentValue = new T() }, + }; + + private static float[] ReadPixels(Bitmap bitmap) + => bitmap.GetPixelSpan() + .ToArray() + .Select(static bits => (float)BitConverter.UInt16BitsToHalf(bits)) + .ToArray(); + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height) + { + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("A CPU effect-test surface could not be created."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Particles/ParticleRenderNodeAllocationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Particles/ParticleRenderNodeAllocationTests.cs new file mode 100644 index 0000000000..efe75f6416 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Particles/ParticleRenderNodeAllocationTests.cs @@ -0,0 +1,244 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Particles; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Particles; + +[TestFixture] +public sealed class ParticleRenderNodeAllocationTests +{ + private static readonly Size s_frame = new(256, 144); + + [TestCase(9_000f, 9_000f, 360f)] + [TestCase(100_000f, 0f, 40f)] + public void FastOffFrameParticles_DoNotAllocateTheirFullUnionBounds( + float speed, + float speedRandom, + float spread) + { + var emitter = new ParticleEmitter + { + Seed = { CurrentValue = 1234 }, + EmissionRate = { CurrentValue = 24 }, + Lifetime = { CurrentValue = 1.2f }, + MaxParticles = { CurrentValue = 400 }, + Speed = { CurrentValue = speed }, + SpeedRandom = { CurrentValue = speedRandom }, + Gravity = { CurrentValue = 0 }, + Spread = { CurrentValue = spread }, + ParticleSize = { CurrentValue = 14 }, + ParticleColor = { CurrentValue = Colors.OrangeRed }, + }; + using ParticleEmitter.Resource resource = emitter.ToResource( + new CompositionContext(TimeSpan.FromSeconds(1))); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frame, outputScale: 1)) + emitter.Render(context, resource); + + var factory = new BoundedTargetFactory(maximumDimension: 512); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + using var target = new CpuRenderTarget((int)s_frame.Width, (int)s_frame.Height); + using var canvas = new ImmediateCanvas(target, logicalSize: s_frame, intent: RenderIntent.Delivery); + + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + Assert.That(factory.Requests, Has.All.Matches(size => + size.Width <= factory.MaximumDimension && size.Height <= factory.MaximumDimension)); + } + + /// + /// A particle is scaled and then rotated about the source's own centre, so a turned square reaches further + /// along both axes than the square itself. This rectangle is what the layer buffer is allocated from, so a + /// bound that ignores rotation clips the corners off every particle instead of merely mismeasuring them. + /// + [Test] + public void RotatedParticles_AllocateMoreThanTheUnrotatedFootprint() + { + PixelSize unrotated = LargestParticleLayer(initialRotation: 0f); + PixelSize rotated = LargestParticleLayer(initialRotation: 45f); + + Assert.Multiple(() => + { + Assert.That(rotated.Width, Is.GreaterThan(unrotated.Width), + "A 45 degree turn widens a particle's extent; the layer has to follow."); + Assert.That(rotated.Height, Is.GreaterThan(unrotated.Height)); + }); + } + + /// + /// A particle is drawn through its own scale and rotation, so the blit resamples the source. Point + /// sampling replicates whichever texels the sample points land on, so the edge steps through a handful of + /// repeated alphas instead of a gradient - the count of distinct edge alphas separates the two. + /// + [Test] + public void ScaledParticles_AreResampledRatherThanPointSampled() + { + using Bitmap rendered = RenderParticles(initialRotation: 30f, particleSize: 37f); + + var distinct = new HashSet(); + int fractional = 0; + ReadOnlySpan pixels = rendered.GetPixelSpan(); + for (int index = 3; index < pixels.Length; index += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + if (alpha > 0.02f && alpha < 0.98f) + { + fractional++; + distinct.Add(pixels[index]); + } + } + + Assert.Multiple(() => + { + Assert.That(fractional, Is.GreaterThan(0), "The fixture must draw a partially covered edge."); + // Point sampling replicates whichever source texels the sample points land on, so a magnified + // particle's edge repeats a handful of alphas; measured here it was 81 against 314 resampled. + Assert.That(distinct, Has.Count.GreaterThan(150), + "A magnified particle's edge must be resampled, not stepped through repeated source texels."); + }); + } + + private static Bitmap RenderParticles(float initialRotation, float particleSize) + { + var emitter = new ParticleEmitter + { + Seed = { CurrentValue = 11 }, + EmissionRate = { CurrentValue = 4 }, + Lifetime = { CurrentValue = 1.2f }, + MaxParticles = { CurrentValue = 8 }, + Speed = { CurrentValue = 0 }, + SpeedRandom = { CurrentValue = 0 }, + Gravity = { CurrentValue = 0 }, + Spread = { CurrentValue = 0 }, + ParticleSize = { CurrentValue = particleSize }, + ParticleColor = { CurrentValue = Colors.White }, + InitialRotation = { CurrentValue = initialRotation }, + InitialRotationRandom = { CurrentValue = 0 }, + }; + using ParticleEmitter.Resource resource = emitter.ToResource( + new CompositionContext(TimeSpan.FromSeconds(1))); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frame, outputScale: 1)) + emitter.Render(context, resource); + + var factory = new BoundedTargetFactory(maximumDimension: 4096); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + using var target = new CpuRenderTarget((int)s_frame.Width, (int)s_frame.Height); + using (var canvas = new ImmediateCanvas(target, logicalSize: s_frame, intent: RenderIntent.Delivery)) + { + canvas.Clear(); + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + private static PixelSize LargestParticleLayer(float initialRotation) + { + var emitter = new ParticleEmitter + { + Seed = { CurrentValue = 7 }, + EmissionRate = { CurrentValue = 4 }, + Lifetime = { CurrentValue = 1.2f }, + MaxParticles = { CurrentValue = 8 }, + Speed = { CurrentValue = 0 }, + SpeedRandom = { CurrentValue = 0 }, + Gravity = { CurrentValue = 0 }, + Spread = { CurrentValue = 0 }, + ParticleSize = { CurrentValue = 40 }, + ParticleColor = { CurrentValue = Colors.OrangeRed }, + InitialRotation = { CurrentValue = initialRotation }, + InitialRotationRandom = { CurrentValue = 0 }, + }; + using ParticleEmitter.Resource resource = emitter.ToResource( + new CompositionContext(TimeSpan.FromSeconds(1))); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frame, outputScale: 1)) + emitter.Render(context, resource); + + var factory = new BoundedTargetFactory(maximumDimension: 4096); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + using var target = new CpuRenderTarget((int)s_frame.Width, (int)s_frame.Height); + using var canvas = new ImmediateCanvas(target, logicalSize: s_frame, intent: RenderIntent.Delivery); + renderer.Render(canvas); + + Assert.That(factory.Requests, Is.Not.Empty, "The fixture must reach the particle layer allocation."); + return factory.Requests + .OrderByDescending(static size => (long)size.Width * size.Height) + .First(); + } + + private sealed class BoundedTargetFactory(int maximumDimension) : IRenderTargetFactory + { + public int MaximumDimension { get; } = maximumDimension; + + public List Requests { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize size = allocation.DeviceSize; + Requests.Add(size); + return size.Width <= MaximumDimension && size.Height <= MaximumDimension + ? new CpuRenderTarget(size.Width, size.Height) + : null; + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height); + + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create a CPU render target."); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveBoundsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveBoundsTests.cs new file mode 100644 index 0000000000..8542a4338b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveBoundsTests.cs @@ -0,0 +1,153 @@ +using Beutl.Graphics; + +namespace Beutl.UnitTests.Engine.Graphics; + +[TestFixture] +public sealed class PerspectiveBoundsTests +{ + // The shape of transforms.rot3d.depth-0050: a 124x58 drawable centred in a 256x144 frame, whose + // transform Drawable.GetTransformMatrix collapses into a single matrix. + private static readonly Rect s_local = new(0, 0, 124, 58); + + // w(x, y) = 1 + persX * (x - 62) over s_local, so the rectangle crosses the plane at |persX| = 1/62. + private const float StraddleThreshold = 1f / 62f; + + private static Matrix Compose(Matrix inner) => + Matrix.CreateTranslation(-62, -29) * inner * Matrix.CreateTranslation(128, 72); + + private static Matrix Perspective(float persX) => new(1, 0, persX, 0, 1, 0, 0, 0, 1); + + [TestCase(0f)] + [TestCase(0.010f)] + [TestCase(-0.010f)] + [TestCase(0.0150f)] + [TestCase(0.0160f)] + [TestCase(0.0161f)] + public void NonCrossingPerspective_IsBitIdenticalToTheMappedCornerBox(float persX) + { + Matrix matrix = Compose(Perspective(persX)); + Assert.That(matrix.GetTransformDivisor(s_local.TopLeft), Is.GreaterThan(0)); + + Rect expected = s_local.TransformToMappedCornerAABB(matrix); + Rect actual = s_local.TransformToAABB(matrix); + + Assert.Multiple(() => + { + Assert.That(actual.X, Is.EqualTo(expected.X)); + Assert.That(actual.Y, Is.EqualTo(expected.Y)); + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + }); + } + + [Test] + public void EntirelyBehindTheCameraPlane_IsStillTheExactMappedCornerBox() + { + // No crossing means the mapped-corner box is exact whichever side the rectangle sits on. + Matrix matrix = Compose(Perspective(0.05f)); + var behind = new Rect(0, 0, 40, 58); + Assert.That(matrix.GetTransformDivisor(new Point(40, 0)), Is.LessThan(0)); + + Assert.That(behind.TransformToAABB(matrix), Is.EqualTo(behind.TransformToMappedCornerAABB(matrix))); + } + + [Test] + public void CrossingButNeverReachingTheNearPlane_IsEmpty() + { + // Everything in front sits closer than the near plane. The rasterizer still draws it — see + // PerspectiveNearPlaneResidualTests for what Rect.DefaultNearPlane gives up here. + Matrix matrix = Compose(Perspective(0.05f)); + var sliver = new Rect(0, 0, 42.5f, 58); + Assert.That(matrix.GetTransformDivisor(new Point(0, 0)), Is.LessThan(0)); + Assert.That( + matrix.GetTransformDivisor(new Point(42.5f, 0)), + Is.GreaterThan(0).And.LessThan(Rect.DefaultNearPlane)); + + Assert.That(sliver.TransformToAABB(matrix), Is.EqualTo(Rect.Empty)); + } + + [TestCase(0.0163f)] + [TestCase(0.0170f)] + [TestCase(0.0250f)] + [TestCase(0.0500f)] + [TestCase(-0.0170f)] + [TestCase(-0.0250f)] + public void CrossingPerspective_ContainsTheFrontHalfThatTheMappedCornerBoxMisses(float persX) + { + Assert.That(MathF.Abs(persX), Is.GreaterThan(StraddleThreshold)); + Matrix matrix = Compose(Perspective(persX)); + + Rect clipped = s_local.TransformToAABB(matrix); + Rect mappedCorners = s_local.TransformToMappedCornerAABB(matrix); + + int outsideClipped = 0; + int outsideMappedCorners = 0; + foreach (Point sample in SampleFrontHalf(matrix)) + { + Point image = sample.Transform(matrix); + if (!Contains(clipped, image)) outsideClipped++; + if (!Contains(mappedCorners, image)) outsideMappedCorners++; + } + + Assert.Multiple(() => + { + Assert.That(clipped.Width, Is.GreaterThan(0).And.LessThan(float.PositiveInfinity)); + Assert.That(clipped.Height, Is.GreaterThan(0).And.LessThan(float.PositiveInfinity)); + Assert.That(outsideClipped, Is.Zero, + "the clipped box must contain everything in front of the near plane"); + Assert.That(outsideMappedCorners, Is.GreaterThan(0), + "the fixture must exercise a case the mapped-corner box gets wrong"); + }); + } + + [Test] + public void Rotation3DAtDepth50_BoundsTheWedgeAtItsOnlyFiniteExtremity() + { + // Rotation3DTransform(0, 60, 0) at Depth 50 over a 120-wide drawable: z reaches 51.96 > 50. + Matrix matrix = Compose(new Matrix( + MathF.Cos(MathF.PI / 3f), 0, MathF.Sin(MathF.PI / 3f) / 50f, + 0, 1, 0, + 0, 0, 1)); + + Rect clipped = s_local.TransformToAABB(matrix); + + Assert.Multiple(() => + { + Assert.That(clipped.Right, Is.EqualTo(142.9479f).Within(0.001f), + "the image is a left-opening wedge whose only finite extremity is its right edge"); + Assert.That(clipped.Left, Is.LessThan(0), + "the wedge opens left, so the box must reach past the frame origin"); + Assert.That(s_local.TransformToMappedCornerAABB(matrix).Left, Is.EqualTo(142.9479f).Within(0.001f), + "the mapped-corner box puts its LEFT edge where the image ends"); + }); + } + + [TestCase(0f)] + [TestCase(-0.05f)] + public void NonPositiveNearPlane_IsRejected(float nearPlane) + { + Assert.Throws( + () => s_local.TransformToAABB(Compose(Perspective(0.05f)), nearPlane)); + } + + // Only what DefaultNearPlane promises to cover, which is less than the rasterizer draws. + // PerspectiveNearPlaneResidualTests samples down to Rect.RasterizerNearPlane and pins the difference. + private static IEnumerable SampleFrontHalf(Matrix matrix) + { + for (int i = 0; i <= 200; i++) + { + for (int j = 0; j <= 40; j++) + { + var p = new Point( + s_local.Width * i / 200f, + s_local.Height * j / 40f); + if (matrix.GetTransformDivisor(p) >= Rect.DefaultNearPlane) + yield return p; + } + } + } + + private static bool Contains(Rect rect, Point p) + => p.X >= rect.Left - 1e-3f && p.X <= rect.Right + 1e-3f + && p.Y >= rect.Top - 1e-3f && p.Y <= rect.Bottom + 1e-3f; +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveNearPlaneResidualTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveNearPlaneResidualTests.cs new file mode 100644 index 0000000000..7643401638 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/PerspectiveNearPlaneResidualTests.cs @@ -0,0 +1,183 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics; + +/// +/// Pins the content gives up on its own, and that +/// gives up none of it where the request delivers. The default +/// clips 820x in front of , so a near-edge-on layer declares bounds +/// that exclude pixels Skia still draws, and the planner turns declared bounds into a hard raster clip. +/// That is why a transform declares against its delivery region instead of against the bare default. +/// +[TestFixture] +public sealed class PerspectiveNearPlaneResidualTests +{ + private static readonly PixelSize s_frame = new(256, 144); + + [TestCase(1200f, 54f, 60.0f, 500f, 13824, 0)] + [TestCase(1200f, 54f, 89.5f, 500f, 18188, 6480)] + [TestCase(1200f, 54f, 89.8f, 500f, 18340, 13680)] + [TestCase(124f, 58f, 60.0f, 10f, 18232, 2592)] + public void TheDeclaredBounds_ExcludeFramePixelsTheRasterizerStillDraws( + float width, float height, float rotationY, float depth, int expectedDrawn, int expectedExcluded) + { + Matrix matrix = ComposeCenteredRotation(width, height, rotationY, depth); + var local = new Rect(0, 0, width, height); + Rect declared = local.TransformToAABB(matrix); + Rect rasterizerExact = local.TransformToAABB(matrix, Rect.RasterizerNearPlane); + + int drawn = 0; + int outsideDeclared = 0; + int outsideRasterizerExact = 0; + foreach (Point pixel in DrawnFramePixels(matrix, local)) + { + drawn++; + if (!Covers(declared, pixel)) outsideDeclared++; + if (!Covers(rasterizerExact, pixel)) outsideRasterizerExact++; + } + + TestContext.WriteLine( + $"[{width}x{height} @{rotationY}deg depth{depth}] drawn={drawn} outsideDeclared={outsideDeclared} " + + $"outsideExact={outsideRasterizerExact} declared={declared} exactWidth={rasterizerExact.Width}"); + + Assert.Multiple(() => + { + Assert.That(drawn, Is.EqualTo(expectedDrawn), + "the fixture must put the rasterizer's own image inside the frame"); + Assert.That(outsideDeclared, Is.EqualTo(expectedExcluded), + "Rect.DefaultNearPlane's documented residual loss changed"); + Assert.That(outsideRasterizerExact, Is.Zero, + "the loss is the default's alone: the rasterizer's own near plane bounds every drawn pixel"); + }); + } + + /// + /// The delivered box keeps whatever of the exact box either reaches the frame or the pragmatic box + /// already declared, so nothing drawn inside the frame is given up and nothing outside it grows. + /// + [TestCase(1200f, 54f, 60.0f, 500f)] + [TestCase(1200f, 54f, 89.5f, 500f)] + [TestCase(1200f, 54f, 89.8f, 500f)] + [TestCase(124f, 58f, 60.0f, 10f)] + public void TheDeliveredBounds_ExcludeNoFramePixelTheRasterizerDraws( + float width, float height, float rotationY, float depth) + { + Matrix matrix = ComposeCenteredRotation(width, height, rotationY, depth); + var local = new Rect(0, 0, width, height); + var frame = new Rect(0, 0, s_frame.Width, s_frame.Height); + Rect pragmatic = local.TransformToAABB(matrix); + Rect delivered = local.TransformToDeliveredAABB(matrix, frame); + + int drawn = 0; + int outsideDelivered = 0; + foreach (Point pixel in DrawnFramePixels(matrix, local)) + { + drawn++; + if (!Covers(delivered, pixel)) outsideDelivered++; + } + + TestContext.WriteLine( + $"[{width}x{height} @{rotationY}deg depth{depth}] drawn={drawn} outsideDelivered={outsideDelivered} " + + $"deliveredWidth={delivered.Width} pragmaticWidth={pragmatic.Width}"); + + Assert.Multiple(() => + { + Assert.That(outsideDelivered, Is.Zero, + "every pixel the rasterizer draws inside the frame must be declared"); + Assert.That(delivered.Width, Is.LessThanOrEqualTo(pragmatic.Union(frame).Width + 0.001f), + "the delivered box must not cost more density than the pragmatic one already did"); + Assert.That(delivered.Height, Is.LessThanOrEqualTo(pragmatic.Union(frame).Height + 0.001f)); + }); + } + + [TestCase(1200f, 54f, 60.0f, 500f)] + [TestCase(1200f, 54f, 89.5f, 500f)] + [TestCase(1200f, 54f, 89.8f, 500f)] + [TestCase(124f, 58f, 60.0f, 10f)] + public void TheDeclaredFarEdge_SitsWhereTheDocumentedFormulaPutsIt( + float width, float height, float rotationY, float depth) + { + Matrix matrix = ComposeCenteredRotation(width, height, rotationY, depth); + Rect declared = new Rect(0, 0, width, height).TransformToAABB(matrix); + + float radians = MathF.PI * rotationY / 180f; + float expectedLeft = (s_frame.Width / 2f) + - (((1f / Rect.DefaultNearPlane) - 1f) * depth * MathF.Cos(radians) + / MathF.Sin(radians)); + + Assert.That(declared.Left, Is.EqualTo(expectedLeft).Within(0.05f)); + } + + [Test] + public void ClippingAtTheRasterizerNearPlane_WouldCollapseTheWorkingScale() + { + Matrix matrix = ComposeCenteredRotation(1200f, 54f, 60f, 500f); + var local = new Rect(0, 0, 1200f, 54f); + Rect declared = local.TransformToAABB(matrix); + Rect rasterizerExact = local.TransformToAABB(matrix, Rect.RasterizerNearPlane); + + Rect belowCrossover = local.TransformToAABB(matrix, 0.03f); + + float exactScale = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(rasterizerExact, 1f); + float declaredScale = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(declared, 2f); + float belowCrossoverScale = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(belowCrossover, 2f); + TestContext.WriteLine( + $"exactWidth={rasterizerExact.Width} exactScale={exactScale} declaredScale={declaredScale} " + + $"belowCrossoverScale={belowCrossoverScale}"); + + Assert.Multiple(() => + { + Assert.That(rasterizerExact.Width, Is.GreaterThan(4_000_000f)); + Assert.That(exactScale, Is.LessThan(0.005f), + "this is why the default is not the rasterizer's near plane"); + Assert.That(declaredScale, Is.EqualTo(2f), + "and why it is not lower: 0.05 keeps an ordinary 60 degree flip unclamped at a 2x preview"); + Assert.That(belowCrossoverScale, Is.LessThan(2f), + "below the ~0.035 crossover the same flip stops fitting the buffer budget at 2x"); + }); + } + + private static Matrix ComposeCenteredRotation(float width, float height, float rotationY, float depth) + { + float radians = MathF.PI * rotationY / 180f; + var rotation = new Matrix( + MathF.Cos(radians), 0, MathF.Sin(radians) / depth, + 0, 1, 0, + 0, 0, 1); + return Matrix.CreateTranslation(-width / 2, -height / 2) + * rotation + * Matrix.CreateTranslation(s_frame.Width / 2f, s_frame.Height / 2f); + } + + /// + /// The frame pixels Skia covers: back-project every pixel centre and keep it where the forward + /// divisor it recovers reaches and it lands inside the local + /// rectangle. The inverse divisor is the reciprocal of the forward one, so the bound inverts. + /// + private static IEnumerable DrawnFramePixels(Matrix matrix, Rect local) + { + Matrix inverse = matrix.Invert(); + for (int y = 0; y < s_frame.Height; y++) + { + for (int x = 0; x < s_frame.Width; x++) + { + var pixel = new Point(x + 0.5f, y + 0.5f); + float inverseDivisor = inverse.GetTransformDivisor(pixel); + if (inverseDivisor <= 0 || inverseDivisor > 1f / Rect.RasterizerNearPlane) + continue; + + Point source = pixel.Transform(inverse); + if (source.X >= local.Left && source.X <= local.Right + && source.Y >= local.Top && source.Y <= local.Bottom) + { + yield return pixel; + } + } + } + } + + private static bool Covers(Rect rect, Point p) + => p.X >= rect.Left && p.X <= rect.Right && p.Y >= rect.Top && p.Y <= rect.Bottom; +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushIntermediateAllocationIntentTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushIntermediateAllocationIntentTests.cs new file mode 100644 index 0000000000..a0d621e6c0 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushIntermediateAllocationIntentTests.cs @@ -0,0 +1,311 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Media.Source; +using Beutl.Serialization; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Brush-owned intermediates must decide degrade-vs-fail from the explicit , +/// not from the working-scale ceiling that happens to accompany it. +/// +[TestFixture] +public sealed class BrushIntermediateAllocationIntentTests +{ + // Larger than any GPU or raster allocation can satisfy, so RenderTarget.Create returns null fast. + private const float UnallocatableScale = 2_000_000f; + + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + [Test] + public void TileBrush_DeliveryFailsEvenWithAFiniteWorkingScaleCeiling() + { + using ImageBrush.Resource brush = CreateImageBrush(); + var constructor = new BrushConstructor( + s_bounds, brush, BlendMode.SrcOver, RenderIntent.Delivery, + drawableBrushMaterializer: null, scale: UnallocatableScale, maxWorkingScale: 4f); + + Assert.That( + () => constructor.CreateShader(), + Throws.TypeOf() + .With.Message.StartWith("Tile-brush intermediate allocation failed")); + } + + [Test] + public void TileBrush_PreviewDegradesEvenWithoutAWorkingScaleCeiling() + { + using ImageBrush.Resource brush = CreateImageBrush(); + var constructor = new BrushConstructor( + s_bounds, brush, BlendMode.SrcOver, RenderIntent.Preview, + drawableBrushMaterializer: null, scale: UnallocatableScale, + maxWorkingScale: float.PositiveInfinity); + + SKShader? shader = null; + Assert.That(() => shader = constructor.CreateShader(), Throws.Nothing); + Assert.That(shader, Is.Null); + } + + /// + /// A DrawableBrush without a materializer degrades before it ever sizes an intermediate, so the fixture + /// has to supply one: otherwise the null shader proves nothing about the allocation this test is named for. + /// + [Test] + public void DrawableBrush_PreviewDegradesEvenWithoutAWorkingScaleCeiling() + { + using DrawableBrush.Resource brush = CreateDrawableBrush(); + bool materialized = false; + var constructor = new BrushConstructor( + s_bounds, + brush, + BlendMode.SrcOver, + RenderIntent.Preview, + scale: UnallocatableScale, + maxWorkingScale: float.PositiveInfinity, + drawableBrushMaterializer: (_, contentBounds, _) => + { + materialized = true; + return new MaterializedDrawableBrush(CreateOpaqueImage(8, 8), contentBounds); + }); + + SKShader? shader = null; + Assert.That(() => shader = constructor.CreateShader(), Throws.Nothing); + Assert.Multiple(() => + { + Assert.That(materialized, Is.True, + "The fixture must reach the tile-intermediate allocation to test how it degrades."); + Assert.That(shader, Is.Null); + }); + } + + private static SKImage CreateOpaqueImage(int width, int height) + { + using SKSurface surface = SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("The materializer fixture needs a CPU surface."); + surface.Canvas.Clear(SKColors.White); + return surface.Snapshot(); + } + + [Test] + public void UndefinedIntent_IsRejected() + { + Assert.That( + () => new BrushConstructor( + s_bounds, brush: null, BlendMode.SrcOver, (RenderIntent)12345, drawableBrushMaterializer: null), + Throws.TypeOf().With.Property("ParamName").EqualTo("intent")); + } + + [Test] + public void Canvas_HandsItsIntentToTheBrushesItPaintsWith() + { + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas(target, intent: RenderIntent.Delivery); + using ImageBrush.Resource brush = CreateImageBrush(); + + Assert.That(canvas.Intent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That( + () => canvas.DrawRectangle(new Rect(0, 0, UnallocatableScale, UnallocatableScale), brush, pen: null), + Throws.TypeOf() + .With.Message.StartWith("Tile-brush intermediate allocation failed"), + "A delivery canvas must propagate its intent into the brush intermediates it allocates."); + } + + [Test] + public void PreviewCanvas_KeepsDrawingWhenABrushIntermediateCannotBeAllocated() + { + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas(target); + using ImageBrush.Resource brush = CreateImageBrush(); + + Assert.That(canvas.Intent, Is.EqualTo(RenderIntent.Preview)); + Assert.That( + () => canvas.DrawRectangle(new Rect(0, 0, UnallocatableScale, UnallocatableScale), brush, pen: null), + Throws.Nothing); + } + + [Test] + public void CanvasBrushConstructorHelper_CarriesDensityCeilingAndIntent() + { + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas( + target, density: 2f, maxWorkingScale: 4f, intent: RenderIntent.Delivery); + + BrushConstructor constructor = canvas.CreateBrushConstructor( + s_bounds, Brushes.Resource.White, BlendMode.SrcOver); + + Assert.Multiple(() => + { + Assert.That(constructor.Scale, Is.EqualTo(canvas.Density)); + Assert.That(constructor.MaxWorkingScale, Is.EqualTo(canvas.MaxWorkingScale)); + Assert.That(constructor.Intent, Is.EqualTo(RenderIntent.Delivery)); + }); + } + + // The executor-managed canvases are where most intermediate allocation happens; a delivery request + // that reached them as Preview degraded silently. + [TestCase(RenderIntent.Preview)] + [TestCase(RenderIntent.Delivery)] + public void TheOpaqueCallbackCanvasCarriesTheRequestIntent(RenderIntent intent) + { + RenderIntent? observed = null; + using var node = new IntentProbeOpaqueNode(s_bounds, canvas => observed = canvas.Intent); + using RenderNodeRenderer renderer = CreateRenderer(node, intent); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.That(observed, Is.EqualTo(intent)); + } + + [Test] + public void ADeliveryRequestFailsWhenAnOpaqueCallbackCannotAllocateItsBrushIntermediate() + { + using var node = new UnallocatableBrushOpaqueNode(s_bounds); + using RenderNodeRenderer renderer = CreateRenderer(node, RenderIntent.Delivery); + + Assert.That( + () => renderer.Rasterize(), + Throws.TypeOf() + .With.Message.StartWith("Tile-brush intermediate allocation failed")); + } + + [Test] + public void APreviewRequestAbsorbsTheSameOpaqueCallbackAllocationFailure() + { + using var node = new UnallocatableBrushOpaqueNode(s_bounds); + using RenderNodeRenderer renderer = CreateRenderer(node, RenderIntent.Preview); + + Assert.That(() => renderer.Rasterize().Dispose(), Throws.Nothing); + } + + [TestCase(RenderIntent.Preview)] + [TestCase(RenderIntent.Delivery)] + public void TheTargetCommandCanvasCarriesTheRequestIntent(RenderIntent intent) + { + RenderIntent? observed = null; + using var node = new TargetCommandProbeNode(s_bounds, canvas => observed = canvas.Intent); + using RenderNodeRenderer renderer = CreateRenderer(node, intent); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.That(observed, Is.EqualTo(intent)); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode root, RenderIntent intent) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private static ImageBrush.Resource CreateImageBrush() + { + using var bitmap = new Bitmap(4, 4); + using var stream = new MemoryStream(); + bitmap.Save(stream, EncodedImageFormat.Png); + + var source = new ImageSource(); + source.ReadFrom(UriHelper.CreateBase64DataUri("image/png", stream.ToArray())); + var brush = new ImageBrush(source); + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + return brush.ToResource(CompositionContext.Default); + } + + private static DrawableBrush.Resource CreateDrawableBrush() + { + var content = new RectShape(); + content.Width.CurrentValue = 8; + content.Height.CurrentValue = 8; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + return brush.ToResource(CompositionContext.Default); + } + + private sealed class IntentProbeOpaqueNode(Rect bounds, Action probe) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(probe); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class UnallocatableBrushOpaqueNode(Rect bounds) : RenderNode + { + private static readonly RenderResourceSlot s_brushSlot = new(); + + private readonly Brush.Resource _brush = CreateImageBrush(); + + public override void Process(RenderNodeContext context) + { + RenderResource brushToken = context.Borrow(_brush); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + bounds, + static (session, state) => session.UseResource(s_brushSlot, currentBrush => + { + using OpaqueRenderOutput output = session.CreateOutput(state); + output.Canvas.Use(canvas => canvas.DrawRectangle( + new Rect(0, 0, UnallocatableScale, UnallocatableScale), + currentBrush, + pen: null)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_brushSlot.Bind(brushToken)]); + context.Publish(context.OpaqueSource(description)); + } + + protected override void OnDispose(bool disposing) + { + base.OnDispose(disposing); + if (disposing) + _brush.Dispose(); + } + } + + private sealed class TargetCommandProbeNode(Rect bounds, Action probe) : RenderNode + { + public override void Process(RenderNodeContext context) + { + TargetCommandDescription command = TargetCommandDescription.CreateRequestLocal( + session => session.Canvas.Use(probe), + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.None); + context.Publish(context.TargetCommand([], command)); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushSourceBoundsIdentityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushSourceBoundsIdentityTests.cs new file mode 100644 index 0000000000..f43ff3b0f3 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushSourceBoundsIdentityTests.cs @@ -0,0 +1,121 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Pins that the rectangle a brush source is recorded against is request data, not structural plan identity. +/// +/// +/// The forward bounds mapping is a closure built while recording, so the callback method behind it is shared by +/// every brush and carries nothing about the rectangle that closure captured. That is deliberate: a resize must +/// re-run the plan with new geometry rather than compile a second one, while a change to the shape of the graph +/// still has to compile. Both halves are pinned here because only their combination says the split is correct. +/// +[TestFixture] +public sealed class BrushSourceBoundsIdentityTests +{ + private static readonly Rect s_domain = new(0, 0, 400, 300); + + [Test] + public void ResizingABrushFilledShape_ReusesTheStructuralPlan() + { + var shape = new RectShape(); + shape.Width.CurrentValue = 200; + shape.Height.CurrentValue = 150; + shape.Fill.CurrentValue = new LinearGradientBrush(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var root = new DrawableRenderNode(resource); + using RenderNodeRenderer renderer = CreateRenderer(root); + + long afterFirstSize = RecordAndRasterize(shape, resource, root, renderer); + shape.Width.CurrentValue = 320; + shape.Height.CurrentValue = 240; + long afterResize = RecordAndRasterize(shape, resource, root, renderer); + + Assert.Multiple(() => + { + Assert.That(afterFirstSize, Is.EqualTo(1)); + Assert.That(afterResize, Is.EqualTo(1), + "Geometry is request data; a resize must re-run the compiled plan, not compile a second one."); + Assert.That(renderer.StructuralPlanCacheStatistics.Hits, Is.GreaterThan(0)); + }); + } + + [Test] + public void AddingAFilterEffect_CompilesANewStructuralPlan() + { + var shape = new RectShape(); + shape.Width.CurrentValue = 200; + shape.Height.CurrentValue = 150; + shape.Fill.CurrentValue = new LinearGradientBrush(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var root = new DrawableRenderNode(resource); + using RenderNodeRenderer renderer = CreateRenderer(root); + + long beforeEffect = RecordAndRasterize(shape, resource, root, renderer); + shape.FilterEffect.CurrentValue = new Blur(); + long afterEffect = RecordAndRasterize(shape, resource, root, renderer); + + Assert.Multiple(() => + { + Assert.That(beforeEffect, Is.EqualTo(1)); + Assert.That(afterEffect, Is.EqualTo(2), + "A new boundary changes the shape of the graph, which the plan key must separate."); + }); + } + + private static RenderNodeRenderer CreateRenderer(DrawableRenderNode root) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static long RecordAndRasterize( + Drawable shape, + Drawable.Resource resource, + DrawableRenderNode root, + RenderNodeRenderer renderer) + { + bool updateOnly = false; + resource.Update(shape, CompositionContext.Default, ref updateOnly); + using (var context = new GraphicsContext2D(root, s_domain.Size)) + { + shape.Render(context, resource); + } + + renderer.Rasterize().Dispose(); + return renderer.StructuralPlanCacheStatistics.Compilations; + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ComposedSceneRenderCacheTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ComposedSceneRenderCacheTests.cs new file mode 100644 index 0000000000..5bdad288cb --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ComposedSceneRenderCacheTests.cs @@ -0,0 +1,598 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +[NonParallelizable] +[TestFixture] +public sealed class ComposedSceneRenderCacheTests +{ + private static readonly PixelSize s_frameSize = new(240, 160); + private static readonly Rect s_frameBounds = new(default, s_frameSize.ToSize(1)); + + [Test] + public void ComposedScene_CacheHitAndDisabledRenderAreByteIdentical() + { + RenderThread.Dispatcher.Invoke(() => + { + Drawable.Resource[] resources = CreateSceneResources(); + try + { + using var root = new DrawableRenderNode(resources[0]); + using (var context = new GraphicsContext2D(root, s_frameSize.ToSize(1))) + { + context.Clear(); + foreach (Drawable.Resource resource in resources) + { + context.DrawDrawable(resource); + } + } + + GeometryRenderNode? cacheableBackground = Descendants(root) + .OfType() + .FirstOrDefault(); + Assert.That(cacheableBackground, Is.Not.Null, + "the composed fixture must include a cacheable shape subtree"); + cacheableBackground!.Cache.RecordStableRequests(); + + using var cachedRenderer = CreateRenderer(root, useRenderCache: true); + using RenderNodeRasterization first = cachedRenderer.Rasterize(); + using RenderNodeRasterization second = cachedRenderer.Rasterize(); + + using var uncachedRenderer = CreateRenderer(root, useRenderCache: false); + using RenderNodeRasterization control = uncachedRenderer.Rasterize(); + + byte[] firstPixels = GetPixels(first); + byte[] secondPixels = GetPixels(second); + byte[] controlPixels = GetPixels(control); + + Assert.Multiple(() => + { + Assert.That(firstPixels, Has.Some.Not.Zero, + "the composed fixture must produce visible pixels"); + Assert.That(second.Bounds, Is.EqualTo(first.Bounds)); + Assert.That(control.Bounds, Is.EqualTo(first.Bounds)); + Assert.That(secondPixels, Is.EqualTo(firstPixels), + "the cache-hit render must be byte-identical to the cache-miss render. " + + DescribeDifference(firstPixels, secondPixels)); + Assert.That(controlPixels, Is.EqualTo(firstPixels), + "cache policy must not change the composed scene output. " + + DescribeDifference(firstPixels, controlPixels)); + }); + } + finally + { + foreach (Drawable.Resource resource in resources) + { + resource.Dispose(); + } + } + }); + } + + [Test] + public void Text_DeviceGridDependentCacheCandidateIsBypassedAndMatchesDisabledRender() + { + RenderThread.Dispatcher.Invoke(() => + { + var text = new TextBlock + { + FontFamily = { CurrentValue = FontFamily.Default }, + Size = { CurrentValue = 72 }, + Fill = { CurrentValue = Brushes.White }, + Text = { CurrentValue = "ab" }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + }; + using Drawable.Resource resource = text.ToResource(CompositionContext.Default); + + AssertCacheAdmissionParity(resource, expectCacheHit: false); + }); + } + + [Test] + public void FractionalDrawableBrush_DeviceGridDependentCacheCandidateIsBypassed() + { + RenderThread.Dispatcher.Invoke(() => + { + using Drawable.Resource resource = CreateDrawableBrushHost(0.5f); + + AssertCacheAdmissionParity(resource, expectCacheHit: false); + }); + } + + [Test] + public void IntegerPhaseText_CacheAdmissionAndReplayAreByteIdenticalToDisabledRender() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var text = new TextBlock + { + FontFamily = { CurrentValue = FontFamily.Default }, + Size = { CurrentValue = 72 }, + Fill = { CurrentValue = Brushes.White }, + Text = { CurrentValue = "ab" }, + Transform = { CurrentValue = new TranslateTransform(0, 0) }, + }; + using Drawable.Resource resource = text.ToResource(CompositionContext.Default); + + AssertProductionCacheSequenceParity( + resource, + RenderCacheOptions.Enabled, + expectCacheHit: false); + }); + } + + [TestCase(BlendMode.DstIn)] + [TestCase(BlendMode.SrcIn)] + [TestCase(BlendMode.DstATop)] + public void PhaseUnsafeMaskScope_IsBypassedAndMatchesDisabledRender(BlendMode blendMode) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var group = new DrawableGroup(); + group.Children.Add(new RectShape + { + Width = { CurrentValue = 160 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = Brushes.White }, + }); + group.Children.Add(new EllipseShape + { + Width = { CurrentValue = 90 }, + Height = { CurrentValue = 90 }, + Fill = { CurrentValue = Brushes.White }, + BlendMode = { CurrentValue = blendMode }, + }); + using Drawable.Resource resource = group.ToResource(CompositionContext.Default); + + AssertProductionCacheSequenceParity( + resource, + RenderCacheOptions.Enabled, + expectCacheHit: false); + }); + } + + [Test] + public void PlainGroup_WithCenteredVectorContentIsConservativelyBypassed() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var group = new DrawableGroup(); + group.Children.Add(new RectShape + { + Width = { CurrentValue = 160 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = Brushes.White }, + }); + using Drawable.Resource resource = group.ToResource(CompositionContext.Default); + + AssertProductionCacheSequenceParity( + resource, + RenderCacheOptions.Enabled, + expectCacheHit: false); + }); + } + + [Test] + public void PlainGroup_DefaultRenderNodeRendererOptionsDoNotUsePersistentCache() + { + RenderThread.Dispatcher.Invoke(() => + { + var group = new DrawableGroup(); + group.Children.Add(new RectShape + { + Width = { CurrentValue = 160 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = Brushes.White }, + }); + using Drawable.Resource resource = group.ToResource(CompositionContext.Default); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frameSize.ToSize(1))) + { + context.Clear(); + context.DrawDrawable(resource); + } + + GeometryRenderNode? cacheable = Descendants(root) + .OfType() + .FirstOrDefault(); + Assert.That(cacheable, Is.Not.Null, "the plain group must contain an eligible geometry node"); + cacheable!.Cache.RecordStableRequests(); + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_frameBounds, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization first = renderer.Rasterize(); + using RenderNodeRasterization second = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(cacheable.Cache.IsCached, Is.False, + "RenderCacheOptions.Default must not admit a plain group into the persistent cache."); + Assert.That(GetPixels(second), Is.EqualTo(GetPixels(first)), + "Two frames of an unchanged group must agree whether or not a cache served them."); + }); + }); + } + + [Test] + public void DefaultPolicy_DoesNotAdmitPlainAntialiasedGeometryOnGpu() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var ellipse = new EllipseShape + { + Width = { CurrentValue = 91 }, + Height = { CurrentValue = 73 }, + Fill = { CurrentValue = Brushes.White }, + }; + using Drawable.Resource resource = ellipse.ToResource(CompositionContext.Default); + + AssertProductionCacheSequenceParity( + resource, + RenderCacheOptions.Default, + expectCacheHit: false); + }); + } + + [Test] + public void FractionalDrawableBrush_UncachedRenderPreservesAnalyticRectangleCoverage() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource resource = CreateDrawableBrushHost( + translation: 0.25f, + width: 120, + height: 120, + rectangularContent: true); + using Bitmap bitmap = RenderComposedFrame(resource, useRenderCache: false); + + Rgba leftEdge = ReadPixel(bitmap, 60, 80); + Rgba topEdge = ReadPixel(bitmap, 120, 20); + Assert.Multiple(() => + { + Assert.That( + leftEdge.Alpha, + Is.EqualTo(0.75f).Within(0.01f), + "The leading edge must retain 75% device-pixel coverage."); + Assert.That( + topEdge.Alpha, + Is.EqualTo(0.75f).Within(0.01f), + "The top edge must retain 75% device-pixel coverage."); + }); + }); + } + + [Test] + public void FractionalDrawableBrush_HasNoCacheAdmissionFrameFlicker() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource resource = CreateDrawableBrushHost( + translation: 0.25f, + width: 120, + height: 120); + + AssertProductionCacheSequenceParity( + resource, + RenderCacheOptions.Enabled, + expectCacheHit: false); + }); + } + + private static Drawable.Resource CreateDrawableBrushHost( + float translation, + float width = 40, + float height = 30, + bool rectangularContent = false) + { + Drawable content = rectangularContent + ? new RectShape + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + Fill = { CurrentValue = Brushes.White }, + } + : new EllipseShape + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + Fill = { CurrentValue = Brushes.White }, + }; + var brush = new DrawableBrush(content) + { + Stretch = { CurrentValue = Stretch.Fill }, + TileMode = { CurrentValue = TileMode.None }, + DestinationRect = { CurrentValue = RelativeRect.Fill }, + }; + var host = new RectShape + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + Fill = { CurrentValue = brush }, + Transform = { CurrentValue = new TranslateTransform(translation, translation) }, + }; + return host.ToResource(CompositionContext.Default); + } + + private static void AssertCacheAdmissionParity( + Drawable.Resource resource, + bool expectCacheHit) + where TNode : RenderNode + { + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frameSize.ToSize(1))) + { + context.Clear(); + context.DrawDrawable(resource); + } + + TNode? cacheable = Descendants(root).OfType().FirstOrDefault(); + Assert.That(cacheable, Is.Not.Null, $"the fixture must contain a {typeof(TNode).Name}"); + cacheable!.Cache.RecordStableRequests(); + + using var cachedRenderer = CreateRenderer(root, useRenderCache: true); + using RenderNodeRasterization admission = cachedRenderer.Rasterize(); + using RenderNodeRasterization hit = cachedRenderer.Rasterize(); + using var uncachedRenderer = CreateRenderer(root, useRenderCache: false); + using RenderNodeRasterization control = uncachedRenderer.Rasterize(); + + byte[] admissionPixels = GetPixels(admission); + byte[] hitPixels = GetPixels(hit); + byte[] controlPixels = GetPixels(control); + Assert.Multiple(() => + { + Assert.That( + admissionPixels, + Is.EqualTo(controlPixels), + "cache admission must not change output. " + + DescribeDifference(controlPixels, admissionPixels)); + Assert.That( + hitPixels, + Is.EqualTo(controlPixels), + "cache replay must not change output. " + + DescribeDifference(controlPixels, hitPixels)); + }); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode root, + bool useRenderCache) + { + return new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_frameBounds, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + } + + private static void AssertProductionCacheSequenceParity( + Drawable.Resource resource, + RenderCacheOptions cacheOptions, + bool expectCacheHit) + => AssertProductionCacheSequence( + resource, + cacheOptions, + expectCacheHit, + assertPixelParity: true); + + private static void AssertProductionCacheSequence( + Drawable.Resource resource, + RenderCacheOptions cacheOptions, + bool expectCacheHit, + bool assertPixelParity) + { + using var cachedRenderer = new Renderer( + s_frameSize.Width, + s_frameSize.Height, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: null) + { + CacheOptions = cacheOptions, + }; + using var uncachedRenderer = new Renderer(s_frameSize.Width, s_frameSize.Height, RenderIntent.Preview) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + var frameData = new CompositionFrame( + [resource], + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + s_frameSize, + null); + for (int frame = 0; frame < 6; frame++) + { + cachedRenderer.Render(frameData); + uncachedRenderer.Render(frameData); + using Bitmap actual = cachedRenderer.Snapshot(); + using Bitmap control = uncachedRenderer.Snapshot(); + byte[] expected = control.GetPixelSpan().ToArray(); + byte[] actualPixels = actual.GetPixelSpan().ToArray(); + if (assertPixelParity) + { + Assert.That( + HasFiniteVisibleContent(control), + Is.True, + $"cache parity control frame {frame} must contain finite visible content (SC-013 non-vacuity)."); + Assert.That( + actualPixels, + Is.EqualTo(expected), + $"cache policy changed frame {frame}. {DescribeDifference(expected, actualPixels)}"); + } + } + + } + + private static bool HasFiniteVisibleContent(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int i = 3; i < pixels.Length; i += 4) + { + float a = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + if (float.IsFinite(a) && a > 0f) + { + return true; + } + } + return false; + } + + private static Bitmap RenderComposedFrame(Drawable.Resource resource, bool useRenderCache) + { + using var renderer = new Renderer(s_frameSize.Width, s_frameSize.Height, RenderIntent.Preview) + { + CacheOptions = useRenderCache + ? RenderCacheOptions.Enabled + : RenderCacheOptions.Disabled, + }; + renderer.Render(new CompositionFrame( + [resource], + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + s_frameSize, + null)); + return renderer.Snapshot(); + } + + private static IEnumerable Descendants(RenderNode node) + { + yield return node; + if (node is ContainerRenderNode container) + { + foreach (RenderNode child in container.Children) + { + foreach (RenderNode descendant in Descendants(child)) + { + yield return descendant; + } + } + } + } + + private static byte[] GetPixels(RenderNodeRasterization rasterization) + { + Assert.That(rasterization.IsEmpty, Is.False); + return rasterization.Bitmap!.GetPixelSpan().ToArray(); + } + + private static Rgba ReadPixel(Bitmap bitmap, int x, int y) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int offset = ((y * bitmap.Width) + x) * 4; + return new Rgba( + (float)BitConverter.UInt16BitsToHalf(pixels[offset]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 1]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 2]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3])); + } + + private static string DescribeDifference(ReadOnlySpan expected, ReadOnlySpan actual) + { + int differing = 0; + int first = -1; + int maximum = 0; + for (int i = 0; i < expected.Length; i++) + { + int delta = Math.Abs(expected[i] - actual[i]); + if (delta == 0) + continue; + + first = first < 0 ? i : first; + differing++; + maximum = Math.Max(maximum, delta); + } + + return $"{differing} bytes differ; first index {first}; maximum byte delta {maximum}."; + } + + private static Drawable.Resource[] CreateSceneResources() + { + var background = new RectShape + { + Width = { CurrentValue = 240 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = Brushes.CornflowerBlue }, + }; + + var accent = new EllipseShape + { + Width = { CurrentValue = 76 }, + Height = { CurrentValue = 76 }, + Fill = { CurrentValue = Brushes.OrangeRed }, + FilterEffect = + { + CurrentValue = new Brightness + { + Amount = { CurrentValue = 78 }, + }, + }, + Transform = { CurrentValue = new TranslateTransform(44, -18) }, + }; + + var label = new TextBlock + { + FontFamily = { CurrentValue = FontFamily.Default }, + Size = { CurrentValue = 28 }, + Fill = { CurrentValue = Brushes.White }, + Text = { CurrentValue = "CACHE" }, + Transform = { CurrentValue = new TranslateTransform(-28, 30) }, + }; + + CompositionContext context = CompositionContext.Default; + return + [ + background.ToResource(context), + accent.ToResource(context), + label.ToResource(context), + ]; + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private readonly record struct Rgba(float Red, float Green, float Blue, float Alpha); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContainerTopologyCacheInvalidationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContainerTopologyCacheInvalidationTests.cs new file mode 100644 index 0000000000..0f2ff66dc0 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContainerTopologyCacheInvalidationTests.cs @@ -0,0 +1,135 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +/// +/// A container's cached output is built from its children, so replacing, adding, or removing one has to +/// invalidate it. Two freshly built children both sit at change version 0, which is why this needs both a +/// topology signal from the mutators and a topology term in the dependency signature. +/// +public class ContainerTopologyCacheInvalidationTests +{ + [Test] + public void EveryMutator_ReportsTheTopologyChange() + { + using var node = new ContainerRenderNode(); + var first = new ContainerRenderNode(); + var second = new ContainerRenderNode(); + + node.AddChild(first); + Assert.That(node.HasChanges, Is.True, "AddChild changes what the container composes."); + + Settle(node); + node.SetChild(0, second); + Assert.That(node.HasChanges, Is.True, "SetChild changes what the container composes."); + + Settle(node); + node.RemoveChild(second); + Assert.That(node.HasChanges, Is.True, "RemoveChild changes what the container composes."); + + node.AddChild(new ContainerRenderNode()); + Settle(node); + node.RemoveRange(0, 1); + Assert.That(node.HasChanges, Is.True, "RemoveRange changes what the container composes."); + } + + [Test] + public void RemovingNothing_IsNotAChange() + { + using var node = new ContainerRenderNode(); + using var absent = new ContainerRenderNode(); + node.AddChild(new ContainerRenderNode()); + Settle(node); + + node.RemoveChild(absent); + node.RemoveRange(0, 0); + + Assert.That(node.HasChanges, Is.False); + } + + [Test] + public void SetChild_WithTheChildAlreadyThere_IsANoOp() + { + using var node = new ContainerRenderNode(); + var child = new ContainerRenderNode(); + node.AddChild(child); + Settle(node); + + node.SetChild(0, child); + + Assert.Multiple(() => + { + Assert.That(child.IsDisposed, Is.False, "Self-replacement must not dispose the child it stored."); + Assert.That(node.Children[0], Is.SameAs(child)); + Assert.That(node.HasChanges, Is.False, "Nothing changed, so nothing needs re-rendering."); + }); + } + + [Test] + public void BringFrom_ReportsTheTopologyChangeOnBothContainers() + { + using var destination = new ContainerRenderNode(); + using var source = new ContainerRenderNode(); + source.AddChild(new ContainerRenderNode()); + Settle(destination); + Settle(source); + + destination.BringFrom(source); + + Assert.Multiple(() => + { + Assert.That(destination.HasChanges, Is.True); + Assert.That(source.HasChanges, Is.True); + }); + } + + [Test] + public void ReplacingAChild_InvalidatesTheContainerCache() + { + using var node = new ContainerRenderNode(); + node.AddChild(new ContainerRenderNode()); + PublishAndSettle(node); + Assert.That(node.Cache.IsCached, Is.True, "precondition: the container starts with a warm cache"); + + node.SetChild(0, new ContainerRenderNode()); + RenderNodeCacheHelper.BeginLifecycle(node); + + Assert.That(node.Cache.IsCached, Is.False); + } + + [Test] + public void TheDependencySignatureAloneCatchesAnUnreportedChildSwap() + { + using var node = new ContainerRenderNode(); + node.AddChild(new ContainerRenderNode()); + PublishAndSettle(node); + + node.SetChild(0, new ContainerRenderNode()); + // Stands in for any path that restructures the container without reporting it: both children were + // built this instant, so nothing but the child's own identity distinguishes the two topologies. + node.ClearChanges(node.ChangeVersion); + Assert.That(node.HasChanges, Is.False, "precondition: the swap is reported by the signature alone"); + + RenderNodeCacheHelper.BeginLifecycle(node); + + Assert.That(node.Cache.IsCached, Is.False); + } + + private static void Settle(ContainerRenderNode node) + => RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: false); + + private static void PublishAndSettle(ContainerRenderNode node) + { + RenderNodeCacheLifecycle lifecycle = RenderNodeCacheHelper.BeginLifecycle(node); + using (var target = RenderTarget.CreateNull(1, 1)) + { + RenderNodeCache.PublishAtomically( + [RenderCacheTestSupport.CreatePublication(node.Cache, target, new Rect(0, 0, 1, 1))]); + } + + lifecycle.CompleteSuccessfully(advanceWarmup: false); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContributeValuesCacheHitExecutionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContributeValuesCacheHitExecutionTests.cs new file mode 100644 index 0000000000..aa4474ec75 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContributeValuesCacheHitExecutionTests.cs @@ -0,0 +1,321 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +[TestFixture] +public sealed class ContributeValuesCacheHitExecutionTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + private const int Contributors = 8; + + [Test] + public void ContributeValuesCacheHit_DoesNotCompleteThePrunedProducerInput() + { + var producer = new EmptyCombineContributionNode(); + producer.Cache.RecordStableRequests(); + using var node = new ValueConsumerNode(producer); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization miss = renderer.Rasterize(); + Assert.That(producer.Cache.IsCached, Is.True, + "the first render must publish the ContributeValues cache candidate"); + + using RenderNodeRasterization hit = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(miss.Bounds, Is.EqualTo(s_bounds)); + Assert.That(hit.Bounds, Is.EqualTo(s_bounds)); + Assert.That(producer.ExecuteCount, Is.EqualTo(1), + "the ContributeValues cache hit must prune the producer callback"); + }); + } + + [Test] + public void OpaqueExpandCache_PreservesIndependentOutputDensities() + { + var producer = new IndependentDensityProducerNode(); + producer.Cache.RecordStableRequests(); + using var node = new IndependentDensityObserverNode(producer); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization miss = renderer.Rasterize(); + using RenderNodeRasterization hit = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(miss.IsEmpty, Is.False); + Assert.That(hit.IsEmpty, Is.False); + Assert.That(producer.Cache.IsCached, Is.True); + Assert.That(producer.ExecuteCount, Is.EqualTo(1)); + Assert.That(node.ObservedScales, Is.EqualTo(new[] + { + new[] { 1f, 2f }, + new[] { 1f, 2f }, + }), "the cold values and cached replay must retain each output's independent density"); + }); + } + + [Test] + public void OpaqueExpandCache_RejectsActualOutputsOutsidePixelRule() + { + using var node = new IndependentDensityProducerNode(); + node.Cache.RecordStableRequests(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + MaxWorkingScale = 4, + CacheOptions = new RenderCacheOptions( + true, + new RenderCacheRules(MaxPixels: 200, MinPixels: 1)), + Purpose = RenderRequestPurpose.Frame, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization first = renderer.Rasterize(); + using RenderNodeRasterization second = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(first.IsEmpty, Is.False); + Assert.That(second.IsEmpty, Is.False); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(node.ExecuteCount, Is.EqualTo(2)); + }); + } + + /// + /// A replay branch that materializes has to report the use complete, or the input's values stay on the + /// ledger and their pooled target stays leased for the rest of the request. ContributeValues is the one + /// branch that materializes inline instead of delegating to a method whose finally does it, so a chain of + /// them held one live intermediate per link where the whole chain needs one. + /// + [Test] + public void ChainedContributeValues_HandBackEachIntermediateAsItIsDrawn() + { + using var root = new ContainerRenderNode(); + for (int index = 0; index < Contributors; index++) + root.AddChild(new EmptyCombineContributionNode()); + + var factory = new CountingTargetFactory(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = factory, + }); + + renderer.Rasterize().Dispose(); + + // Two: the frame's own target and the one intermediate the contributors take turns with. Holding + // each contributor's target open instead cost one per link, measured at nine for the eight here. + Assert.That(factory.Creates, Is.EqualTo(2), + $"{Contributors} contributors of one size must share the pool, not hold one target each."); + } + + private sealed class CountingTargetFactory : IRenderTargetFactory + { + public int Creates { get; private set; } + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + Creates++; + return new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + } + + private sealed class ValueConsumerNode(RenderNode producer) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle input = context.RecordNode(producer, []).Single(); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + nameof(ValueConsumerNode), + static (session, _) => + { + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(session.Inputs[0].Draw); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueMap(input, description)); + } + + protected override void OnDispose(bool disposing) + { + producer.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class EmptyCombineContributionNode : RenderNode + { + private readonly ExecutionProbe _probe = new(); + private readonly object _probeKey = new(); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) + { + RenderResource probeResource = context.Borrow(_probe); + RenderFragmentHandle combined = context.OpaqueCombine([], OpaqueRenderDescription.Create( + typeof(EmptyCombineContributionNode), + static (session, _) => session.UseResource(ContributeValuesCacheHitExecutionSlots.Probe, probe => + { + probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.FullInputs( + static _ => s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.ZeroOrOne, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [ContributeValuesCacheHitExecutionSlots.Probe.Bind(probeResource)])); + context.Publish(context.ContributeValues(combined)); + } + } + + private sealed class IndependentDensityProducerNode : RenderNode + { + private readonly ExecutionProbe _probe = new(); + private readonly object _probeKey = new(); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) + { + RenderResource probeResource = context.Borrow(_probe); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + typeof(IndependentDensityProducerNode), + static (session, _) => session.UseResource(ContributeValuesCacheHitExecutionSlots.Probe, probe => + { + probe.Record(); + using OpaqueRenderOutput left = session.CreateOutput(new Rect(0, 0, 8, 12), density: 1); + using OpaqueRenderOutput right = session.CreateOutput(new Rect(8, 0, 8, 12), density: 2); + left.Canvas.Use(canvas => canvas.Clear(Colors.Red)); + right.Canvas.Use(canvas => canvas.Clear(Colors.Blue)); + session.Publish(left); + session.Publish(right); + }), + OpaqueRenderBoundsContract.FullInputs(static _ => s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Dynamic, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [ContributeValuesCacheHitExecutionSlots.Probe.Bind(probeResource)]); + RenderFragmentHandle expanded = context.OpaqueExpand([], description); + context.Publish(context.ContributeValues(expanded)); + } + } + + private sealed class IndependentDensityObserverNode(IndependentDensityProducerNode producer) : RenderNode + { + private readonly RecordingProbe _scaleProbe = new(); + private readonly object _probeKey = new(); + + public IReadOnlyList ObservedScales => _scaleProbe.Records; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle input = context.RecordNode(producer, []).Single(); + RenderResource> probeResource = context.Borrow(_scaleProbe); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + typeof(IndependentDensityObserverNode), + static (session, _) => session.UseResource(ContributeValuesCacheHitExecutionSlots.ScaleProbe, probe => + { + probe.Record(session.Inputs + .Select(static item => item.EffectiveScale.Value) + .ToArray()); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => + { + foreach (RenderExecutionInput item in session.Inputs) + item.Draw(canvas); + }); + session.Publish(output); + }), + OpaqueRenderBoundsContract.FullInputs(static _ => s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [ContributeValuesCacheHitExecutionSlots.ScaleProbe.Bind(probeResource)]); + context.Publish(context.OpaqueCombine([input], description)); + } + + protected override void OnDispose(bool disposing) + { + producer.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} + +internal static class ContributeValuesCacheHitExecutionSlots +{ + internal static readonly RenderResourceSlot> ScaleProbe = new(); + internal static readonly RenderResourceSlot Probe = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/DegradedPreviewCachePurityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/DegradedPreviewCachePurityTests.cs new file mode 100644 index 0000000000..5be605c143 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/DegradedPreviewCachePurityTests.cs @@ -0,0 +1,374 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Media.Source; +using Beutl.Serialization; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +/// +/// Pins that a preview frame which dropped part of itself leaves nothing that outlives it. +/// +/// +/// A preview degrades rather than fails when a target cannot be allocated, so the frame on screen is +/// deliberately incomplete. Anything that survives the frame - the persistent node cache above all - would +/// then keep serving those missing pixels long after the memory pressure that caused them is gone. +/// +[TestFixture] +public sealed class DegradedPreviewCachePurityTests +{ + private static readonly Rect s_bounds = new(0, 0, 32, 24); + + [Test] + public void APreviewWhoseBrushDroppedItsIntermediate_PublishesNothingToTheNodeCache() + { + var shape = new RectShape(); + shape.Width.CurrentValue = (float)s_bounds.Width; + shape.Height.CurrentValue = (float)s_bounds.Height; + shape.Fill.CurrentValue = CreateDrawableBrush(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_bounds.Size)) + { + context.Clear(); + context.DrawDrawable(resource); + } + + GeometryRenderNode cacheable = Descendants(root).OfType().First(); + cacheable.Cache.RecordStableRequests(); + // The frame's own target is the only size this factory hands out, so the brush's own intermediate, + // which is sized from the brush content, is declined. + var factory = new SizedTargetFactory(PixelRect.FromRect(s_bounds, 1).Size); + using RenderNodeRenderer renderer = CreateRenderer(root, factory, RenderIntent.Preview); + + renderer.Rasterize().Dispose(); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.GreaterThan(0), + "The fixture must actually make the brush run out of targets."); + Assert.That(cacheable.Cache.IsCached, Is.False, + "A frame whose brush degraded to transparent must not leave those pixels in the cache."); + }); + } + + [Test] + public void APreviewThatDroppedAnAllocation_CommitsNoBackdropSnapshot() + { + var shape = new RectShape(); + shape.Width.CurrentValue = (float)s_bounds.Width; + shape.Height.CurrentValue = (float)s_bounds.Height; + shape.Fill.CurrentValue = CreateDrawableBrush(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var brushRoot = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(brushRoot, s_bounds.Size)) + { + context.Clear(); + context.DrawDrawable(resource); + } + + using var root = new ContainerRenderNode(); + root.AddChild(brushRoot); + var probe = new BackdropSinkProbeNode(); + root.AddChild(probe); + var factory = new SizedTargetFactory(PixelRect.FromRect(s_bounds, 1).Size); + using RenderNodeRenderer renderer = CreateRenderer(root, factory, RenderIntent.Preview); + + renderer.Rasterize().Dispose(); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.GreaterThan(0), + "The fixture must actually make the brush run out of targets."); + Assert.That(probe.Commits, Is.Zero, + "A snapshot sink outlives the frame, so a degraded frame has nothing fit to commit to it."); + }); + } + + /// + /// A tile brush allocates its own intermediate instead of taking a materialization lease, so nothing in + /// the executor sees it run dry. Without a report the frame reads as complete and the transparent hole + /// where the fill should be is exactly what a snapshot sink keeps. + /// + [Test] + public void APreviewWhoseTileBrushDroppedItsIntermediate_CommitsNoBackdropSnapshot() + { + // The frame's own target is the only size this factory hands out, and the tile intermediate is + // sized from the shape, which is smaller. + var factory = new SizedTargetFactory(PixelRect.FromRect(s_bounds, 1).Size); + BackdropSinkProbeNode probe = RenderTileBrushFrame(factory); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.GreaterThan(0), + "The fixture must actually make the tile brush run out of targets."); + Assert.That(probe.Commits, Is.Zero, + "A frame whose tile fill degraded to transparent has nothing fit to outlive it."); + }); + } + + [Test] + public void APreviewWhoseTileBrushKeptItsIntermediate_CommitsItsBackdropSnapshot() + { + var factory = new BudgetedTargetFactory(budget: 16); + BackdropSinkProbeNode probe = RenderTileBrushFrame(factory); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.Zero, "precondition: nothing ran out of targets"); + Assert.That(probe.Commits, Is.EqualTo(1), + "Without this the guard above would hold for a frame that never committed anything."); + }); + } + + private static BackdropSinkProbeNode RenderTileBrushFrame(IRenderTargetFactory factory) + { + var shape = new RectShape(); + shape.Width.CurrentValue = 16; + shape.Height.CurrentValue = 12; + shape.Fill.CurrentValue = CreateTileBrush(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var brushRoot = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(brushRoot, s_bounds.Size)) + { + context.Clear(); + context.DrawDrawable(resource); + } + + using var root = new ContainerRenderNode(); + root.AddChild(brushRoot); + var probe = new BackdropSinkProbeNode(); + root.AddChild(probe); + using RenderNodeRenderer renderer = CreateRenderer(root, factory, RenderIntent.Preview); + + renderer.Rasterize().Dispose(); + return probe; + } + + private static Brush CreateTileBrush() + { + using var bitmap = new Bitmap(4, 4); + using var stream = new MemoryStream(); + bitmap.Save(stream, EncodedImageFormat.Png); + + var source = new ImageSource(); + source.ReadFrom(UriHelper.CreateBase64DataUri("image/png", stream.ToArray())); + var brush = new ImageBrush(source); + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + return brush; + } + + private sealed class BackdropSinkProbeNode : SnapshotBackdropRenderNode, IBuiltInBackdropCaptureSink + { + public int Commits { get; private set; } + + bool IBuiltInBackdropCaptureSink.TryCommitBackdropCapture(Bitmap bitmap, float density) + { + Commits++; + bitmap.Dispose(); + return true; + } + + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + { + Commits++; + bitmap.Dispose(); + } + } + + /// + /// A nested request renders into its own target and the parent composites that target, so a drop the + /// nested body observed makes the parent's output incomplete too. Reporting only a failed nested root + /// acquisition left the parent free to publish those pixels. + /// + [Test] + public void APreviewWhoseNestedRequestDropped_PublishesNothingToTheParentsNodeCache() + { + using RenderNode nestedRoot = CreateBrushRoot(out Drawable.Resource nestedResource); + using (nestedResource) + { + using var parent = new NestedTargetNode(nestedRoot, s_bounds); + parent.Cache.RecordStableRequests(); + var factory = new SizedTargetFactory(PixelRect.FromRect(s_bounds, 1).Size); + using RenderNodeRenderer renderer = CreateRenderer(parent, factory, RenderIntent.Preview); + + renderer.Rasterize().Dispose(); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.GreaterThan(0), + "The fixture must actually make the nested brush run out of targets."); + Assert.That(parent.Cache.IsCached, Is.False, + "A parent compositing a degraded nested request must not cache the result."); + }); + } + } + + private static RenderNode CreateBrushRoot(out Drawable.Resource resource) + { + var shape = new RectShape(); + shape.Width.CurrentValue = (float)s_bounds.Width; + shape.Height.CurrentValue = (float)s_bounds.Height; + shape.Fill.CurrentValue = CreateDrawableBrush(); + resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_bounds.Size)) + { + context.Clear(); + context.DrawDrawable(resource); + } + + return root; + } + + private sealed class NestedTargetNode(RenderNode nestedRoot, Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + _ = context.RecordNestedTarget(nestedRoot, bounds); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + bounds, + static (session, area) => + { + using OpaqueRenderOutput output = session.CreateOutput(area); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.ContributeValues(context.OpaqueSource(description))); + } + } + + private static Brush CreateDrawableBrush() + { + var content = new EllipseShape(); + content.Width.CurrentValue = 12; + content.Height.CurrentValue = 12; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + return brush; + } + + private static IEnumerable Descendants(RenderNode node) + { + foreach (RenderNode child in node.ChildNodes.ToArray()) + { + yield return child; + foreach (RenderNode descendant in Descendants(child)) + yield return descendant; + } + } + + [Test] + public void APreviewThatCannotSpareTheCacheCopy_StillRendersItsFrame() + { + using var node = new IntermediateNode(s_bounds); + node.Cache.RecordStableRequests(); + // Enough for the frame itself, never enough for the extra copy the cache would take. + var factory = new BudgetedTargetFactory(budget: 2); + using RenderNodeRenderer renderer = CreateRenderer(node, factory, RenderIntent.Preview); + + Assert.That(() => renderer.Rasterize().Dispose(), Throws.Nothing, + "A copy that exists only to warm a cache must not fail a frame whose pixels are fine."); + Assert.That(factory.Declined, Is.GreaterThan(0), + "The fixture must actually deny the cache its copy."); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + IRenderTargetFactory factory, + RenderIntent intent) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + + /// A node that publishes one opaque source and therefore needs a buffer of its own. + private sealed class IntermediateNode(Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + bounds, + static (session, area) => + { + using OpaqueRenderOutput output = session.CreateOutput(area); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.ContributeValues(context.OpaqueSource(description))); + } + } + + /// Hands out one exact size and declines every other, so a specific allocation runs dry. + private sealed class SizedTargetFactory(PixelSize allowed) : IRenderTargetFactory + { + public int Declined { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + if (allocation.DeviceSize != allowed) + { + Declined++; + return null; + } + + return new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + } + + private sealed class BudgetedTargetFactory(int budget) : IRenderTargetFactory + { + private int _granted; + + public int Declined { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + if (_granted >= budget) + { + Declined++; + return null; + } + + _granted++; + return new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/OutputIdentityFanOutCostTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/OutputIdentityFanOutCostTests.cs new file mode 100644 index 0000000000..d851af57a6 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/OutputIdentityFanOutCostTests.cs @@ -0,0 +1,122 @@ +using System.Diagnostics; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +/// +/// Pins that an output identity costs what the graph has, not what its paths enumerate. +/// +/// +/// Identities are memoized while they are built, so a fragment consumed by two parents becomes one shared +/// instance and the identity graph is a DAG. Hashing or comparing one by plain recursion walks every path +/// through that DAG rather than every edge, which is exponential in the fan-out and runs once per frame. +/// +[TestFixture] +public sealed class OutputIdentityFanOutCostTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + // Twenty-six doublings are about 67 million paths and 27 edges. Walking the paths took 20 seconds on the + // machine this was written on and walking the edges took none of it, so the bound below separates the two + // by enough that a slower machine still lands on the right side. + private const int Doublings = 26; + + [Test] + + public void HashingAndComparing_CostTheGraphsEdgesNotItsPaths() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + using var root = BuildDoublingChain(Doublings); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + RenderFragmentReference rootFragment = RootOf(graph); + + var elapsed = Stopwatch.StartNew(); + RenderFragmentOutputIdentity first = RenderFragmentOutputIdentity.Create(rootFragment, graph.RequestId); + RenderFragmentOutputIdentity second = RenderFragmentOutputIdentity.Create(rootFragment, graph.RequestId); + int hash = first.GetHashCode(); + bool equal = first.Equals(second); + elapsed.Stop(); + + Assert.Multiple(() => + { + Assert.That(equal, Is.True, "Two identities of one fragment must agree."); + Assert.That(hash, Is.EqualTo(second.GetHashCode())); + Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), + $"{Doublings} doublings must cost their edges, not their paths."); + }); + } + + private static ContainerRenderNode BuildDoublingChain(int doublings) + { + var root = new DoublingContainer(s_bounds); + root.AddChild(new SourceNode(s_bounds)); + for (int level = 0; level < doublings; level++) + { + var next = new DoublingContainer(s_bounds); + next.AddChild(root); + root = next; + } + + return root; + } + + private static RenderRequest CreateRequest(RenderRequestOwner owner) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + owner: owner)); + + private static RenderFragmentReference RootOf(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } + + private sealed class SourceNode(Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + => context.Publish(context.OpaqueSource(DescribeSource(bounds))); + } + + /// Consumes its child's fragment twice, so one identity is reached by two edges. + private sealed class DoublingContainer(Rect bounds) : ContainerRenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle input = context.Inputs[0]; + context.Publish(context.OpaqueCombine([input, input], DescribeCombine(bounds))); + } + } + + private static OpaqueRenderCall DescribeSource(Rect bounds) + => OpaqueRenderDefinition.Create( + Draw, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale) + .Call(bounds); + + private static OpaqueRenderCall DescribeCombine(Rect bounds) + => OpaqueRenderDefinition.Create( + Draw, + OpaqueRenderBoundsContract.FullInputs(_ => bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale) + .Call(bounds); + + private static void Draw(OpaqueRenderSession session, Rect area) + { + using OpaqueRenderOutput output = session.CreateOutput(area); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ProgramCacheTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ProgramCacheTests.cs new file mode 100644 index 0000000000..5a2ffb54b1 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ProgramCacheTests.cs @@ -0,0 +1,667 @@ +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +[TestFixture] +public sealed class ProgramCacheTests +{ + private const string SourceA = "half4 main(float2 p) { return half4(1); }"; + private const string SourceB = "half4 main(float2 p) { return half4(0); }"; + + [Test] + public void GetOrCreate_MergedProgramFactory_ReceivesColdProgramOnly() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + SkslMergedProgram first = SkslSnippetMerger.Merge([new SkslSnippetStage(description)]); + SkslMergedProgram equivalent = SkslSnippetMerger.Merge([new SkslSnippetStage(description)]); + ProgramCacheContextKey context = Context("device-a", "context-a"); + SkslMergedProgram? factoryArgument = null; + int factoryCalls = 0; + + FakeProgram created; + using (ProgramCacheLease lease = cache.GetOrCreate(first, context, Create)) + { + created = lease.Program; + } + + using (ProgramCacheLease warmed = cache.GetOrCreate(equivalent, context, Create)) + { + Assert.Multiple(() => + { + Assert.That(warmed.Program, Is.SameAs(created)); + Assert.That(warmed.IsCacheHit, Is.True); + Assert.That(factoryCalls, Is.EqualTo(1)); + Assert.That(factoryArgument, Is.SameAs(first)); + }); + } + + FakeProgram Create(SkslMergedProgram program) + { + factoryCalls++; + factoryArgument = program; + return new FakeProgram(factoryCalls, 16); + } + } + + [Test] + public void GetOrCreate_WarmedEquivalentIdentity_IsAHitWithoutAnotherCreation() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity firstIdentity = Identity(SourceA); + ShaderProgramIdentity equivalentIdentity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int nextId = 0; + + FakeProgram firstProgram; + using (ProgramCacheLease first = cache.GetOrCreate( + firstIdentity, + context, + () => new FakeProgram(++nextId, 16))) + { + firstProgram = first.Program; + first.Program.Bindings["gain"] = 7; + Assert.Multiple(() => + { + Assert.That(first.IsCacheHit, Is.False); + Assert.That(first.IsTransient, Is.False); + Assert.That(first.Program.ResetCount, Is.EqualTo(1)); + }); + } + + using (ProgramCacheLease warmed = cache.GetOrCreate( + equivalentIdentity, + context, + () => new FakeProgram(++nextId, 16))) + { + Assert.Multiple(() => + { + Assert.That(warmed.IsCacheHit, Is.True); + Assert.That(warmed.IsTransient, Is.False); + Assert.That(warmed.Program, Is.SameAs(firstProgram)); + Assert.That(warmed.Program.Bindings, Is.Empty, + "runtime bindings from the preceding lease must never survive a warmed hit"); + Assert.That(warmed.Program.ResetCount, Is.EqualTo(3), + "a cached program is reset both when returned and immediately before it is leased again"); + }); + } + + ProgramCacheStatistics statistics = cache.Statistics; + Assert.Multiple(() => + { + Assert.That(statistics.Hits, Is.EqualTo(1)); + Assert.That(statistics.Misses, Is.EqualTo(1)); + Assert.That(statistics.Creations, Is.EqualTo(1)); + Assert.That(statistics.RetainedPrograms, Is.EqualTo(1)); + Assert.That(statistics.RetainedBytes, Is.EqualTo(16)); + }); + } + + [Test] + public void GetOrCreate_SameDescriptionLoweredForDifferentBackends_DoesNotCollide() + { + const string spirvSource = + "#version 450\nlayout(location=0) out vec4 color; void main() { color = vec4(1); }"; + var lowering = new SpirvShaderLowering( + spirvSource, + [], + supportsBitExactSkiaHandoff: false); + ShaderDescription description = ShaderDescription.CurrentPixel( + new SkslSource( + "half4 apply(half4 color) { return color; }", + ShaderDescriptionKind.CurrentPixel), + lowering, + bindings: null); + ShaderProgramIdentity skslIdentity = ShaderProgramIdentity.CreateSksl( + spirvSource, + [], + SkslBackendBudgetResolver.SpirvVulkan); + ShaderProgramIdentity spirvIdentity = ShaderProgramIdentity.CreateSpirv( + description, + lowering, + SkslBackendBudgetResolver.SpirvVulkan); + using var cache = CreateCache(maxRetainedBytes: 64); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int nextId = 0; + + FakeProgram skslProgram; + using (ProgramCacheLease sksl = cache.GetOrCreate( + skslIdentity, + context, + () => new FakeProgram(++nextId, 8))) + { + skslProgram = sksl.Program; + } + + using ProgramCacheLease spirv = cache.GetOrCreate( + spirvIdentity, + context, + () => new FakeProgram(++nextId, 8)); + + Assert.Multiple(() => + { + Assert.That(skslIdentity, Is.Not.EqualTo(spirvIdentity)); + Assert.That(spirv.Program, Is.Not.SameAs(skslProgram)); + Assert.That(spirv.IsCacheHit, Is.False); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.RetainedPrograms, Is.EqualTo(2)); + }); + } + + [Test] + public void GetOrCreate_HashBucketCollision_UsesFullSourceAndBindingSignature() + { + using var cache = CreateCache(maxRetainedBytes: 128); + const int forcedBucket = 12345; + ShaderProgramIdentity sourceA = Identity(SourceA, forcedBucket); + ShaderProgramIdentity sourceB = Identity(SourceB, forcedBucket); + ShaderProgramIdentity differentSignature = Identity( + SourceA, + forcedBucket, + [new SkslMergedBindingLayout( + 0, + 0, + SkslBindingKind.Uniform, + "gain", + "__beutl_s0_gain", + "float", + null, + null)]); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int nextId = 0; + + FakeProgram first; + using (ProgramCacheLease lease = cache.GetOrCreate( + sourceA, + context, + () => new FakeProgram(++nextId, 8))) + { + first = lease.Program; + } + + using (ProgramCacheLease lease = cache.GetOrCreate( + sourceB, + context, + () => new FakeProgram(++nextId, 8))) + { + Assert.That(lease.Program, Is.Not.SameAs(first)); + } + + using (ProgramCacheLease lease = cache.GetOrCreate( + differentSignature, + context, + () => new FakeProgram(++nextId, 8))) + { + Assert.That(lease.Program, Is.Not.SameAs(first)); + } + + using (ProgramCacheLease lease = cache.GetOrCreate( + Identity(SourceA, forcedBucket), + context, + () => new FakeProgram(++nextId, 8))) + { + Assert.That(lease.Program, Is.SameAs(first)); + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(3)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(3)); + Assert.That(cache.Statistics.RetainedPrograms, Is.EqualTo(3)); + }); + } + + [Test] + public void GetOrCreate_ReentrantExactKey_UsesResetTransientWithoutCorruptingOuterBindings() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + var created = new List(); + + FakeProgram outerProgram; + using (ProgramCacheLease outer = cache.GetOrCreate(identity, context, Create)) + { + outerProgram = outer.Program; + outer.Program.Bindings["outer"] = 41; + using (ProgramCacheLease inner = cache.GetOrCreate(identity, context, Create)) + { + Assert.Multiple(() => + { + Assert.That(inner.IsCacheHit, Is.True, + "the exact cached identity was found even though its mutable instance was already leased"); + Assert.That(inner.IsTransient, Is.True); + Assert.That(inner.Program, Is.Not.SameAs(outer.Program)); + Assert.That(inner.Program.Bindings, Is.Empty); + }); + + inner.Program.Bindings["inner"] = 99; + Assert.That(outer.Program.Bindings["outer"], Is.EqualTo(41)); + } + + Assert.Multiple(() => + { + Assert.That(created[1].Bindings, Is.Empty); + Assert.That(created[1].DisposeCount, Is.EqualTo(1)); + Assert.That(outer.Program.Bindings["outer"], Is.EqualTo(41)); + }); + } + + using (ProgramCacheLease warmed = cache.GetOrCreate(identity, context, Create)) + { + Assert.Multiple(() => + { + Assert.That(warmed.IsCacheHit, Is.True); + Assert.That(warmed.IsTransient, Is.False); + Assert.That(warmed.Program, Is.SameAs(outerProgram)); + Assert.That(warmed.Program.Bindings, Is.Empty); + }); + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Hits, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(1)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(2)); + }); + + FakeProgram Create() + { + var program = new FakeProgram(created.Count + 1, 16); + created.Add(program); + return program; + } + } + + [Test] + public void GetOrCreate_SharedImmutableMode_ReusesOneProgramUntilTheLastLeaseReturns() + { + using var cache = new ProgramCache( + resetRuntimeBindings: static _ => { }, + retainedByteSize: static program => program.RetainedBytes, + maxRetainedBytes: 64, + shareLeasedPrograms: true); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int creations = 0; + + ProgramCacheLease outer = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++creations, 16)); + ProgramCacheLease inner = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++creations, 16)); + FakeProgram program = outer.Program; + + Assert.Multiple(() => + { + Assert.That(inner.Program, Is.SameAs(program)); + Assert.That(inner.IsCacheHit, Is.True); + Assert.That(inner.IsTransient, Is.False); + Assert.That(creations, Is.EqualTo(1)); + }); + + Assert.That(cache.EvictContext("device-a", "context-a"), Is.EqualTo(1)); + outer.Dispose(); + Assert.That(program.DisposeCount, Is.Zero); + inner.Dispose(); + + Assert.Multiple(() => + { + Assert.That(program.DisposeCount, Is.EqualTo(1)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(1)); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + }); + } + + [Test] + public void SynchronizeContext_EvictsProgramsFromThePreviousDestinationContext() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey firstContext = Context("device-a", "context-a"); + FakeProgram program; + using (ProgramCacheLease lease = cache.GetOrCreate( + identity, + firstContext, + () => new FakeProgram(1, 16))) + { + program = lease.Program; + } + + Assert.Multiple(() => + { + Assert.That(cache.SynchronizeContext("device-a", "context-a"), Is.Zero); + Assert.That(cache.SynchronizeContext("device-a", "context-b"), Is.EqualTo(1)); + Assert.That(program.DisposeCount, Is.EqualTo(1)); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + }); + } + + [Test] + public void GetOrCreate_ContextCompileContract_IsPartOfTheFullKey() + { + using var cache = CreateCache(maxRetainedBytes: 128); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey[] contexts = + [ + Context("device-a", "context-a", capability: "skia-v1", format: "rgba16f", options: "default"), + Context("device-b", "context-a", capability: "skia-v1", format: "rgba16f", options: "default"), + Context("device-a", "context-b", capability: "skia-v1", format: "rgba16f", options: "default"), + Context("device-a", "context-a", capability: "skia-v2", format: "rgba16f", options: "default"), + Context("device-a", "context-a", capability: "skia-v1", format: "rgba8", options: "default"), + Context("device-a", "context-a", capability: "skia-v1", format: "rgba16f", options: "optimized"), + ]; + int nextId = 0; + + foreach (ProgramCacheContextKey context in contexts) + { + using ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 8)); + Assert.That(lease.IsCacheHit, Is.False); + } + + using ProgramCacheLease warmed = cache.GetOrCreate( + identity, + Context("device-a", "context-a", capability: "skia-v1", format: "rgba16f", options: "default"), + () => new FakeProgram(++nextId, 8)); + Assert.Multiple(() => + { + Assert.That(warmed.IsCacheHit, Is.True); + Assert.That(cache.Statistics.Misses, Is.EqualTo(contexts.Length)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(contexts.Length)); + }); + } + + [Test] + public void ByteBudget_EvictsLeastRecentlyUsedAvailableProgram() + { + using var cache = CreateCache(maxRetainedBytes: 20); + ProgramCacheContextKey context = Context("device-a", "context-a"); + ShaderProgramIdentity a = Identity(SourceA + "// a"); + ShaderProgramIdentity b = Identity(SourceA + "// b"); + ShaderProgramIdentity c = Identity(SourceA + "// c"); + int nextId = 0; + + FakeProgram programA = AcquireAndReturn(a); + FakeProgram programB = AcquireAndReturn(b); + Assert.That(AcquireAndReturn(a), Is.SameAs(programA), "A is now the most recently used entry"); + _ = AcquireAndReturn(c); + + Assert.Multiple(() => + { + Assert.That(programA.DisposeCount, Is.Zero); + Assert.That(programB.DisposeCount, Is.EqualTo(1), "B is the least recently used available entry"); + Assert.That(cache.Statistics.RetainedPrograms, Is.EqualTo(2)); + Assert.That(cache.Statistics.RetainedBytes, Is.EqualTo(20)); + Assert.That(cache.Statistics.Evictions, Is.EqualTo(1)); + }); + + using ProgramCacheLease recreatedB = cache.GetOrCreate( + b, + context, + () => new FakeProgram(++nextId, 10)); + Assert.Multiple(() => + { + Assert.That(recreatedB.IsCacheHit, Is.False); + Assert.That(recreatedB.Program, Is.Not.SameAs(programB)); + }); + + FakeProgram AcquireAndReturn(ShaderProgramIdentity identity) + { + using ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 10)); + return lease.Program; + } + } + + [Test] + public void OversizedProgram_IsTransientAndNeverBecomesAWarmedHit() + { + using var cache = CreateCache(maxRetainedBytes: 8); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + var programs = new List(); + + for (int i = 0; i < 2; i++) + { + using ProgramCacheLease lease = cache.GetOrCreate(identity, context, Create); + Assert.Multiple(() => + { + Assert.That(lease.IsCacheHit, Is.False); + Assert.That(lease.IsTransient, Is.True); + }); + } + + Assert.Multiple(() => + { + Assert.That(programs, Has.All.Matches(static program => program.DisposeCount == 1)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(2)); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + Assert.That(cache.Statistics.RetainedBytes, Is.Zero); + }); + + FakeProgram Create() + { + var program = new FakeProgram(programs.Count + 1, 9); + programs.Add(program); + return program; + } + } + + [Test] + public void EvictContextAndDevice_RemoveOnlyMatchingEntries() + { + using var cache = CreateCache(maxRetainedBytes: 128); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey a1 = Context("device-a", "context-1"); + ProgramCacheContextKey a2 = Context("device-a", "context-2"); + ProgramCacheContextKey b1 = Context("device-b", "context-1"); + int nextId = 0; + + FakeProgram programA1 = AcquireAndReturn(a1); + FakeProgram programA2 = AcquireAndReturn(a2); + FakeProgram programB1 = AcquireAndReturn(b1); + + Assert.That(cache.EvictContext("device-a", "context-1"), Is.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(programA1.DisposeCount, Is.EqualTo(1)); + Assert.That(programA2.DisposeCount, Is.Zero); + Assert.That(programB1.DisposeCount, Is.Zero); + }); + + Assert.That(cache.EvictDevice("device-a"), Is.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(programA2.DisposeCount, Is.EqualTo(1)); + Assert.That(programB1.DisposeCount, Is.Zero); + Assert.That(cache.Statistics.RetainedPrograms, Is.EqualTo(1)); + }); + + using ProgramCacheLease warmB = cache.GetOrCreate( + identity, + b1, + () => new FakeProgram(++nextId, 8)); + Assert.That(warmB.Program, Is.SameAs(programB1)); + + FakeProgram AcquireAndReturn(ProgramCacheContextKey context) + { + using ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 8)); + return lease.Program; + } + } + + [Test] + public void EvictDevice_WhileLeased_DefersDisposalAndMakesLaterLookupMiss() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int nextId = 0; + ProgramCacheLease outer = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 16)); + FakeProgram invalidated = outer.Program; + + Assert.That(cache.EvictDevice("device-a"), Is.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(invalidated.DisposeCount, Is.Zero, + "device loss cannot invalidate a mutable program while its outer lease is still executing"); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + }); + + using (ProgramCacheLease replacement = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 16))) + { + Assert.Multiple(() => + { + Assert.That(replacement.IsCacheHit, Is.False); + Assert.That(replacement.Program, Is.Not.SameAs(invalidated)); + }); + } + + outer.Dispose(); + Assert.Multiple(() => + { + Assert.That(invalidated.DisposeCount, Is.EqualTo(1)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(2)); + }); + } + + [Test] + public void RuntimeResetFailure_EvictsAndDisposesPoisonedProgram() + { + using var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + int nextId = 0; + ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 16)); + FakeProgram poisoned = lease.Program; + poisoned.ThrowOnNextReset = true; + + Assert.Throws(lease.Dispose); + Assert.Multiple(() => + { + Assert.That(poisoned.DisposeCount, Is.EqualTo(1)); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + }); + + using ProgramCacheLease replacement = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(++nextId, 16)); + Assert.That(replacement.IsCacheHit, Is.False); + } + + [Test] + public void Dispose_WithActiveLease_DefersItsProgramAndRejectsLaterLookup() + { + var cache = CreateCache(maxRetainedBytes: 64); + ShaderProgramIdentity identity = Identity(SourceA); + ProgramCacheContextKey context = Context("device-a", "context-a"); + ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => new FakeProgram(1, 16)); + FakeProgram program = lease.Program; + + cache.Dispose(); + Assert.Multiple(() => + { + Assert.That(program.DisposeCount, Is.Zero); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + Assert.Throws(() => cache.GetOrCreate( + identity, + context, + () => new FakeProgram(2, 16))); + }); + + lease.Dispose(); + cache.Dispose(); + Assert.That(program.DisposeCount, Is.EqualTo(1)); + } + + private static ProgramCache CreateCache(long maxRetainedBytes) + => new( + static program => program.ResetRuntimeBindings(), + static program => program.RetainedBytes, + maxRetainedBytes); + + private static ProgramCacheContextKey Context( + object device, + object context, + object? capability = null, + string format = "linear-premul-rgba16f", + object? options = null) + => new( + device, + context, + capability ?? "skia-default", + format, + options ?? "default"); + + private static ShaderProgramIdentity Identity( + string source, + int? bucketHashOverride = null, + IReadOnlyList? bindings = null) + => ShaderProgramIdentity.CreateSksl( + source, + bindings ?? [], + SkslBackendBudget.Unlimited, + bucketHashOverride); + + private sealed class FakeProgram(int id, long retainedBytes) : IDisposable + { + public int Id { get; } = id; + + public long RetainedBytes { get; } = retainedBytes; + + public Dictionary Bindings { get; } = []; + + public int ResetCount { get; private set; } + + public int DisposeCount { get; private set; } + + public bool ThrowOnNextReset { get; set; } + + public void ResetRuntimeBindings() + { + ResetCount++; + Bindings.Clear(); + if (ThrowOnNextReset) + { + ThrowOnNextReset = false; + throw new InvalidOperationException("Injected runtime reset failure."); + } + } + + public void Dispose() + { + DisposeCount++; + } + + public override string ToString() => $"FakeProgram {Id}"; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheCandidateTopologyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheCandidateTopologyTests.cs new file mode 100644 index 0000000000..1ba3c279e4 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheCandidateTopologyTests.cs @@ -0,0 +1,424 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +/// +/// Pins to the pair-wise reachability +/// semantics it replaced. The topology drives cache-candidate supersedence, so a divergence here +/// silently changes which candidates are cached rather than failing loudly. +/// +[TestFixture] +[NonParallelizable] +public sealed class RenderCacheCandidateTopologyTests +{ + private static readonly PixelSize s_frameSize = new(240, 160); + private static readonly Rect s_syntheticBounds = new(0, 0, 64, 64); + + private static readonly string[] s_graphs = + [ + "RepresentativeScene", + "Shapes5", + "Shapes10", + "Shapes25", + "Shapes50", + "Shapes100", + "SharedFragmentId", + "DiamondInputs", + "DeepChain", + "DisconnectedRoots", + ]; + + private static GraphCase CreateGraph(string name) => name switch + { + "RepresentativeScene" => RepresentativeScene(), + "Shapes5" => ShapeScene(5), + "Shapes10" => ShapeScene(10), + "Shapes25" => ShapeScene(25), + "Shapes50" => ShapeScene(50), + "Shapes100" => ShapeScene(100), + "SharedFragmentId" => SharedFragmentIdCandidates(), + "DiamondInputs" => DiamondInputs(), + "DeepChain" => DeepChain(), + "DisconnectedRoots" => DisconnectedRoots(), + _ => throw new ArgumentOutOfRangeException(nameof(name), name, null), + }; + + [TestCaseSource(nameof(s_graphs))] + public void BuildCandidateTopology_MatchesThePairWiseReference(string name) + { + GraphCase graphCase = RenderThread.Dispatcher.Invoke(() => CreateGraph(name)); + try + { + RenderCacheResolver.CandidateTopology actual = RenderCacheResolver.BuildCandidateTopology( + graphCase.Graph, + graphCase.References); + ReferenceTopology expected = BuildReferenceTopology(graphCase.Graph, graphCase.References); + + TestContext.Out.WriteLine( + $"fragments={graphCase.Graph.Fragments.Length} candidates={graphCase.Graph.CacheCandidates.Length} " + + $"descendantPairs={expected.Descendants.Values.Sum(static set => set.Count)}"); + + Assert.Multiple(() => + { + Assert.That( + actual.Descendants.Keys, + Is.EquivalentTo(expected.Descendants.Keys), + "every candidate must get exactly one descendant set"); + foreach ((RenderCacheCandidateId parent, HashSet reference) + in expected.Descendants) + { + Assert.That( + actual.Descendants[parent], + Is.EquivalentTo(reference), + $"descendants of {parent} must match the pair-wise reference"); + } + + Assert.That(actual.ParentFirst, Has.Length.EqualTo(expected.ParentFirst.Length)); + for (int index = 0; index < Math.Min(actual.ParentFirst.Length, expected.ParentFirst.Length); index++) + { + Assert.That( + actual.ParentFirst[index], + Is.SameAs(expected.ParentFirst[index]), + $"parent-first entry {index} must match, so tie-breaking stays observable"); + } + }); + } + finally + { + graphCase.Dispose(); + } + } + + [Test] + public void BuildCandidateTopology_DoesNotGrowQuadraticallyWithCandidateCount() + { + using GraphCase small = RenderThread.Dispatcher.Invoke(() => ShapeScene(25)); + using GraphCase large = RenderThread.Dispatcher.Invoke(() => ShapeScene(100)); + + long smallBytes = MeasureTopologyBytes(small); + long largeBytes = MeasureTopologyBytes(large); + double candidateRatio = (double)large.Graph.CacheCandidates.Length / small.Graph.CacheCandidates.Length; + double byteRatio = (double)largeBytes / smallBytes; + + TestContext.Out.WriteLine( + $"candidates {small.Graph.CacheCandidates.Length} -> {large.Graph.CacheCandidates.Length} " + + $"({candidateRatio:F2}x), bytes {smallBytes} -> {largeBytes} ({byteRatio:F2}x)"); + + Assert.That( + byteRatio, + Is.LessThan(candidateRatio * candidateRatio / 2), + "one traversal per candidate must keep topology allocation well below the pair-wise quadratic"); + } + + private static long MeasureTopologyBytes(GraphCase graphCase) + { + for (int round = 0; round < 3; round++) + _ = RenderCacheResolver.BuildCandidateTopology(graphCase.Graph, graphCase.References); + + long best = long.MaxValue; + for (int round = 0; round < 5; round++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + _ = RenderCacheResolver.BuildCandidateTopology(graphCase.Graph, graphCase.References); + best = Math.Min(best, GC.GetAllocatedBytesForCurrentThread() - before); + } + + return best; + } + + private sealed record ReferenceTopology( + Dictionary> Descendants, + RenderCacheCandidate[] ParentFirst); + + /// + /// The implementation this fixture pins, transcribed from the revision that ran one full DFS per + /// ordered candidate pair. Kept verbatim so a divergence is attributable to the production change. + /// + private static ReferenceTopology BuildReferenceTopology( + RecordedRenderGraph graph, + IReadOnlyDictionary references) + { + var result = new Dictionary>(); + foreach (RenderCacheCandidate parent in graph.CacheCandidates) + { + var descendants = new HashSet(); + foreach (RenderCacheCandidate child in graph.CacheCandidates) + { + if (parent.Id == child.Id) + continue; + if (parent.FragmentId == child.FragmentId) + { + if (parent.AuthoredOrder > child.AuthoredOrder) + descendants.Add(child.Id); + continue; + } + + if (DependsOn(references[parent.FragmentId], references[child.FragmentId])) + descendants.Add(child.Id); + } + + result.Add(parent.Id, descendants); + } + + RenderCacheCandidate[] parentFirst = [.. graph.CacheCandidates + .OrderByDescending(candidate => result[candidate.Id].Count) + .ThenByDescending(static candidate => candidate.AuthoredOrder)]; + return new ReferenceTopology(result, parentFirst); + } + + private static bool DependsOn( + RenderFragmentReference parent, + RenderFragmentReference possibleDescendant) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var pending = new Stack(parent.Inputs); + while (pending.TryPop(out RenderFragmentReference? current)) + { + if (ReferenceEquals(current, possibleDescendant)) + return true; + if (!visited.Add(current)) + continue; + foreach (RenderFragmentReference input in current.Inputs) + pending.Push(input); + } + + return false; + } + + private sealed class GraphCase( + RecordedRenderGraph graph, + Dictionary references, + IDisposable[] owned) : IDisposable + { + public RecordedRenderGraph Graph { get; } = graph; + + public Dictionary References { get; } = references; + + public void Dispose() + { + foreach (IDisposable disposable in owned) + disposable.Dispose(); + } + } + + private static GraphCase RepresentativeScene() + => RecordDrawables(RenderCacheCandidateTopologyScenes.CreateRepresentativeScene()); + + private static GraphCase ShapeScene(int shapes) + => RecordDrawables(RenderCacheCandidateTopologyScenes.CreateShapeScene(shapes, s_frameSize)); + + private static GraphCase RecordDrawables(Drawable.Resource[] resources) + { + var root = new DrawableRenderNode(resources[0]); + using (var context = new GraphicsContext2D(root, s_frameSize.ToSize(1))) + { + context.Clear(); + foreach (Drawable.Resource resource in resources) + context.DrawDrawable(resource); + } + + WarmCaches(root, []); + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + cachePolicy: RenderCacheOptions.Enabled, + targetDomain: new Rect(default, s_frameSize.ToSize(1)))); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + Assert.That(graph.CacheCandidates, Is.Not.Empty, "the recorded scene must produce cache candidates"); + return new GraphCase(graph, IndexReferences(graph), [request, root, .. resources]); + } + + private static void WarmCaches(RenderNode current, HashSet seen) + { + if (current.IsDisposed || !seen.Add(current)) + return; + + ReadOnlySpan children = current.ChildNodes; + for (int i = 0; i < children.Length; i++) + WarmCaches(children[i], seen); + + current.Cache.RecordStableRequests(); + current.HasChanges = false; + } + + private static Dictionary IndexReferences( + RecordedRenderGraph graph) + { + var references = new Dictionary(graph.Fragments.Length); + foreach (RecordedRenderFragment fragment in graph.Fragments) + { + if (fragment.Payload is RenderFragmentReference reference) + references.Add(fragment.Id, reference); + } + + return references; + } + + private static GraphCase SharedFragmentIdCandidates() + { + RenderFragmentReference leaf = Pure(); + RenderFragmentReference middle = Pure([leaf]); + RenderFragmentReference root = Pure([middle]); + return BuildSynthetic( + [leaf, middle, root], + [root], + [(leaf, new object()), (middle, new object()), (middle, new object()), (root, new object())]); + } + + private static GraphCase DiamondInputs() + { + RenderFragmentReference shared = Pure(); + RenderFragmentReference left = Pure([shared]); + RenderFragmentReference right = Pure([shared]); + RenderFragmentReference root = Pure([left, right]); + return BuildSynthetic( + [shared, left, right, root], + [root], + [(shared, new object()), (left, new object()), (right, new object()), (root, new object())]); + } + + private static GraphCase DeepChain() + { + var chain = new List(); + RenderFragmentReference current = Pure(); + chain.Add(current); + for (int index = 0; index < 39; index++) + { + current = Pure([current]); + chain.Add(current); + } + + return BuildSynthetic( + chain, + [current], + [.. chain.Select(static reference => (reference, (object)new object()))]); + } + + private static GraphCase DisconnectedRoots() + { + RenderFragmentReference leftLeaf = Pure(); + RenderFragmentReference leftRoot = Pure([leftLeaf]); + RenderFragmentReference rightLeaf = Pure(); + RenderFragmentReference rightRoot = Pure([rightLeaf]); + return BuildSynthetic( + [leftLeaf, leftRoot, rightLeaf, rightRoot], + [leftRoot, rightRoot], + [ + (leftLeaf, new object()), + (leftRoot, new object()), + (rightLeaf, new object()), + (rightRoot, new object()), + ]); + } + + private static GraphCase BuildSynthetic( + IReadOnlyList references, + IReadOnlyList roots, + IReadOnlyList<(RenderFragmentReference Reference, object Key)> candidates) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + cachePolicy: RenderCacheOptions.Enabled, + targetDomain: s_syntheticBounds)); + var builder = new RecordedRenderGraphBuilder(request.Id); + foreach (RenderFragmentReference reference in references) + { + RenderProvenanceId provenanceId = builder.AddProvenance(reference, "topology-test"); + RenderValueId[] valueInputs = reference.Inputs.SelectMany(static item => item.ValueIds).ToArray(); + reference.ValueIds = [builder.AddValue(valueInputs, provenanceId, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenanceId, reference); + } + + foreach ((RenderFragmentReference reference, object key) in candidates) + builder.AddCacheCandidate(reference.Id!.Value, key); + foreach (RenderFragmentReference root in roots) + builder.PublishRoot(root.Id!.Value); + + RecordedRenderGraph graph = builder.Build(); + return new GraphCase(graph, IndexReferences(graph), [request]); + } + + private static RenderFragmentReference Pure(IReadOnlyList? inputs = null) + => new( + RenderFragmentKind.ContributeValues, + s_syntheticBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs ?? [], + payload: null, + static _ => true); +} + +internal static class RenderCacheCandidateTopologyScenes +{ + public static Drawable.Resource[] CreateRepresentativeScene() + { + var background = new RectShape + { + Width = { CurrentValue = 240 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = Brushes.CornflowerBlue }, + }; + + var accent = new EllipseShape + { + Width = { CurrentValue = 76 }, + Height = { CurrentValue = 76 }, + Fill = { CurrentValue = Brushes.OrangeRed }, + FilterEffect = { CurrentValue = new Brightness { Amount = { CurrentValue = 78 } } }, + Transform = { CurrentValue = new TranslateTransform(44, -18) }, + }; + + var label = new TextBlock + { + FontFamily = { CurrentValue = FontFamily.Default }, + Size = { CurrentValue = 28 }, + Fill = { CurrentValue = Brushes.White }, + Text = { CurrentValue = "CACHE" }, + Transform = { CurrentValue = new TranslateTransform(-28, 30) }, + }; + + CompositionContext context = CompositionContext.Default; + return [background.ToResource(context), accent.ToResource(context), label.ToResource(context)]; + } + + public static Drawable.Resource[] CreateShapeScene(int shapes, PixelSize frameSize) + { + CompositionContext context = CompositionContext.Default; + var result = new List(shapes + 1); + var background = new RectShape + { + Width = { CurrentValue = frameSize.Width }, + Height = { CurrentValue = frameSize.Height }, + Fill = { CurrentValue = Brushes.CornflowerBlue }, + }; + result.Add(background.ToResource(context)); + + for (int index = 0; index < shapes; index++) + { + var shape = new EllipseShape + { + Width = { CurrentValue = 20 + index % 7 }, + Height = { CurrentValue = 20 + index % 5 }, + Fill = { CurrentValue = Brushes.OrangeRed }, + FilterEffect = { CurrentValue = new Brightness { Amount = { CurrentValue = 50 + index % 40 } } }, + Transform = { CurrentValue = new TranslateTransform(index % 17 * 7, index % 11 * 9) }, + }; + result.Add(shape.ToResource(context)); + } + + return [.. result]; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheIdentityChannelTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheIdentityChannelTests.cs new file mode 100644 index 0000000000..dd290be668 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheIdentityChannelTests.cs @@ -0,0 +1,328 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +/// +/// Characterizes what the state-passing rule rejects and which pixel-affecting channels it lets through as +/// author-owned identities whose cache keys and versions must then be maintained by hand. +/// +/// +/// Recording rejects a callback that captures, and nothing else: the state object itself is not walked, so a +/// mutable reference, a delegate behind a holder, a static field, and a resource pinned to a fixed key and +/// version all reach the callback intact. Each of those is an identity channel the cache cannot see, so a +/// change made through one is served from the previous frame's stored pixels. +/// +[TestFixture] +public sealed class RenderCacheIdentityChannelTests +{ + internal static class RenderCacheIdentityChannelSlots + { + internal static readonly RenderResourceSlot> ReadColor = new(); + internal static readonly RenderResourceSlot Payload = new(); + internal static readonly RenderResourceSlot Probe = new(); + } + + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + public void CallbackCapturingAPerRecordingValue_IsRejectedWhileRecording() + { + var captured = new ColorBox { Color = Colors.Red }; + + ArgumentException? rejection = Assert.Throws( + () => Describe(typeof(RenderCacheIdentityChannelTests), (_, _) => _ = captured.Color)); + + Assert.That(rejection!.Message, Does.Contain("must not capture per-recording values"), + "The capture check is the whole of the state-passing rule, so it must stay the reason given."); + } + + [Test] + public void MutableReferenceHeldByTheCallbackState_IsServedStale() + { + using var node = new MutableStateHolderNode(); + AssertServedStale(node, () => node.Box.Color = Colors.Blue); + } + + [Test] + public void CapturingDelegateOneLevelDownFromTheState_IsServedStale() + { + using var node = new DelegateInHolderNode(); + AssertServedStale(node, () => node.Color = Colors.Blue); + } + + [Test] + public void StaticFieldReadByTheStaticCallback_IsServedStale() + { + using var node = new StaticFieldNode(); + StaticFieldNode.Color = Colors.Red; + AssertServedStale(node, static () => StaticFieldNode.Color = Colors.Blue); + } + + [Test] + public void BorrowedResourceBehindAPinnedCacheKeyAndVersion_IsServedStale() + { + using var node = new PinnedResourceNode(); + AssertServedStale(node, () => node.Payload.Color = Colors.Blue); + } + + [Test] + public void CapturingDelegateBorrowedAsAResource_IsServedStale() + { + using var node = new BorrowedDelegateNode(); + AssertServedStale(node, () => node.Color = Colors.Blue); + } + + /// + /// Drives two frames around and asserts the second frame reused the + /// first frame's pixels without re-executing the producer. + /// + private static void AssertServedStale(ProbedRenderNode node, Action changeTheDrawnValue) + { + node.Cache.RecordStableRequests(); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using (RenderNodeRasterization _ = renderer.Rasterize()) + { + } + + changeTheDrawnValue(); + using RenderNodeRasterization second = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(node.ExecuteCount, Is.EqualTo(1), + "a cache hit serves the stored pixels without re-executing the producer"); + Assert.That(TopLeft(second), Is.EqualTo(ToPremultipliedHalfBits(Colors.Red)), + "the second frame shows the first frame's colour"); + }); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static ulong TopLeft(RenderNodeRasterization rasterization) + { + Assert.That(rasterization.IsEmpty, Is.False); + return ReadFirstPixel(rasterization.Bitmap!); + } + + private static ulong ToPremultipliedHalfBits(Color color) + { + using var target = new CpuRenderTarget(1, 1); + target.Value.Canvas.Clear(color.ToSKColor()); + using Bitmap snapshot = target.Snapshot(); + return ReadFirstPixel(snapshot); + } + + private static ulong ReadFirstPixel(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + return ((ulong)pixels[0] << 48) + | ((ulong)pixels[1] << 32) + | ((ulong)pixels[2] << 16) + | pixels[3]; + } + + private static OpaqueRenderDescription Describe( + TState state, + Action execute, + IEnumerable? resources = null) + where TState : notnull + => OpaqueRenderDescription.Create( + state, + execute, + OpaqueRenderBoundsContract.FullInputs(static _ => s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: resources); + + internal sealed class ColorBox + { + public Color Color { get; set; } + } + + private abstract class ProbedRenderNode : RenderNode + { + protected readonly ExecutionProbe Probe = new(); + private readonly object _probeKey = new(); + + public int ExecuteCount => Probe.Count; + + protected RenderResourceBinding BindProbe(RenderNodeContext context) + => RenderCacheIdentityChannelSlots.Probe.Bind(context.Borrow(Probe)); + } + + /// Channel 1: state holds a reference whose contents change between frames. + private sealed class MutableStateHolderNode : ProbedRenderNode + { + public ColorBox Box { get; } = new() { Color = Colors.Red }; + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = Describe( + Box, + static (session, box) => session.UseResource(RenderCacheIdentityChannelSlots.Probe, probe => + { + probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(box.Color)); + session.Publish(output); + }), + resources: [BindProbe(context)]); + context.Publish(context.ContributeValues(context.OpaqueCombine([], description))); + } + } + + /// Channel 2: the static callback reads a static field the state never mentions. + private sealed class StaticFieldNode : ProbedRenderNode + { + public static Color Color { get; set; } = Colors.Red; + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = Describe( + typeof(StaticFieldNode), + static (session, _) => session.UseResource(RenderCacheIdentityChannelSlots.Probe, probe => + { + probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Color)); + session.Publish(output); + }), + resources: [BindProbe(context)]); + context.Publish(context.ContributeValues(context.OpaqueCombine([], description))); + } + } + + /// + /// Channel 3: a capturing delegate reached through a holder object. Only the callback is inspected for + /// captures, so the delegate travels as ordinary state and what it reads never reaches the cache key. + /// + private sealed class DelegateInHolderNode : ProbedRenderNode + { + private readonly ColorSource _source; + + public DelegateInHolderNode() => _source = new ColorSource(() => Color); + + public Color Color { get; set; } = Colors.Red; + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = Describe( + _source, + static (session, source) => session.UseResource(RenderCacheIdentityChannelSlots.Probe, probe => + { + probe.Record(); + Color color = source.Read(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(color)); + session.Publish(output); + }), + resources: [BindProbe(context)]); + context.Publish(context.ContributeValues(context.OpaqueCombine([], description))); + } + + private sealed class ColorSource(Func read) + { + public Color Read() => read(); + } + } + + /// Channel 4: a borrowed resource whose content changes behind a pinned cache key and version. + private sealed class PinnedResourceNode : ProbedRenderNode + { + private static readonly object s_pinnedCacheKey = new(); + + public ColorBox Payload { get; } = new() { Color = Colors.Red }; + + public override void Process(RenderNodeContext context) + { + RenderResource resource = + context.Borrow(Payload); + OpaqueRenderDescription description = Describe( + typeof(PinnedResourceNode), + static (session, _) => session.UseResource(RenderCacheIdentityChannelSlots.Probe, probe => + { + probe.Record(); + session.UseResource(RenderCacheIdentityChannelSlots.Payload, payload => + { + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(payload.Color)); + session.Publish(output); + }); + }), + resources: [RenderCacheIdentityChannelSlots.Payload.Bind(resource), BindProbe(context)]); + context.Publish(context.ContributeValues(context.OpaqueCombine([], description))); + } + } + + /// + /// Channel 5: the route the contract itself recommends and + /// FilterEffectInputBinding.PublishDeferredPreviews took. The capturing delegate did not stop + /// capturing; it moved out of the callback closure into a declared resource under an author-declared identity. + /// + private sealed class BorrowedDelegateNode : ProbedRenderNode + { + private static readonly object s_declaredIdentity = new(); + + private readonly Func _readColor; + + public BorrowedDelegateNode() => _readColor = () => Color; + + public Color Color { get; set; } = Colors.Red; + + public override void Process(RenderNodeContext context) + { + RenderResource> sink = context.Borrow(_readColor); + OpaqueRenderDescription description = Describe( + typeof(BorrowedDelegateNode), + static (session, _) => session.UseResource(RenderCacheIdentityChannelSlots.Probe, probe => + { + probe.Record(); + session.UseResource(RenderCacheIdentityChannelSlots.ReadColor, readColor => + { + Color color = readColor(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(color)); + session.Publish(output); + }); + }), + resources: [RenderCacheIdentityChannelSlots.ReadColor.Bind(sink), BindProbe(context)]); + context.Publish(context.ContributeValues(context.OpaqueCombine([], description))); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs new file mode 100644 index 0000000000..41e90e3634 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs @@ -0,0 +1,2041 @@ +using System.Collections.Immutable; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +[TestFixture] +public sealed class RenderCacheResolutionTests +{ + private static readonly Rect s_bounds = new(0, 0, 64, 64); + private static readonly RenderCacheResolutionContext s_context = new( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + new RenderCacheDeviceContextIdentity("device-a", "context-a")); + + [Test] + public void Recorder_DeclaresOnlyWarmEnabledNodeCandidatesWithoutReadingCachePixels() + { + using var coldNode = new CacheableNode(disableCache: false); + using var warmNode = new CacheableNode(disableCache: false); + using var disabledNode = new CacheableNode(disableCache: true); + warmNode.Cache.RecordStableRequests(); + disabledNode.Cache.RecordStableRequests(); + + using var coldRequest = NewRequest(); + using var firstWarmRequest = NewRequest(); + using var secondWarmRequest = NewRequest(); + using var disabledRequest = NewRequest(); + RecordedRenderGraph cold = new RenderRequestRecorder(coldRequest).Record(coldNode); + RecordedRenderGraph firstWarm = new RenderRequestRecorder(firstWarmRequest).Record(warmNode); + RecordedRenderGraph secondWarm = new RenderRequestRecorder(secondWarmRequest).Record(warmNode); + RecordedRenderGraph disabled = new RenderRequestRecorder(disabledRequest).Record(disabledNode); + + Assert.Multiple(() => + { + Assert.That(cold.CacheCandidates, Is.Empty); + Assert.That(disabled.CacheCandidates, Is.Empty); + Assert.That(firstWarm.CacheCandidates.Length, Is.EqualTo(1)); + Assert.That(firstWarm.CacheCandidates.Single().Cache, Is.SameAs(warmNode.Cache)); + Assert.That( + secondWarm.CacheCandidates.Single().CacheKey, + Is.SameAs(firstWarm.CacheCandidates.Single().CacheKey)); + Assert.That(warmNode.ExecuteCount, Is.EqualTo(0)); + }); + } + + /// + /// A node reachable from two parents is recorded once per parent. Both recordings point at the same + /// RenderNodeCache, so offering both as candidates lets one family try to publish two independent outputs + /// to one cache, which the executor rejects by failing the frame. + /// + [Test] + public void Recorder_OffersOneCandidatePerNodeEvenWhenTwoParentsShareIt() + { + var shared = new CacheableNode(disableCache: false); + shared.Cache.RecordStableRequests(); + using var container = new ContainerRenderNode(); + container.AddChild(new ReferencesChildRenderNode(shared)); + container.AddChild(new ReferencesChildRenderNode(shared)); + + using var request = NewRequest(); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(container); + + Assert.That( + graph.CacheCandidates.Count(candidate => ReferenceEquals(candidate.Cache, shared.Cache)), + Is.EqualTo(1), + "A shared node must offer its cache one candidate, not one per parent."); + } + + [Test] + public void Recorder_KeepsSiblingsCacheableAfterOneOfThemOptsOut() + { + var disabledNode = new CacheableNode(disableCache: true); + var laterNode = new CacheableNode(disableCache: false); + disabledNode.Cache.RecordStableRequests(); + laterNode.Cache.RecordStableRequests(); + using var container = new ContainerRenderNode(); + container.AddChild(disabledNode); + container.AddChild(laterNode); + + using var request = NewRequest(); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(container); + + Assert.That( + graph.CacheCandidates.Select(static candidate => candidate.Cache), + Is.EqualTo(new[] { laterNode.Cache }), + "A container hierarchy is recorded onto one parent checkpoint, so a node opting out of the " + + "cache must not decide for the siblings recorded after it."); + } + + [Test] + public void FrameCache_ColdMissPublishesAndWarmHitSkipsProducerWithPixelParity() + { + using var node = new SolidCacheNode(); + node.Cache.RecordStableRequests(); + using var renderer = CreateFrameRenderer(node); + + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(cold.Bitmap, Is.Not.Null); + Assert.That(warm.Bitmap, Is.Not.Null); + Assert.That(node.ExecuteCount, Is.EqualTo(1)); + Assert.That(node.Cache.IsCached, Is.True); + Assert.That( + warm.Bitmap!.GetPixelSpan().SequenceEqual(cold.Bitmap!.GetPixelSpan()), + Is.True); + }); + } + + [Test] + public void StaticPrefixCache_AcceptsHundredAnimatedFramesWithZeroPrefixExecution() + { + using var node = new SolidCacheNode(); + node.Cache.RecordStableRequests(); + using var renderer = CreateFrameRenderer(node); + + using RenderNodeRasterization first = renderer.Rasterize(); + Assert.That(node.ExecuteCount, Is.EqualTo(1), + "the static prefix must execute exactly once on the cold frame"); + ushort[] firstPixels = first.Bitmap!.GetPixelSpan().ToArray(); + + for (int frame = 1; frame < 100; frame++) + { + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Assert.That( + rasterization.Bitmap!.GetPixelSpan().SequenceEqual(firstPixels), + Is.True, + $"warm frame {frame} must match the cold prefix output (SC-012)"); + } + + Assert.Multiple(() => + { + Assert.That(node.ExecuteCount, Is.EqualTo(1), + "the static prefix must not re-execute across 100 animated frames (SC-012)"); + Assert.That(node.Cache.IsCached, Is.True); + }); + } + + [Test] + public void ExecutionFailure_RejectsEveryStagedCaptureWithoutPartialPublication() + { + using var root = new ContainerRenderNode(); + var completed = new SolidCacheNode(); + var failing = new SolidCacheNode(throwOnExecute: true); + completed.Cache.RecordStableRequests(); + failing.Cache.RecordStableRequests(); + root.AddChild(completed); + root.AddChild(failing); + using var renderer = CreateFrameRenderer(root); + + Assert.That(() => renderer.Rasterize(), Throws.InvalidOperationException); + Assert.Multiple(() => + { + Assert.That(completed.ExecuteCount, Is.EqualTo(1)); + Assert.That(failing.ExecuteCount, Is.EqualTo(1)); + Assert.That(completed.Cache.IsCached, Is.False); + Assert.That(failing.Cache.IsCached, Is.False); + }); + } + + [Test] + public void PublicationFailure_RejectsTheWholeBatch() + { + using var root = new ContainerRenderNode(); + var first = new SolidCacheNode(); + var invalidatedOwner = new SolidCacheNode(); + invalidatedOwner.OnExecute = invalidatedOwner.Cache.Dispose; + first.Cache.RecordStableRequests(); + invalidatedOwner.Cache.RecordStableRequests(); + root.AddChild(first); + root.AddChild(invalidatedOwner); + using var renderer = CreateFrameRenderer(root); + + Assert.That(() => renderer.Rasterize(), Throws.InstanceOf()); + Assert.Multiple(() => + { + Assert.That(first.Cache.IsCached, Is.False); + Assert.That(invalidatedOwner.Cache.IsCached, Is.False); + }); + } + + + [Test] + public void AuxiliaryRequests_MayNotPublishPersistentMisses() + { + using var node = new SolidCacheNode(); + node.Cache.RecordStableRequests(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + }, + }); + + using (renderer.Rasterize()) + using (renderer.Rasterize()) + { + } + + Assert.Multiple(() => + { + Assert.That(node.ExecuteCount, Is.EqualTo(2)); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void ParentHit_SupersedesChildWithoutLookingUpOrRewritingIt() + { + RenderFragmentReference child = Pure(); + RenderFragmentReference parent = Pure([child]); + using Scenario scenario = Build( + [child, parent], + [parent], + [(child, "child"), (parent, "parent")]); + RenderCacheResolution cold = Resolve(scenario); + var lookup = new RecordingLookup(); + lookup.AddRange(cold.MissCaptures); + + RenderCacheResolution warmed = Resolve(scenario, lookup); + RenderCacheDecision childDecision = warmed.GetDecision(scenario.Candidate(child)); + RenderCacheDecision parentDecision = warmed.GetDecision(scenario.Candidate(parent)); + + Assert.Multiple(() => + { + Assert.That(parentDecision.Kind, Is.EqualTo(RenderCacheResolutionKind.Hit)); + Assert.That(childDecision.Kind, Is.EqualTo(RenderCacheResolutionKind.Superseded)); + Assert.That(childDecision.SupersededBy, Is.EqualTo(parentDecision.Candidate.Id)); + Assert.That(lookup.RequestedKeys, Is.EqualTo(new object[] { "parent" })); + Assert.That(parent.Inputs.Single(), Is.SameAs(child)); + Assert.That(scenario.Graph.Fragments.Count, Is.EqualTo(2)); + }); + } + + [Test] + public void ParentMiss_LeavesValidChildHitSelectableAndStagesTheParent() + { + RenderFragmentReference child = Pure(); + RenderFragmentReference parent = Pure([child], payload: new RuntimeValue(1)); + using Scenario scenario = Build( + [child, parent], + [parent], + [(child, "child"), (parent, "parent")]); + RenderCacheResolution cold = Resolve(scenario); + var lookup = new RecordingLookup(); + lookup.Add(cold.GetDecision(scenario.Candidate(child)).MissCapture!); + + RenderCacheResolution warmed = Resolve(scenario, lookup); + + Assert.Multiple(() => + { + Assert.That( + warmed.GetDecision(scenario.Candidate(parent)).Kind, + Is.EqualTo(RenderCacheResolutionKind.MissCapture)); + Assert.That( + warmed.GetDecision(scenario.Candidate(child)).Kind, + Is.EqualTo(RenderCacheResolutionKind.Hit)); + Assert.That(warmed.Hits.Single().OriginalProducerId, Is.EqualTo(child.Id)); + Assert.That(warmed.MissCaptures.Single().ProducerId, Is.EqualTo(parent.Id)); + Assert.That(lookup.RequestedKeys, Is.EqualTo(new object[] { "parent", "child" })); + }); + } + + [TestCase("TargetCommand", "TargetTokenDependency")] + [TestCase("RawTargetScope", "RawTargetWork")] + [TestCase("TargetCapture", "TargetTokenDependency")] + public void TargetAndRawCandidates_BypassWhilePureChildrenRemainSelectable( + string boundaryKindName, + string expectedReasonName) + { + RenderFragmentKind boundaryKind = Enum.Parse(boundaryKindName); + RenderCacheBypassReason expectedReason = Enum.Parse(expectedReasonName); + RenderFragmentReference child = Pure(); + RenderFragmentReference boundary = Boundary(boundaryKind, child); + RenderFragmentReference[] roots = boundaryKind == RenderFragmentKind.TargetCapture + ? [child, boundary] + : [boundary]; + using Scenario scenario = Build( + [child, boundary], + roots, + [(child, "child"), (boundary, "boundary")]); + RenderCacheResolution cold = Resolve(scenario); + var lookup = new RecordingLookup(); + RenderCacheMissCapture? childCapture = cold + .GetDecision(scenario.Candidate(child)) + .MissCapture; + if (childCapture is not null) + lookup.Add(childCapture); + + RenderCacheResolution warmed = Resolve(scenario, lookup); + RenderCacheDecision boundaryDecision = warmed.GetDecision(scenario.Candidate(boundary)); + RenderCacheDecision childDecision = warmed.GetDecision(scenario.Candidate(child)); + + Assert.Multiple(() => + { + Assert.That(boundaryDecision.Kind, Is.EqualTo(RenderCacheResolutionKind.Bypass)); + Assert.That(boundaryDecision.BypassReason, Is.EqualTo(expectedReason)); + if (childCapture is not null) + Assert.That(childDecision.Kind, Is.EqualTo(RenderCacheResolutionKind.Hit)); + }); + } + + [Test] + public void CompleteIdentity_InvalidatesCoverageDensityFormatPurposeDeviceContextAndBounds() + { + using Scenario baseline = SingleCandidate(); + RenderCacheResolution cold = Resolve(baseline); + var lookup = new RecordingLookup(); + lookup.Add(cold.MissCaptures.Single()); + + AssertMiss(SingleCandidate(requestedRegion: new Rect(0, 0, 32, 64)), s_context, lookup); + AssertMiss(SingleCandidate(outputScale: 2), s_context, lookup); + AssertMiss( + SingleCandidate(), + new RenderCacheResolutionContext( + new RenderCacheFormatIdentity("RGBA8", "Premultiplied", "LinearSrgb"), + s_context.DeviceContext), + lookup); + AssertMiss(SingleCandidate(purpose: RenderRequestPurpose.Auxiliary), s_context, lookup); + AssertMiss( + SingleCandidate(), + new RenderCacheResolutionContext( + s_context.Format, + new RenderCacheDeviceContextIdentity("device-b", "context-a")), + lookup); + AssertMiss( + SingleCandidate(), + new RenderCacheResolutionContext( + s_context.Format, + new RenderCacheDeviceContextIdentity("device-a", "context-b")), + lookup); + AssertMiss(SingleCandidate(bounds: new Rect(0, 0, 63, 64)), s_context, lookup); + } + + [Test] + public void BinderFreeShaderIdentity_ReusesAcrossUnobservedSharedStageRequirement() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + using Scenario baseline = ShaderFanOut(description, widenSiblingRequirement: false); + RenderCacheResolution cold = Resolve(baseline); + var lookup = new RecordingLookup(); + lookup.Add(cold.MissCaptures.Single()); + + using Scenario expanded = ShaderFanOut(description, widenSiblingRequirement: true); + RenderCacheResolution resolution = Resolve(expanded, lookup); + + Assert.Multiple(() => + { + Assert.That( + expanded.Regions.GetFragmentRequirement(expanded.Named("shared")), + Is.Not.EqualTo(baseline.Regions.GetFragmentRequirement(baseline.Named("shared")))); + Assert.That(resolution.Hits, Has.Length.EqualTo(1)); + Assert.That(resolution.MissCaptures, Is.Empty); + }); + } + + [Test] + public void BinderFreeCandidateIdentity_ReusesWhenExternalReusableSiblingChangesSharedRequirement() + { + using Scenario baseline = ExternalReusableShaderFanOut(widenSiblingRequirement: false); + RenderCacheResolution cold = Resolve(baseline); + var lookup = new RecordingLookup(); + lookup.Add(cold.MissCaptures.Single()); + + using Scenario expanded = ExternalReusableShaderFanOut(widenSiblingRequirement: true); + RenderCacheResolution resolution = Resolve(expanded, lookup); + + Assert.Multiple(() => + { + Assert.That( + expanded.Regions.GetFragmentRequirement(expanded.Named("candidate")), + Is.EqualTo(baseline.Regions.GetFragmentRequirement(baseline.Named("candidate")))); + Assert.That( + baseline.Regions.GetFragmentRequirement(baseline.Named("producer")), + Is.EqualTo(RequiredRegion.Region(new Rect(16, 16, 16, 16)))); + Assert.That( + expanded.Regions.GetFragmentRequirement(expanded.Named("producer")), + Is.EqualTo(RequiredRegion.Region(new Rect(8, 8, 32, 32)))); + Assert.That(resolution.Hits, Has.Length.EqualTo(1)); + Assert.That(resolution.MissCaptures, Is.Empty); + }); + } + + [Test] + public void GridSensitiveIdentity_DistinguishesIntegralDestinationTranslations() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + var firstContext = new RenderCacheResolutionContext( + s_context.Format, + s_context.DeviceContext, + deviceGridOffset: new Vector(1, 1)); + var secondContext = new RenderCacheResolutionContext( + s_context.Format, + s_context.DeviceContext, + deviceGridOffset: new Vector(2, 2)); + var lookup = new RecordingLookup(); + using (Scenario first = ShaderCandidate(description)) + { + RenderCacheResolution cold = Resolve(first, context: firstContext); + lookup.AddRange(cold.MissCaptures); + Assert.That( + cold.MissCaptures.Single().Identity.DeviceGridOffset, + Is.EqualTo(new Vector(1, 1))); + } + + using Scenario second = ShaderCandidate(description); + RenderCacheResolution moved = Resolve(second, lookup, secondContext); + + Assert.Multiple(() => + { + Assert.That(moved.Hits, Is.Empty); + Assert.That(moved.MissCaptures, Has.Length.EqualTo(1)); + Assert.That( + moved.MissCaptures.Single().Identity.DeviceGridOffset, + Is.EqualTo(new Vector(2, 2))); + }); + } + + [TestCase(2f, 2f)] + [TestCase(1.5f, 1.5f)] + public void DivergentFanOut_ColdAndWarmCacheUseHighestCappedDensity( + float maxWorkingScale, + float expectedDensity) + { + RenderFragmentReference source = Pure(); + RenderFragmentReference unitScale = FixedScaleMap(source, 1, 1); + RenderFragmentReference doubleScale = FixedScaleMap(source, 2, expectedDensity); + using Scenario scenario = Build( + [source, unitScale, doubleScale], + [unitScale, doubleScale], + [(source, "source")], + maxWorkingScale: maxWorkingScale); + + RenderCacheResolution cold = Resolve(scenario); + RenderCacheMissCapture capture = cold.MissCaptures.Single(); + var lookup = new RecordingLookup(); + lookup.Add(capture); + + RenderCacheResolution warm = Resolve(scenario, lookup); + RenderCacheDecision decision = warm.GetDecision(scenario.Candidate(source)); + + Assert.Multiple(() => + { + Assert.That(capture.Identity.Density, Is.EqualTo(expectedDensity)); + Assert.That(decision.Kind, Is.EqualTo(RenderCacheResolutionKind.Hit)); + Assert.That(decision.Hit!.Entry.Identity.Density, Is.EqualTo(expectedDensity)); + }); + } + + [Test] + public void MaterializationDemands_OpacityMaskDependencyUsesActiveTargetDensity() + { + RenderFragmentReference primary = Pure(scale: EffectiveScale.At(0.5f)); + RenderFragmentReference maskDependency = Pure(); + var opacityMask = new RenderFragmentReference( + RenderFragmentKind.OpacityMask, + s_bounds, + EffectiveScale.At(0.5f), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [primary, maskDependency], + payload: null, + static _ => true); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [opacityMask], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.Multiple(() => + { + Assert.That(demands[opacityMask], Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(demands[primary], Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(demands[maskDependency], Is.EqualTo(EffectiveScale.At(1))); + }); + } + + [Test] + public void MaterializationDemands_CachedOpacityMaskDependencyUsesValueDensity() + { + RenderFragmentReference primary = Pure(scale: EffectiveScale.At(0.5f)); + RenderFragmentReference maskDependency = Pure(); + var opacityMask = new RenderFragmentReference( + RenderFragmentKind.OpacityMask, + s_bounds, + EffectiveScale.At(0.5f), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [primary, maskDependency], + payload: null, + static _ => true); + var boundaries = new HashSet( + ReferenceEqualityComparer.Instance) + { + opacityMask, + }; + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [opacityMask], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity, + boundaries).Demands; + + Assert.Multiple(() => + { + Assert.That(demands[opacityMask], Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(demands[primary], Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(demands[maskDependency], Is.EqualTo(EffectiveScale.At(0.5f))); + }); + } + + + + + [Test] + public void OpacityMaskIdentity_IncludesUnboundedDependencyMaterializationDemand() + { + using Scenario unitScale = OpacityMaskCandidate(outputScale: 1); + using Scenario doubleScale = OpacityMaskCandidate(outputScale: 2); + ImmutableArray unitRoots = + RenderRequestCompiler.ResolveRoots(unitScale.Graph); + ImmutableArray doubleRoots = + RenderRequestCompiler.ResolveRoots(doubleScale.Graph); + IReadOnlyDictionary unitDemands = + RenderMaterializationDemandResolver.Resolve( + unitRoots, + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + IReadOnlyDictionary doubleDemands = + RenderMaterializationDemandResolver.Resolve( + doubleRoots, + outputScale: 2, + maxWorkingScale: float.PositiveInfinity).Demands; + + RenderFragmentOutputIdentity unitIdentity = RenderFragmentOutputIdentity.Create( + unitRoots.Single(), + unitScale.Graph.RequestId, + unitDemands); + RenderFragmentOutputIdentity doubleIdentity = RenderFragmentOutputIdentity.Create( + doubleRoots.Single(), + doubleScale.Graph.RequestId, + doubleDemands); + + Assert.Multiple(() => + { + Assert.That( + unitDemands[unitScale.Named("dependency")], + Is.EqualTo(EffectiveScale.At(1))); + Assert.That( + doubleDemands[doubleScale.Named("dependency")], + Is.EqualTo(EffectiveScale.At(2))); + Assert.That(doubleIdentity, Is.Not.EqualTo(unitIdentity)); + }); + } + + [Test] + public void SingleCandidate_ColdAndWarmConvergeWithinTwoPassesAndProbeLookupOnce() + { + using Scenario scenario = SingleCandidate(); + var lookup = new RecordingLookup(); + + RenderCachePlanningResult cold = ResolvePlanning(scenario, lookup); + Assert.Multiple(() => + { + Assert.That(cold.ResolutionPasses, Is.InRange(1, 2)); + Assert.That(cold.Resolution.MissCaptures, Has.Length.EqualTo(1)); + Assert.That(lookup.RequestedKeys, Is.EqualTo(new object[] { "source" })); + }); + + lookup.Add(cold.Resolution.MissCaptures.Single()); + lookup.RequestedKeys.Clear(); + RenderCachePlanningResult warm = ResolvePlanning(scenario, lookup); + + Assert.Multiple(() => + { + Assert.That(warm.ResolutionPasses, Is.InRange(1, 2)); + Assert.That(warm.Resolution.Hits, Has.Length.EqualTo(1)); + Assert.That(lookup.RequestedKeys, Is.EqualTo(new object[] { "source" })); + }); + } + + [Test] + public void LookupOnlyStableHit_ConvergesInTwoPassesWithOneUnderlyingProbe() + { + using Scenario scenario = SingleCandidate(); + RenderCachePlanningResult cold = ResolvePlanning(scenario); + var lookup = new RecordingLookup(); + lookup.Add(cold.Resolution.MissCaptures.Single()); + var lookupOnlyContext = new RenderCacheResolutionContext( + s_context.Format, + s_context.DeviceContext, + allowPersistentLookup: true, + allowCapturePublication: false); + + RenderCachePlanningResult result = ResolvePlanning( + scenario, + lookup, + lookupOnlyContext); + + Assert.Multiple(() => + { + Assert.That(result.ResolutionPasses, Is.EqualTo(2)); + Assert.That(result.Resolution.Hits, Has.Length.EqualTo(1)); + Assert.That(result.Resolution.MissCaptures, Is.Empty); + Assert.That(lookup.RequestedKeys, Is.EqualTo(new object[] { "source" })); + }); + } + + [Test] + public void FourPassBoundaryCascade_FallsBackWithUncachedDemandsAndReportsTheCap() + { + RenderFragmentReference source = Pure(); + RenderFragmentReference fourth = ValueReplayMap( + source, + EffectiveScale.At(0.0625f), + "fourth-runtime"); + RenderFragmentReference third = ValueReplayMap( + fourth, + EffectiveScale.At(0.125f), + "third-runtime"); + RenderFragmentReference second = ValueReplayMap( + third, + EffectiveScale.At(0.25f), + "second-runtime"); + RenderFragmentReference first = ValueReplayMap( + second, + EffectiveScale.At(0.5f), + "first-runtime"); + using Scenario scenario = Build( + [source, fourth, third, second, first], + [first], + [ + (fourth, "fourth"), + (third, "third"), + (second, "second"), + (first, "first"), + ], + names: new Dictionary + { + ["source"] = source, + }); + var lookup = new DelayedIdentityHitLookup( + new Dictionary + { + ["first"] = 1, + ["second"] = 2, + ["third"] = 3, + ["fourth"] = 4, + }); + var lookupOnlyContext = new RenderCacheResolutionContext( + s_context.Format, + s_context.DeviceContext, + allowPersistentLookup: true, + allowCapturePublication: false); + + RenderCachePlanningResult result = ResolvePlanning( + scenario, + lookup, + lookupOnlyContext); + + Assert.Multiple(() => + { + Assert.That(result.ResolutionPasses, Is.EqualTo(4)); + Assert.That( + result.Resolution.Decisions, + Has.All.Property(nameof(RenderCacheDecision.BypassReason)) + .EqualTo(RenderCacheBypassReason.UnstableBoundaryPlan)); + Assert.That( + result.MaterializationDemands[scenario.Named("source")], + Is.EqualTo(EffectiveScale.At(1))); + }); + } + + [Test] + public void OpacityMaskCacheBoundary_ColdAndWarmCrossDensityUseStableValueDemand() + { + using Scenario coldScenario = OpacityMaskCandidate(outputScale: 1); + RenderCachePlanningResult cold = ResolvePlanning(coldScenario); + var lookup = new RecordingLookup(); + lookup.Add(cold.Resolution.MissCaptures.Single()); + + using Scenario warmScenario = OpacityMaskCandidate(outputScale: 2); + RenderCachePlanningResult warm = ResolvePlanning(warmScenario, lookup); + + Assert.Multiple(() => + { + Assert.That( + cold.MaterializationDemands[coldScenario.Named("dependency")], + Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That( + warm.MaterializationDemands[warmScenario.Named("dependency")], + Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(warm.Resolution.Hits.Length, Is.EqualTo(1)); + Assert.That( + warm.Resolution.Hits.Single().Identity, + Is.EqualTo(cold.Resolution.MissCaptures.Single().Identity)); + }); + } + + [Test] + public void LookupOnlyBoundaryCycle_FallsBackToUncachedReplayDemands() + { + using Scenario scenario = OpacityMaskCandidate(outputScale: 1); + var lookup = new FirstIdentityOnlyLookup(); + var lookupOnlyContext = new RenderCacheResolutionContext( + s_context.Format, + s_context.DeviceContext, + allowPersistentLookup: true, + allowCapturePublication: false); + + RenderCachePlanningResult result = ResolvePlanning( + scenario, + lookup, + lookupOnlyContext); + + Assert.Multiple(() => + { + Assert.That(result.Resolution.Hits, Is.Empty); + Assert.That(result.Resolution.MissCaptures, Is.Empty); + Assert.That(result.ResolutionPasses, Is.EqualTo(2)); + Assert.That( + result.Resolution.Decisions.Single().BypassReason, + Is.EqualTo(RenderCacheBypassReason.UnstableBoundaryPlan)); + Assert.That( + result.MaterializationDemands[scenario.Named("dependency")], + Is.EqualTo(EffectiveScale.At(1))); + }); + } + + [Test] + public void NestedValueReplayCaches_WarmParentHitUsesRawPlanningBoundaries() + { + RenderFragmentReference dependency = Pure(); + RenderFragmentReference child = ValueReplayMap(dependency, EffectiveScale.At(1), "child"); + RenderFragmentReference parent = ValueReplayMap(child, EffectiveScale.At(0.5f), "parent"); + using Scenario scenario = Build( + [dependency, child, parent], + [parent], + [(child, "child"), (parent, "parent")], + names: new Dictionary + { + ["dependency"] = dependency, + ["child"] = child, + ["parent"] = parent, + }); + RenderCachePlanningResult cold = ResolvePlanning(scenario); + var lookup = new RecordingLookup(); + lookup.AddRange(cold.Resolution.MissCaptures); + + RenderCachePlanningResult warm = ResolvePlanning(scenario, lookup); + + Assert.Multiple(() => + { + Assert.That( + warm.Resolution.GetDecision(scenario.Candidate("parent")).Kind, + Is.EqualTo(RenderCacheResolutionKind.Hit)); + Assert.That( + warm.Resolution.GetDecision(scenario.Candidate("child")).Kind, + Is.EqualTo(RenderCacheResolutionKind.Superseded)); + Assert.That( + warm.MaterializationDemands[scenario.Named("dependency")], + Is.EqualTo(EffectiveScale.At(1))); + }); + } + + [Test] + public void PlanningBoundaryThatBecomesIneligible_IsRemovedFromFinalDemands() + { + var expandedBounds = new Rect(0, 0, 12_000, 1); + var parentBounds = new Rect(0, 0, 32, 32); + RenderFragmentReference dependency = Pure(); + RenderBoundsContract expandChild = RenderBoundsContract.Create( + static _ => new Rect(0, 0, 12_000, 1), + static _ => s_bounds); + RenderFragmentReference child = ValueReplayMap( + dependency, + EffectiveScale.Unbounded, + "expanding-child", + expandedBounds, + expandChild); + RenderBoundsContract shrinkToParent = RenderBoundsContract.CreateFullInput( + static _ => new Rect(0, 0, 32, 32)); + RenderFragmentReference parent = ValueReplayMap( + child, + EffectiveScale.At(2), + "shrink-parent", + parentBounds, + shrinkToParent); + using Scenario scenario = Build( + [dependency, child, parent], + [parent], + [(child, "child"), (parent, "parent")], + names: new Dictionary + { + ["dependency"] = dependency, + ["child"] = child, + ["parent"] = parent, + }, + cacheRules: new RenderCacheRules(MaxPixels: 20_000, MinPixels: 1)); + + RenderCachePlanningResult result = ResolvePlanning(scenario); + + Assert.Multiple(() => + { + Assert.That( + result.Resolution.GetDecision(scenario.Candidate("parent")).Kind, + Is.EqualTo(RenderCacheResolutionKind.MissCapture)); + Assert.That( + result.Resolution.GetDecision(scenario.Candidate("child")).BypassReason, + Is.EqualTo(RenderCacheBypassReason.OutsideCacheRules)); + Assert.That( + result.MaterializationDemands[scenario.Named("dependency")], + Is.EqualTo(EffectiveScale.At(2))); + }); + } + + [Test] + public void MaterializationDemands_TargetCommandLayerUsesConcreteInputSupply() + { + RenderFragmentReference denseInput = Pure(scale: EffectiveScale.At(2)); + var layer = new RenderFragmentReference( + RenderFragmentKind.Layer, + s_bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [denseInput], + payload: null, + static _ => true); + var command = new RenderFragmentReference( + RenderFragmentKind.TargetCommand, + s_bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.None, + contributesValuesToTarget: false, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [layer], + payload: null, + static _ => false); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [command], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.Multiple(() => + { + Assert.That(demands[layer], Is.EqualTo(EffectiveScale.At(2))); + Assert.That(demands[denseInput], Is.EqualTo(EffectiveScale.At(2))); + }); + } + + /// + /// An authored scope that transforms its input in the input's own coordinates says so, and only then does + /// its scale contract describe the step between them completely enough to carry demand back. + /// + [Test] + public void MaterializationDemands_AnInputLogicalScopeCarriesItsDeclaredDemandBackwards() + { + RenderFragmentReference leaf = Pure(); + RenderFragmentReference scope = AuthoredTargetScope( + leaf, + RenderScaleContract.MapInputSupply(ReduceSupplyByFour, QuadrupleDemand), + RenderScopeTransformSpace.InputLogical); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [scope], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.That(demands[leaf], Is.EqualTo(EffectiveScale.At(4))); + } + + /// + /// The default, and what an appended transform is: the destination matrix already carries the scope's + /// scale, so raising the input's demand would rasterize it enlarged and then draw it enlarged again. + /// + [Test] + public void MaterializationDemands_AnAmbientTargetScopeLeavesTheDemandAlone() + { + RenderFragmentReference leaf = Pure(); + RenderFragmentReference scope = AuthoredTargetScope( + leaf, + RenderScaleContract.MapInputSupply(ReduceSupplyByFour, QuadrupleDemand), + RenderScopeTransformSpace.AmbientTarget); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [scope], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.That(demands[leaf], Is.EqualTo(EffectiveScale.At(1))); + } + + /// + /// A raw scope's callback is opaque, so the declared scale contract is the only statement of how the + /// replayed input is consumed. Forwarding the target demand past a scope that resamples rasterizes an + /// unbounded child at the target density and then enlarges it. + /// + [Test] + public void MaterializationDemands_RawTargetScopeCarriesItsDeclaredDemandBackwards() + { + RenderFragmentReference leaf = Pure(); + RenderFragmentReference scope = RawTargetScope( + leaf, + RenderScaleContract.MapInputSupply(ReduceSupplyByFour, QuadrupleDemand)); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [scope], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.That(demands[leaf], Is.EqualTo(EffectiveScale.At(4))); + } + + /// + /// The companion to : + /// a scope whose enlargement is already carried by the destination matrix declares no backward map, and + /// pre-scaling its input there would rasterize it large and then draw it scaled again. + /// + [Test] + public void MaterializationDemands_RawTargetScopeWithoutABackwardMapLeavesTheDemandAlone() + { + RenderFragmentReference leaf = Pure(); + RenderFragmentReference scope = RawTargetScope( + leaf, + RenderScaleContract.MapInputSupplyPreservingDemand(ReduceSupplyByFour)); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [scope], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity).Demands; + + Assert.That(demands[leaf], Is.EqualTo(EffectiveScale.At(1))); + } + + [TestCase(4f)] + [TestCase(float.PositiveInfinity)] + public void MaterializationDemands_UpscalingTransformStaysWithinWorkingAndBufferCeilings( + float maxWorkingScale) + { + var bounds = new Rect(0, 0, 8, 6); + RenderFragmentReference leaf = Pure(bounds: bounds); + var layer = new RenderFragmentReference( + RenderFragmentKind.Layer, + bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [leaf], + new LayerRenderFragmentPayload(bounds), + static _ => true); + RenderFragmentReference transform = ValueReplayMap( + layer, + EffectiveScale.Unbounded, + "upscale", + scaleContract: RenderScaleContract.MapInputSupply( + static supply => supply, + ScaleDemandByOneMillion)); + + IReadOnlyDictionary demands = + RenderMaterializationDemandResolver.Resolve( + [transform], + outputScale: 1, + maxWorkingScale: maxWorkingScale).Demands; + + float expected = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + bounds, + MathF.Min(1_000_000, maxWorkingScale)); + PixelRect allocated = PixelRect.FromRect(bounds, demands[layer].Value); + Assert.Multiple(() => + { + Assert.That(demands[layer], Is.EqualTo(EffectiveScale.At(expected))); + Assert.That(demands[leaf], Is.EqualTo(EffectiveScale.At(expected))); + Assert.That(allocated.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(allocated.Height, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void ContributeValuesCache_DelegatesDensityAndFootprintToLargeLayerInput() + { + var inputBounds = new Rect(0, 0, 64, 1); + var layerDomain = new Rect(0, 0, 10_000, 1); + var requestedRegion = new Rect(0, 0, 1, 1); + const float outputScale = 2; + float expectedDensity = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + layerDomain, + outputScale); + RenderFragmentReference leaf = Pure(bounds: inputBounds); + var layer = new RenderFragmentReference( + RenderFragmentKind.Layer, + inputBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: false, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [leaf], + new LayerRenderFragmentPayload(layerDomain), + static _ => false); + var contributing = new RenderFragmentReference( + RenderFragmentKind.ContributeValues, + inputBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [layer], + payload: null, + static _ => true); + using Scenario scenario = Build( + [leaf, layer, contributing], + [contributing], + [(contributing, "contributing")], + requestedRegion, + outputScale, + cacheRules: new RenderCacheRules(MaxPixels: 1_000, MinPixels: 1)); + + RenderCachePlanningResult planning = ResolvePlanning(scenario); + IReadOnlyDictionary demands = + planning.MaterializationDemands; + RenderCacheDecision decision = planning.Resolution + .GetDecision(scenario.Candidate(contributing)); + + Assert.Multiple(() => + { + Assert.That(demands[contributing], Is.EqualTo(EffectiveScale.At(expectedDensity))); + Assert.That(demands[layer], Is.EqualTo(EffectiveScale.At(expectedDensity))); + Assert.That( + (long)PixelRect.FromRect(layerDomain, expectedDensity).Width, + Is.GreaterThan(1_000)); + Assert.That(decision.Kind, Is.EqualTo(RenderCacheResolutionKind.Bypass)); + Assert.That(decision.BypassReason, Is.EqualTo(RenderCacheBypassReason.OutsideCacheRules)); + }); + } + + [Test] + public void FullHashCollision_NeverSubstitutesAnUnequalEntry() + { + using Scenario first = SingleCandidate(candidateKey: new CollidingKey("first")); + RenderCacheResolution cold = Resolve(first); + RenderCacheEntry wrong = new(cold.MissCaptures.Single().Identity, new object()); + using Scenario second = SingleCandidate(candidateKey: new CollidingKey("second")); + + RenderCacheResolution resolution = Resolve(second, new CollisionLookup(wrong)); + + Assert.Multiple(() => + { + Assert.That(wrong.Identity.GetHashCode(), + Is.EqualTo(resolution.MissCaptures.Single().Identity.GetHashCode())); + Assert.That(resolution.Hits, Is.Empty); + Assert.That(resolution.MissCaptures.Length, Is.EqualTo(1)); + Assert.That(resolution.MissCaptures.Single().Identity, Is.Not.EqualTo(wrong.Identity)); + }); + } + + [Test] + public void FusionMode_IsPartOfRenderOutputCacheIdentity() + { + var lookup = new RecordingLookup(); + RenderOutputCacheIdentity enabledIdentity; + using (Scenario enabled = SingleCandidate(fusionMode: FusionMode.Enabled)) + { + RenderCacheResolution cold = Resolve(enabled, lookup); + RenderCacheMissCapture capture = cold.MissCaptures.Single(); + enabledIdentity = capture.Identity; + lookup.Add(capture); + } + + using Scenario disabled = SingleCandidate(fusionMode: FusionMode.Disabled); + RenderCacheResolution resolution = Resolve(disabled, lookup); + + Assert.Multiple(() => + { + Assert.That(resolution.Hits, Is.Empty); + Assert.That(resolution.MissCaptures, Has.Length.EqualTo(1)); + Assert.That(resolution.MissCaptures.Single().Identity, Is.Not.EqualTo(enabledIdentity)); + Assert.That(enabledIdentity.FusionMode, Is.EqualTo(FusionMode.Enabled)); + Assert.That(resolution.MissCaptures.Single().Identity.FusionMode, Is.EqualTo(FusionMode.Disabled)); + }); + } + + [Test] + public void MissCapture_RetainsProducerValuesAndProvenanceWithoutChangingTokenTopology() + { + RenderFragmentReference source = Pure(); + RenderFragmentReference command = Boundary(RenderFragmentKind.TargetCommand, source); + using Scenario scenario = Build( + [source, command], + [command], + [(source, "source")]); + TargetDependencyPlan before = TargetDependencyLowerer.Lower([command]); + + RenderCacheResolution resolution = Resolve(scenario); + TargetDependencyPlan after = TargetDependencyLowerer.Lower([command]); + RecordedRenderFragment producer = scenario.Graph.Fragments.Single(item => item.Id == source.Id); + RenderCacheMissCapture capture = resolution.MissCaptures.Single(); + + Assert.Multiple(() => + { + Assert.That(capture.ProducerId, Is.EqualTo(producer.Id)); + Assert.That(capture.ValueIds, Is.EqualTo(producer.Values)); + Assert.That(capture.ProvenanceId, Is.EqualTo(producer.ProvenanceId)); + Assert.That(command.Inputs.Single(), Is.SameAs(source)); + Assert.That(after.Steps, Is.EqualTo(before.Steps)); + Assert.That(after.Scopes, Is.EqualTo(before.Scopes)); + }); + } + + [Test] + public void Resolve_BeforeRegionDiscovery_IsRejected() + { + RenderFragmentReference source = Pure(); + using Scenario scenario = Build( + [source], + [source], + [(source, "source")], + stopAtMetadata: true); + + Assert.That( + () => new RenderCacheResolver().Resolve( + scenario.Request, + scenario.Graph, + scenario.Regions, + RenderRequestCompiler.ResolveRoots(scenario.Graph), + s_context), + Throws.InvalidOperationException); + } + + [Test] + public void Resolve_DefaultContextWithoutCandidates_IsRejected() + { + RenderFragmentReference source = Pure(); + using Scenario scenario = Build( + [source], + [source], + []); + + Assert.That( + () => new RenderCacheResolver().Resolve( + scenario.Request, + scenario.Graph, + scenario.Regions, + RenderRequestCompiler.ResolveRoots(scenario.Graph), + default), + Throws.ArgumentException); + } + + private static Scenario PrefixAndTail(int frame) + { + RenderFragmentReference prefix = Pure(payload: new RuntimeValue(100)); + RenderFragmentReference tail = Pure([prefix], payload: new RuntimeValue(frame)); + return Build( + [prefix, tail], + [tail], + [(prefix, "prefix"), (tail, "tail")], + names: new Dictionary + { + ["prefix"] = prefix, + ["tail"] = tail, + }); + } + + private static Scenario ShaderCandidate( + ShaderDescription description, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity, + EffectiveScale? scale = null) + { + RenderFragmentReference source = Pure(scale: scale); + var shader = new RenderFragmentReference( + RenderFragmentKind.Shader, + s_bounds, + scale ?? EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [source], + new ShaderRenderFragmentPayload(description), + static _ => true); + return Build( + [source, shader], + [shader], + [(shader, "shader")], + outputScale: outputScale, + maxWorkingScale: maxWorkingScale); + } + + private static Scenario ShaderFanOut( + ShaderDescription sharedDescription, + bool widenSiblingRequirement) + { + RenderFragmentReference source = Pure(); + RenderFragmentReference shared = Shader(source, sharedDescription); + RenderFragmentReference candidate = Shader( + shared, + ShaderDescription.CurrentPixel("half4 apply(half4 color) { return color; }")); + RenderBoundsContract siblingBounds = widenSiblingRequirement + ? RenderBoundsContract.Create( + static input => input, + static requested => requested.Inflate(new Thickness(8))) + : RenderBoundsContract.Create( + static input => input, + static requested => requested); + RenderFragmentReference sibling = Shader( + shared, + ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + siblingBounds)); + + return Build( + [source, shared, candidate, sibling], + [candidate, sibling], + [(candidate, "fanout-candidate")], + requestedRegion: new Rect(16, 16, 16, 16), + names: new Dictionary + { + ["candidate"] = candidate, + ["shared"] = shared, + }); + } + + private static Scenario TransparentShaderFanOut( + ShaderDescription reusableDescription, + bool widenSiblingRequirement) + { + RenderFragmentReference source = Pure(); + RenderFragmentReference producer = Shader( + source, + ShaderDescription.CurrentPixel("half4 apply(half4 color) { return color; }")); + RenderFragmentReference wrapper = Pure([producer]); + RenderFragmentReference candidate = Shader(wrapper, reusableDescription); + RenderBoundsContract siblingBounds = widenSiblingRequirement + ? RenderBoundsContract.Create( + static input => input, + static requested => requested.Inflate(new Thickness(8))) + : RenderBoundsContract.Create( + static input => input, + static requested => requested); + RenderFragmentReference sibling = Shader( + producer, + ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + siblingBounds)); + + return Build( + [source, producer, wrapper, candidate, sibling], + [candidate, sibling], + [(candidate, "transparent-fanout-candidate")], + requestedRegion: new Rect(16, 16, 16, 16), + names: new Dictionary + { + ["candidate"] = candidate, + ["producer"] = producer, + ["wrapper"] = wrapper, + }); + } + + private static Scenario ExternalReusableShaderFanOut(bool widenSiblingRequirement) + { + RenderFragmentReference source = Pure(); + RenderFragmentReference producer = Shader( + source, + ShaderDescription.CurrentPixel("half4 apply(half4 color) { return color; }")); + RenderFragmentReference candidate = Shader( + producer, + ShaderDescription.CurrentPixel("half4 apply(half4 color) { return color; }")); + RenderBoundsContract siblingBounds = widenSiblingRequirement + ? RenderBoundsContract.Create( + static input => input, + static requested => requested.Inflate(new Thickness(8))) + : RenderBoundsContract.Create( + static input => input, + static requested => requested); + ShaderDescription siblingDescription = ShaderDescription.WholeSource( + "uniform shader src; uniform float amount; " + + "half4 main(float2 coord) { return src.eval(coord) * amount; }", + siblingBounds, + bindings => bindings.Uniform( + "amount", + 1f, + static (writer, value, context) => writer.Set(value + context.InputBounds.Width))); + RenderFragmentReference sibling = Shader(producer, siblingDescription); + + return Build( + [source, producer, candidate, sibling], + [candidate, sibling], + [(candidate, "binder-free-candidate")], + requestedRegion: new Rect(16, 16, 16, 16), + names: new Dictionary + { + ["candidate"] = candidate, + ["producer"] = producer, + }); + } + + private static RenderFragmentReference Shader( + RenderFragmentReference input, + ShaderDescription description) + => new( + RenderFragmentKind.Shader, + description.Bounds.TransformBounds(input.Bounds), + input.EffectiveScale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [input], + new ShaderRenderFragmentPayload(description), + static _ => true); + + private static Scenario GeometryCandidate(GeometryDescription description) + { + RenderFragmentReference source = Pure(); + var geometry = new RenderFragmentReference( + RenderFragmentKind.Geometry, + s_bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [source], + new GeometryRenderFragmentPayload(description), + static _ => true); + return Build( + [source, geometry], + [geometry], + [(geometry, "geometry")]); + } + + private static Scenario OpacityMaskCandidate(float outputScale) + { + RenderFragmentReference primary = Pure(scale: EffectiveScale.At(0.5f)); + RenderFragmentReference dependency = Pure(); + var opacityMask = new RenderFragmentReference( + RenderFragmentKind.OpacityMask, + s_bounds, + EffectiveScale.At(0.5f), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [primary, dependency], + payload: null, + static _ => true); + return Build( + [primary, dependency, opacityMask], + [opacityMask], + [(opacityMask, "mask")], + outputScale: outputScale, + names: new Dictionary + { + ["dependency"] = dependency, + }); + } + + private static RenderFragmentReference ValueReplayMap( + RenderFragmentReference input, + EffectiveScale scale, + string key, + Rect? bounds = null, + RenderBoundsContract? boundsContract = null, + RenderScaleContract? scaleContract = null) + { + TargetScopeDescription description = TargetScopeDescription.CreateValueReplayMap( + static session => session.Canvas.Use(_ => session.ReplayInput()), + boundsContract ?? RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + scaleContract ?? RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + return new RenderFragmentReference( + RenderFragmentKind.TargetScope, + bounds ?? input.Bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [input], + new TargetScopeRenderFragmentPayload(description), + static _ => true); + } + + private static RenderFragmentReference AuthoredTargetScope( + RenderFragmentReference input, + RenderScaleContract scale, + RenderScopeTransformSpace transformSpace) + { + TargetScopeDescription description = TargetScopeDescription.Create( + (byte)0, + static (session, _) => session.ReplayInput(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + scale, + RenderDeviceGridSensitivity.Insensitive, + RenderDeviceGridMapping.Remapped, + transformSpace); + return new RenderFragmentReference( + RenderFragmentKind.TargetScope, + input.Bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [input], + new TargetScopeRenderFragmentPayload(description), + static _ => true); + } + + private static RenderFragmentReference RawTargetScope( + RenderFragmentReference input, + RenderScaleContract scale) + { + RawTargetScopeDescription description = RawTargetScopeDescription.CreateRequestLocal( + static session => session.ReplayInput(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + scale); + return new RenderFragmentReference( + RenderFragmentKind.RawTargetScope, + input.Bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: true, + [input], + new RawTargetScopeRenderFragmentPayload(description), + static _ => true); + } + + private static EffectiveScale ReduceSupplyByFour(EffectiveScale inputSupply) + => inputSupply.IsUnbounded ? EffectiveScale.Unbounded : EffectiveScale.At(inputSupply.Value / 4); + + private static EffectiveScale QuadrupleDemand(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 4); + + private static EffectiveScale ScaleDemandByOneMillion(EffectiveScale outputDemand) + => EffectiveScale.At(outputDemand.Value * 1_000_000); + + private static RenderRequest NewRequest() + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + cachePolicy: RenderCacheOptions.Enabled, + targetDomain: s_bounds)); + + private static RenderNodeRenderer CreateFrameRenderer( + RenderNode node, + IRenderTargetFactory? targetFactory = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = targetFactory, + }); + + private static Scenario SingleCandidate( + Rect? requestedRegion = null, + float outputScale = 1, + RenderRequestPurpose purpose = RenderRequestPurpose.Frame, + Rect? bounds = null, + object? candidateKey = null, + FusionMode fusionMode = FusionMode.Enabled) + { + RenderFragmentReference source = Pure(bounds: bounds); + return Build( + [source], + [source], + [(source, candidateKey ?? "source")], + requestedRegion, + outputScale, + purpose, + fusionMode: fusionMode); + } + + private static void AssertMiss( + Scenario scenario, + RenderCacheResolutionContext context, + IRenderCacheLookup lookup) + { + using (scenario) + { + RenderCacheResolution resolution = Resolve(scenario, lookup, context); + Assert.That(resolution.Hits, Is.Empty); + Assert.That(resolution.MissCaptures.Length, Is.EqualTo(1)); + } + } + + private static RenderCacheResolution Resolve( + Scenario scenario, + IRenderCacheLookup? lookup = null, + RenderCacheResolutionContext? context = null) + => ResolvePlanning(scenario, lookup, context).Resolution; + + private static RenderCachePlanningResult ResolvePlanning( + Scenario scenario, + IRenderCacheLookup? lookup = null, + RenderCacheResolutionContext? context = null) + => new RenderCacheResolver().Resolve( + scenario.Request, + scenario.Graph, + scenario.Regions, + RenderRequestCompiler.ResolveRoots(scenario.Graph), + context ?? s_context, + lookup); + + private static Scenario Build( + IReadOnlyList references, + IReadOnlyList roots, + IReadOnlyList<(RenderFragmentReference Reference, object Key)> candidates, + Rect? requestedRegion = null, + float outputScale = 1, + RenderRequestPurpose purpose = RenderRequestPurpose.Frame, + IReadOnlyDictionary? names = null, + bool stopAtMetadata = false, + float maxWorkingScale = float.PositiveInfinity, + RenderCacheRules? cacheRules = null, + FusionMode fusionMode = FusionMode.Enabled) + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + purpose, + targetDomain: s_bounds, + requestedRegion: requestedRegion, + outputScale: outputScale, + maxWorkingScale: maxWorkingScale, + cachePolicy: new RenderCacheOptions( + IsEnabled: true, + cacheRules ?? RenderCacheRules.Default), + fusionMode: fusionMode); + var request = new RenderRequest(options); + var builder = new RecordedRenderGraphBuilder(request.Id); + var provenance = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach (RenderFragmentReference reference in references) + { + RenderProvenanceId provenanceId = builder.AddProvenance(reference, "test-node"); + provenance.Add(reference, provenanceId); + RenderValueId[] inputs = reference.Inputs.SelectMany(static item => item.ValueIds).ToArray(); + reference.ValueIds = reference.ValueCardinality.Maximum == 0 + ? [] + : [builder.AddValue(inputs, provenanceId, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenanceId, reference); + } + + var candidateIds = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((RenderFragmentReference reference, object key) in candidates) + { + candidateIds.Add( + reference, + builder.AddCacheCandidate(reference.Id!.Value, key)); + } + foreach (RenderFragmentReference root in roots) + builder.PublishRoot(root.Id!.Value); + + RecordedRenderGraph graph = builder.Build(); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + _ = TargetDependencyLowerer.Lower([.. roots], options.TargetDomain); + request.TransitionTo(RenderRequestState.TargetDependenciesLowered); + request.TransitionTo(RenderRequestState.MetadataResolved); + RegionAnalysis regions = new RegionAnalyzer().Analyze(options, roots); + if (!stopAtMetadata) + request.TransitionTo(RenderRequestState.RegionsResolved); + + return new Scenario(request, graph, regions, candidateIds, names); + } + + private static RenderFragmentReference Pure( + IReadOnlyList? inputs = null, + object? payload = null, + Rect? bounds = null, + EffectiveScale? scale = null) + { + inputs ??= []; + return new RenderFragmentReference( + RenderFragmentKind.ContributeValues, + bounds ?? s_bounds, + scale ?? EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: inputs.Any(static item => item.HasTargetEffects), + hasOpaqueExternalWork: inputs.Any(static item => item.HasOpaqueExternalWork), + inputs, + payload, + static _ => true); + } + + + private static ShaderRenderFragmentPayload CreateShaderPayload() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + return new ShaderRenderFragmentPayload(description); + } + + private static GeometryRenderFragmentPayload CreateGeometryPayload() + { + GeometryDescription description = GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.OutputBounds); + return new GeometryRenderFragmentPayload(description); + } + + private static RenderFragmentReference FixedScaleMap( + RenderFragmentReference input, + float authoredScale, + float resolvedScale) + { + var identity = new FixedScaleIdentity(authoredScale); + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + static _ => { }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.Custom( + new FixedScaleResolver(authoredScale).Resolve)); + return new RenderFragmentReference( + RenderFragmentKind.OpaqueMap, + s_bounds, + EffectiveScale.At(resolvedScale), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: true, + [input], + new OpaqueRenderFragmentPayload( + OpaqueRenderTopology.Map, + description, + [RenderInputReadback.None]), + static _ => true); + } + + private static RenderFragmentReference Boundary( + RenderFragmentKind kind, + RenderFragmentReference child) + { + object payload; + RenderValueCardinality cardinality; + bool contributes; + bool canBeUsed; + IReadOnlyList inputs; + switch (kind) + { + case RenderFragmentKind.TargetCommand: + payload = new TargetCommandRenderFragmentPayload( + TargetCommandDescription.Create( + "command", + static (_, _) => { }, + TargetRegion.Region(s_bounds), + Rect.Empty, + RenderHitTestContract.None), + []); + cardinality = RenderValueCardinality.None; + contributes = false; + canBeUsed = false; + inputs = [child]; + break; + case RenderFragmentKind.RawTargetScope: + payload = new RawTargetScopeRenderFragmentPayload( + RawTargetScopeDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply)); + cardinality = RenderValueCardinality.Single; + contributes = true; + canBeUsed = false; + inputs = [child]; + break; + case RenderFragmentKind.TargetCapture: + payload = new TargetCaptureRenderFragmentPayload( + TargetCaptureDescription.Create( + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + cardinality = RenderValueCardinality.Single; + contributes = false; + canBeUsed = true; + inputs = []; + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + return new RenderFragmentReference( + kind, + kind == RenderFragmentKind.TargetCommand ? Rect.Empty : s_bounds, + kind == RenderFragmentKind.TargetCommand ? EffectiveScale.Unbounded : EffectiveScale.At(1), + cardinality, + contributes, + canBeUsed, + hasTargetEffects: true, + hasOpaqueExternalWork: kind == RenderFragmentKind.RawTargetScope, + inputs, + payload, + static _ => false); + } + + private sealed class Scenario : IDisposable + { + private readonly IReadOnlyDictionary _candidateIds; + private readonly IReadOnlyDictionary? _names; + + public Scenario( + RenderRequest request, + RecordedRenderGraph graph, + RegionAnalysis regions, + IReadOnlyDictionary candidateIds, + IReadOnlyDictionary? names) + { + Request = request; + Graph = graph; + Regions = regions; + _candidateIds = candidateIds; + _names = names; + } + + public RenderRequest Request { get; } + + public RecordedRenderGraph Graph { get; } + + public RegionAnalysis Regions { get; } + + public RenderCacheCandidateId Candidate(RenderFragmentReference reference) + => _candidateIds[reference]; + + public RenderCacheCandidateId Candidate(string name) + => Candidate(_names![name]); + + public RenderFragmentReference Named(string name) + => _names![name]; + + public void Dispose() => Request.Dispose(); + } + + private sealed class RecordingLookup : IRenderCacheLookup + { + private readonly List _entries = []; + + public List RequestedKeys { get; } = []; + + public void Add(RenderCacheMissCapture capture) + => _entries.Add(new RenderCacheEntry(capture.Identity, new object())); + + public void AddRange(IEnumerable captures) + { + foreach (RenderCacheMissCapture capture in captures) + Add(capture); + } + + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry) + { + RequestedKeys.Add(candidate.CacheKey); + entry = _entries.FirstOrDefault(item => item.Identity.Equals(identity)); + return entry is not null; + } + } + + private sealed class CollisionLookup(RenderCacheEntry entry) : IRenderCacheLookup + { + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? result) + { + result = entry; + return true; + } + } + + private sealed class FirstIdentityOnlyLookup : IRenderCacheLookup + { + private RenderOutputCacheIdentity? _firstIdentity; + + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? result) + { + if (_firstIdentity is null) + { + _firstIdentity = identity; + result = new RenderCacheEntry(identity, new object()); + return true; + } + + if (_firstIdentity.Equals(identity)) + { + result = new RenderCacheEntry(identity, new object()); + return true; + } + + result = null; + return false; + } + } + + private sealed class DelayedIdentityHitLookup(IReadOnlyDictionary hitThresholds) + : IRenderCacheLookup + { + private readonly Dictionary> _identities = []; + + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? result) + { + if (!_identities.TryGetValue(candidate.CacheKey, out var identities)) + { + identities = []; + _identities.Add(candidate.CacheKey, identities); + } + + int identityIndex = identities.FindIndex(item => item.Equals(identity)); + if (identityIndex < 0) + { + identities.Add(identity); + identityIndex = identities.Count - 1; + } + + bool hit = identityIndex + 1 >= hitThresholds[candidate.CacheKey]; + result = hit + ? new RenderCacheEntry(identity, new object()) + : null; + return hit; + } + } + + private sealed record RuntimeValue(int Value); + + private sealed record FixedScaleResolver(float Scale) + { + public float Resolve(RenderScaleContext _) => Scale; + } + + private readonly record struct FixedScaleIdentity(float Scale); + + private static void RenderStableGeometry(GeometrySession session, string state) + { + } + + private sealed record CollidingKey(string Value) + { + public override int GetHashCode() => 7; + } + + private sealed class CacheableNode(bool disableCache) : RenderNode + { + private static readonly RenderResourceSlot s_probeSlot = new(); + private readonly ExecutionProbe _probe = new(); + private readonly object _probeKey = new(); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) + { + if (disableCache) + context.DisableRenderCache(); + + RenderResource probe = context.Borrow(_probe); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + "stable", + static (session, _) => session.UseResource( + s_probeSlot, + static probe => probe.Record()), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_probeSlot.Bind(probe)]); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class SolidCacheNode(bool throwOnExecute = false) : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + + public override void Process(RenderNodeContext context) + { + Brush.Resource fill = Brushes.Resource.Red; + RenderResource fillResource = context.Borrow(fill); + RenderResource probeResource = context.Borrow(_probe); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + throwOnExecute, + static (session, shouldThrow) => + session.UseResource(s_probeSlot, probe => + { + probe.Record(); + if (shouldThrow) + throw new InvalidOperationException("injected execution failure"); + + session.UseResource(s_fillSlot, currentFill => + { + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.DrawRectangle(s_bounds, currentFill, pen: null)); + session.Publish(output); + }); + }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_fillSlot.Bind(fillResource), s_probeSlot.Bind(probeResource)]); + context.Publish(context.OpaqueSource(description)); + } + + private static readonly RenderResourceSlot s_probeSlot = new(); + private readonly SolidCacheProbe _probe = new(); + private readonly object _probeKey = new(); + + public int ExecuteCount => _probe.Count; + + public Action? OnExecute + { + get => _probe.OnExecute; + set => _probe.OnExecute = value; + } + + } + + private sealed class SolidCacheProbe + { + public int Count { get; private set; } + + public Action? OnExecute { get; set; } + + public void Record() + { + Count++; + OnExecute?.Invoke(); + } + } + + private sealed class TrackingTargetFactory : IRenderTargetFactory + { + public List Targets { get; } = []; + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + var result = new TrackingRenderTarget(deviceSize); + Targets.Add(result); + return result; + } + } + + private sealed class TrackingRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public TrackingRenderTarget(PixelSize size) + : base(CreateSurface(size), size.Width, size.Height) + { + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + if (!IsDisposed) + DisposeCalls++; + base.Dispose(disposing); + } + + private static SKSurface CreateSurface(PixelSize size) + => SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a cache-test render target."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheHelperTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheHelperTest.cs index 608b00b97e..c7fc4692d6 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheHelperTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheHelperTest.cs @@ -2,231 +2,381 @@ using Beutl.Graphics.Rendering; using Beutl.Graphics.Rendering.Cache; using Beutl.Media; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; public class RenderNodeCacheHelperTest { [Test] - public void CanCacheRecursive_ShouldReturnFalse_WhenCacheCannotCache() + public void DefaultPolicy_IsDisabledAndCacheRequiresExplicitOptIn() { - // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); + Assert.Multiple(() => + { + Assert.That(RenderCacheOptions.Default.IsEnabled, Is.False); + Assert.That(RenderCacheOptions.Default, Is.SameAs(RenderCacheOptions.Disabled)); + Assert.That(RenderCacheOptions.Enabled.IsEnabled, Is.True); + }); + } - // Act - bool result = RenderNodeCacheHelper.CanCacheRecursive(containerNode); + [Test] + public void Lifecycle_FollowsReferencedChildNodesForInvalidation() + { + using var child = new ContainerRenderNode(); + using var root = new ReferencesChildRenderNode(child); + RenderNodeCache.PublishAtomically( + [ + RenderCacheTestSupport.CreatePublication(root.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + RenderCacheTestSupport.CreatePublication(child.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + ]); + child.HasChanges = true; + + RenderNodeCacheHelper.BeginLifecycle(root); + + Assert.Multiple(() => + { + Assert.That(root.Cache.IsCached, Is.False); + Assert.That(child.Cache.IsCached, Is.False); + }); + } - // Assert - Assert.That(result, Is.False); + [Test] + public void Lifecycle_WithoutSuccessfulCompletion_DoesNotClearDirtyFlags() + { + using var root = new ContainerRenderNode { HasChanges = true }; + + _ = RenderNodeCacheHelper.BeginLifecycle(root); + + Assert.That(root.HasChanges, Is.True); } [Test] - public void CanCacheRecursive_ShouldReturnTrue_WhenCacheCanCache() + public void ClearOwnedCaches_ShouldInvalidateCache() { // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); + using var node = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); + Rect bounds = new(0, 0, 100, 100); + using (var renderTarget = RenderTarget.CreateNull(100, 100)) + { + RenderNodeCache.PublishAtomically( + [RenderCacheTestSupport.CreatePublication(node.Cache, renderTarget, bounds)]); + } // Act - bool result = RenderNodeCacheHelper.CanCacheRecursive(containerNode); + RenderNodeCacheHelper.ClearOwnedCaches(node); // Assert - Assert.That(result, Is.True); + Assert.That(node.Cache.IsCached, Is.False); } [Test] - public void CanCacheRecursive_ShouldReturnFalse_WhenChildCountIsDifferent() + public void ClearOwnedCaches_ShouldInvalidateCache_WhenNodeIsContainerRenderNode() { // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); - containerNode.AddChild(new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null)); + using var node = new ContainerRenderNode(); + using var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); + Rect bounds = new(0, 0, 100, 100); + using (var renderTarget = RenderTarget.CreateNull(100, 100)) + { + RenderNodeCache.PublishAtomically( + [RenderCacheTestSupport.CreatePublication(childNode.Cache, renderTarget, bounds)]); + } + node.AddChild(childNode); // Act - bool result = RenderNodeCacheHelper.CanCacheRecursive(containerNode); + RenderNodeCacheHelper.ClearOwnedCaches(node); // Assert - Assert.That(result, Is.False); + Assert.That(childNode.Cache.IsCached, Is.False); } [Test] - public void CanCacheRecursive_ShouldReturnFalse_WhenChildIsDifferent() + public void DirectFrameRequests_WarmAutomaticallyAndPublishEligibleCacheCandidates() { - // Arrange + using var containerNode = new ContainerRenderNode(); var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); - containerNode.SetChild(0, new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null)); + containerNode.HasChanges = true; + using var renderer = CreateFrameRenderer(containerNode); - // Act - bool result = RenderNodeCacheHelper.CanCacheRecursive(containerNode); + RenderRequests(renderer, RenderNodeCache.StableRequestCount + 2); - // Assert - Assert.That(result, Is.False); + Assert.Multiple(() => + { + Assert.That(containerNode.HasChanges, Is.False); + Assert.That(containerNode.Cache.IsCached, Is.True); + Assert.That(childNode.Cache.IsCached, Is.True); + }); } [Test] - public void CanCacheRecursiveChildrenOnly_ShouldReturnFalse_WhenAnyChildCannotCache() + public void FrameRequest_ShouldNotPublishWhenCachePolicyIsDisabled() { - // Arrange + using var containerNode = new ContainerRenderNode(); var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); containerNode.AddChild(childNode); - containerNode.Cache.ReportRenderCount(3); + containerNode.HasChanges = true; + using var renderer = CreateFrameRenderer(containerNode, useRenderCache: false); - // Act - var result = RenderNodeCacheHelper.CanCacheRecursiveChildrenOnly(containerNode); + RenderRequests(renderer, RenderNodeCache.StableRequestCount + 2); - // Assert - Assert.That(result, Is.False); + Assert.Multiple(() => + { + Assert.That(containerNode.Cache.IsCached, Is.False); + Assert.That(childNode.Cache.IsCached, Is.False); + }); } [Test] - public void CanCacheRecursiveChildrenOnly_ShouldReturnTrue_WhenAllChildrenCanCache() + public void DirectFrameRequest_DoesNotCaptureBeforeAutomaticWarmupCompletes() { - // Arrange + using var containerNode = new ContainerRenderNode(); var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); + containerNode.SettleConstruction(); + using var renderer = CreateFrameRenderer(containerNode); - // Act - bool result = RenderNodeCacheHelper.CanCacheRecursiveChildrenOnly(containerNode); + RenderRequests(renderer, RenderNodeCache.StableRequestCount); - // Assert - Assert.That(result, Is.True); + Assert.Multiple(() => + { + Assert.That(containerNode.Cache.IsCached, Is.False); + Assert.That(childNode.Cache.IsCached, Is.False); + }); + + RenderRequests(renderer, 1); + + Assert.Multiple(() => + { + Assert.That(containerNode.Cache.IsCached, Is.True); + Assert.That(childNode.Cache.IsCached, Is.True); + }); } [Test] - public void ClearCache_ShouldInvalidateCache() + public void DirectFrameRequest_WhenRecordingFails_RetainsDirtyFlag() { - // Arrange - using var node = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - using (var renderTarget = RenderTarget.CreateNull(100, 100)) - { - node.Cache.StoreCache(renderTarget, new Rect(0, 0, 100, 100)); - } + using var node = new ThrowingRenderNode { HasChanges = true }; + using var renderer = CreateFrameRenderer(node); - // Act - RenderNodeCacheHelper.ClearCache(node); + Assert.That( + () => renderer.Rasterize(), + Throws.TypeOf().With.Message.EqualTo("recording failed")); - // Assert - Assert.That(node.Cache.IsCached, Is.False); + Assert.Multiple(() => + { + Assert.That(node.HasChanges, Is.True); + Assert.That(node.Cache.SuccessfulStableRequestCount, Is.Zero); + }); } [Test] - public void ClearCache_ShouldInvalidateCache_WhenNodeIsContainerRenderNode() + public void DirectFrameRequest_CallStateChangesReuseWarmCacheUntilHasChangesIsSet() { - // Arrange - using var node = new ContainerRenderNode(); - using var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - using (var renderTarget = RenderTarget.CreateNull(100, 100)) + using var node = new StatefulCallNode(Colors.Red); + using var renderer = CreateFrameRenderer(node); + + RenderRequests(renderer, RenderNodeCache.StableRequestCount + 1); + int warmedExecutionCount = node.ExecutionCount; + + Assert.That(node.Cache.IsCached, Is.True); + + node.SetCallState(Colors.Blue, reportChanges: false); + using (renderer.Rasterize()) { - childNode.Cache.StoreCache(renderTarget, new Rect(0, 0, 100, 100)); } - node.AddChild(childNode); - // Act - RenderNodeCacheHelper.ClearCache(node); + Assert.Multiple(() => + { + Assert.That(node.ExecutionCount, Is.EqualTo(warmedExecutionCount), + "Call state alone must not replace a warmed output cache entry."); + Assert.That(node.Cache.IsCached, Is.True); + }); - // Assert - Assert.That(childNode.Cache.IsCached, Is.False); + node.SetCallState(Colors.Green, reportChanges: true); + using (renderer.Rasterize()) + { + } + + Assert.Multiple(() => + { + Assert.That(node.ExecutionCount, Is.EqualTo(warmedExecutionCount + 1), + "HasChanges must evict the warm output and execute the newly recorded Call state."); + Assert.That(node.HasChanges, Is.False, + "The successful request, not the state setter, consumes the invalidation signal."); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(node.Cache.SuccessfulStableRequestCount, Is.Zero); + }); } [Test] - public void MakeCache_ShouldCreateCache_WhenCacheIsEnabledAndCanCache() + public void DirectFrameRequests_DirtyParentAndChangingSiblingEachFrame_WarmAndReuseStableChildCache() { - // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); - var cacheOptions = new RenderCacheOptions(true, RenderCacheRules.Default); + using var parent = new ContainerRenderNode(); + using var stable = new StatefulCallNode(Colors.Red); + using var changing = new StatefulCallNode(Colors.Blue); + parent.AddChild(stable); + parent.AddChild(changing); + using var renderer = CreateFrameRenderer(parent); + + for (int frame = 0; frame <= RenderNodeCache.StableRequestCount; frame++) + { + parent.HasChanges = true; + changing.SetCallState( + frame % 2 == 0 ? Colors.Blue : Colors.Green, + reportChanges: true); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + } - // Act - RenderNodeCacheHelper.MakeCache(containerNode, cacheOptions); + int stableExecutionCount = stable.ExecutionCount; + int changingExecutionCount = changing.ExecutionCount; + Assert.Multiple(() => + { + Assert.That(parent.Cache.CanCapture, Is.False, "the dirty parent restarts warm-up each frame"); + Assert.That(parent.Cache.IsCached, Is.False); + Assert.That(changing.Cache.CanCapture, Is.False, "the changing child restarts warm-up each frame"); + Assert.That(changing.Cache.IsCached, Is.False); + Assert.That(stable.Cache.CanCapture, Is.True, "the unchanged child reaches cache admission"); + Assert.That(stable.Cache.IsCached, Is.True, "the unchanged child publishes a reusable output"); + }); + + parent.HasChanges = true; + changing.SetCallState(Colors.Purple, reportChanges: true); + using (renderer.Rasterize()) + { + } - // Assert - Assert.That(containerNode.Cache.IsCached, Is.True); + Assert.Multiple(() => + { + Assert.That(parent.Cache.CanCapture, Is.False); + Assert.That(changing.Cache.CanCapture, Is.False); + Assert.That(changing.ExecutionCount, Is.EqualTo(changingExecutionCount + 1)); + Assert.That(stable.Cache.CanCapture, Is.True); + Assert.That(stable.Cache.IsCached, Is.True); + Assert.That(stable.ExecutionCount, Is.EqualTo(stableExecutionCount), + "the stable child output is reused while its parent and sibling change."); + }); } - [Test] - public void MakeCache_ShouldNotCreateCache_WhenCacheIsDisabled() + [TestCase(10_404, true)] + [TestCase(10_403, false)] + public void FrameRequest_ShouldApplyConfiguredCacheRulesToPhysicalCapture(int maxPixels, bool expectedCached) { - // Arrange + using var containerNode = new ContainerRenderNode(); var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); - var cacheOptions = new RenderCacheOptions(false, RenderCacheRules.Default); + containerNode.HasChanges = true; + using var renderer = CreateFrameRenderer( + containerNode, + cacheRules: new RenderCacheRules(maxPixels, 1)); - // Act - RenderNodeCacheHelper.MakeCache(containerNode, cacheOptions); + RenderRequests(renderer, RenderNodeCache.StableRequestCount + 2); - // Assert - Assert.That(containerNode.Cache.IsCached, Is.False); + Assert.That(containerNode.Cache.IsCached, Is.EqualTo(expectedCached)); } - [Test] - public void MakeCache_ShouldNotCreateCache_WhenCannotCacheChildren() + private static RenderNodeRenderer CreateFrameRenderer( + RenderNode node, + bool useRenderCache = true, + RenderCacheRules? cacheRules = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 100, 100), + CacheOptions = new RenderCacheOptions( + useRenderCache, + cacheRules ?? RenderCacheRules.Default), + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static void RenderRequests(RenderNodeRenderer renderer, int count) { - // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - containerNode.Cache.ReportRenderCount(3); - var cacheOptions = new RenderCacheOptions(true, RenderCacheRules.Default); - - // Act - RenderNodeCacheHelper.MakeCache(containerNode, cacheOptions); + for (int i = 0; i < count; i++) + { + using RenderNodeRasterization rasterization = renderer.Rasterize(); + } + } - // Assert - Assert.That(containerNode.Cache.IsCached, Is.False); + private sealed class ThrowingRenderNode : RenderNode + { + public override void Process(RenderNodeContext context) + => throw new InvalidOperationException("recording failed"); } - [Test] - public void CreateDefaultCache_ShouldStoreCache_WhenCacheRulesMatch() + private sealed class StatefulCallNode(Color color) : RenderNode { - // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - childNode.Cache.ReportRenderCount(3); - containerNode.Cache.ReportRenderCount(3); - var cacheOptions = new RenderCacheOptions(true, new RenderCacheRules(10000, 1)); + private static readonly Rect s_bounds = new(0, 0, 100, 100); + private static readonly RenderResourceSlot s_probeSlot = new(); + private static readonly OpaqueRenderDefinition s_definition = + OpaqueRenderDefinition.Create( + static (session, state) => session.UseResource(s_probeSlot, probe => + { + probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(state)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector, + resources: [s_probeSlot]); + + private readonly ExecutionProbe _probe = new(); + private Color _color = color; + + public int ExecutionCount => _probe.Count; + + public void SetCallState(Color color, bool reportChanges) + { + _color = color; + if (reportChanges) + HasChanges = true; + } - // Act - RenderNodeCacheHelper.CreateDefaultCache(containerNode, cacheOptions); + public override void Process(RenderNodeContext context) + { + RenderResource probe = context.Borrow(_probe); + context.Publish(context.OpaqueSource(s_definition.Call( + _color, + [s_probeSlot.Bind(probe)]))); + } + } - // Assert - Assert.That(containerNode.Cache.IsCached, Is.True); + private sealed class ExecutionProbe + { + public int Count { get; private set; } + + public void Record() => Count++; } - [Test] - public void CreateDefaultCache_ShouldNotStoreCache_WhenCacheRulesDoNotMatch() + private sealed class CpuTargetFactory : IRenderTargetFactory { - // Arrange - var childNode = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - var containerNode = new ContainerRenderNode(); - containerNode.AddChild(childNode); - var cacheOptions = new RenderCacheOptions(true, new RenderCacheRules(1, 1)); + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); - // Act - RenderNodeCacheHelper.CreateDefaultCache(containerNode, cacheOptions); + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); - // Assert - Assert.That(containerNode.Cache.IsCached, Is.False); + private sealed class CpuRenderTarget : RenderTarget + { + public CpuRenderTarget(PixelSize size) + : base( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a CPU cache-test target."), + size.Width, + size.Height) + { + } + } } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheTests.cs index c966fb7fd9..f216ae5413 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheTests.cs @@ -1,140 +1,218 @@ using System.Linq; +using System.Runtime.CompilerServices; using Beutl.Graphics; using Beutl.Graphics.Rendering; using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; public class RenderNodeCacheTests { [Test] - [TestCase(3)] - [TestCase(4)] - public void ReportRenderCount_GreaterThanOrEqualToThree_ShouldSetCanCacheToTrue(int count) + public void StableRequests_ReachingThreshold_AdmitsCacheCapture() { - // Arrange using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); - // Act - cache.ReportRenderCount(count); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + node.Cache.RecordSuccessfulStableRequest(); + } - // Assert - Assert.That(cache.CanCache(), Is.True); + Assert.That(node.Cache.CanCapture, Is.True); } [Test] - public void IncrementRenderCount_CalledThreeOrMoreTimes_ShouldSetCanCacheToTrue() + public void DirtyNode_BeginLifecycleInvalidatesCacheAndResetsWarmup() { - // Arrange using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); + RenderNodeCache.PublishAtomically( + [RenderCacheTestSupport.CreatePublication(node.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1))]); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + node.Cache.RecordSuccessfulStableRequest(); + } + node.HasChanges = true; - // Act - cache.IncrementRenderCount(); - cache.IncrementRenderCount(); - cache.IncrementRenderCount(); + RenderNodeCacheLifecycle lifecycle = RenderNodeCacheHelper.BeginLifecycle(node); - // Assert - Assert.That(cache.CanCache(), Is.True); - } + Assert.Multiple(() => + { + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(node.Cache.SuccessfulStableRequestCount, Is.Zero); + Assert.That(node.HasChanges, Is.True); + }); - [Test] - public void UseCache_NotCached_ShouldThrowInvalidOperationException() - { - // Arrange - using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); + lifecycle.CompleteSuccessfully(advanceWarmup: true); - // Act & Assert - Assert.Catch(() => cache.UseCache(out _)); + Assert.That(node.HasChanges, Is.False); } [Test] - public void UseCache_NotCached_ShouldReturnEmptyArray() + public void DirtyBranch_BeginLifecycleInvalidatesItselfAndAncestorsButKeepsUnchangedDescendantsAndSiblings() { - // Arrange - using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); + using var root = new ContainerRenderNode(); + using var dirtyBranch = new ContainerRenderNode(); + using var dirtyLeaf = new ContainerRenderNode(); + using var sibling = new ContainerRenderNode(); + root.AddChild(dirtyBranch); + root.AddChild(sibling); + dirtyBranch.AddChild(dirtyLeaf); + + RenderNodeCache.PublishAtomically( + [ + RenderCacheTestSupport.CreatePublication(root.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + RenderCacheTestSupport.CreatePublication(dirtyBranch.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + RenderCacheTestSupport.CreatePublication(dirtyLeaf.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + RenderCacheTestSupport.CreatePublication(sibling.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + ]); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + root.Cache.RecordSuccessfulStableRequest(); + dirtyBranch.Cache.RecordSuccessfulStableRequest(); + dirtyLeaf.Cache.RecordSuccessfulStableRequest(); + sibling.Cache.RecordSuccessfulStableRequest(); + } + dirtyBranch.HasChanges = true; - // Act - var result = cache.UseCache(); + RenderNodeCacheLifecycle lifecycle = RenderNodeCacheHelper.BeginLifecycle(root); - // Assert - Assert.That(result, Is.Empty); + Assert.Multiple(() => + { + Assert.That(root.Cache.IsCached, Is.False, "a dirty descendant must invalidate its ancestor"); + Assert.That(dirtyBranch.Cache.IsCached, Is.False); + Assert.That(root.Cache.SuccessfulStableRequestCount, Is.Zero); + Assert.That(dirtyBranch.Cache.SuccessfulStableRequestCount, Is.Zero); + Assert.That(dirtyLeaf.Cache.IsCached, Is.True, "an unchanged descendant remains reusable"); + Assert.That(dirtyLeaf.Cache.CanCapture, Is.True, "an unchanged descendant retains its warm-up"); + Assert.That(sibling.Cache.IsCached, Is.True, "an unrelated sibling remains reusable"); + Assert.That(sibling.Cache.CanCapture, Is.True, "an unrelated sibling retains its warm-up"); + }); + + lifecycle.CompleteSuccessfully(advanceWarmup: true); + + Assert.That(dirtyBranch.HasChanges, Is.False); } + // HasChanges is one consumable flag per node, but a node reached through ChildNodes can be shared by + // several roots, and each root's lifecycle only sees its own snapshot. [Test] - public void StoreCache_Called_ShouldStoreCache() + public void SharedChild_ChangeInvalidatesEveryCachedDependentRoot() { - // Arrange - using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); + using var shared = new ContainerRenderNode(); + using var parentA = new ReferencesChildRenderNode(shared); + using var parentB = new ReferencesChildRenderNode(shared); + + RenderNodeCache.PublishAtomically( + [ + RenderCacheTestSupport.CreatePublication( + parentB.Cache, RenderTarget.CreateNull(1, 1), new Rect(0, 0, 1, 1)), + ]); + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + parentB.Cache.RecordSuccessfulStableRequest(); + shared.Cache.RecordSuccessfulStableRequest(); + } - // Act - using var renderTarget = RenderTarget.CreateNull(1, 1); - cache.StoreCache(renderTarget, new Rect(0, 0, 1, 1)); + RenderNodeCacheHelper.BeginLifecycle(parentB).CompleteSuccessfully(advanceWarmup: true); + Assert.That(parentB.Cache.IsCached, Is.True); - // Assert - Assert.That(cache.IsCached, Is.True); - } + shared.HasChanges = true; - [Test] - public void StoreCache_CalledMultipleTimes_ShouldStoreMultipleCaches() - { - // Arrange - using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); + RenderNodeCacheHelper.BeginLifecycle(parentA).CompleteSuccessfully(advanceWarmup: true); - // Act - using var renderTarget1 = RenderTarget.CreateNull(1, 1); - using var renderTarget2 = RenderTarget.CreateNull(1, 1); - cache.StoreCache([(renderTarget1, new Rect(0, 0, 1, 1)), (renderTarget2, new Rect(0, 0, 1, 1))]); + RenderNodeCacheHelper.BeginLifecycle(parentB); - // Assert - Assert.That(cache.IsCached, Is.True); - Assert.That(cache.UseCache().Count(), Is.EqualTo(2)); + Assert.That(parentB.Cache.IsCached, Is.False, + "a shared child's change must invalidate every cached dependent, not only the first root that observed it"); } [Test] - public void StoreCache_Called_ShouldInvalidateExistingCache() + [NonParallelizable] + public void Finalizer_SwallowsCachedTargetCleanupFailure() { - // Arrange - using var node = new ContainerRenderNode(); - using var cache = new RenderNodeCache(node); - using (var renderTarget = RenderTarget.CreateNull(1, 1)) - { - cache.StoreCache(renderTarget, new Rect(0, 0, 1, 1)); - } + var cleanup = new InvalidOperationException("finalizer cache cleanup failed"); + var target = new ThrowingRenderTarget(cleanup); + WeakReference cacheReference = CreateAbandonedCache(target); - // Act - using (var newRenderTarget = RenderTarget.CreateNull(1, 1)) + for (int attempt = 0; attempt < 3 && cacheReference.IsAlive; attempt++) { - cache.StoreCache(newRenderTarget, new Rect(0, 0, 1, 1)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); } - // Assert - Assert.That(cache.IsCached, Is.True); - Assert.That(cache.UseCache().Count(), Is.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(cacheReference.IsAlive, Is.False); + Assert.That(target.IsDisposed, Is.True); + Assert.That(target.DisposeCalls, Is.EqualTo(1)); + }); } - [Test] - public void IncrementRenderCount_WhenNodeChanged_ShouldInvalidateExistingCache() + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAbandonedCache(ThrowingRenderTarget target) { - // Arrange - using var node = new ContainerRenderNode(); - using var renderTarget = RenderTarget.CreateNull(1, 1); - node.Cache.StoreCache(renderTarget, new Rect(0, 0, 1, 1)); - node.Cache.ReportRenderCount(RenderNodeCache.Count); - node.HasChanges = true; + var node = new ContainerRenderNode(); + var cache = new RenderNodeCache(node); + Rect bounds = new(0, 0, 1, 1); + var fragment = new RenderFragmentReference( + RenderFragmentKind.Layer, + bounds, + EffectiveScale.At(1), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs: null, + payload: null, + hitTest: null); + var identity = new RenderOutputCacheIdentity( + "finalizer-cache", + RenderFragmentOutputIdentity.Create(fragment, new RenderRequestId(1)), + bounds, + RequiredRegion.Region(bounds), + density: 1, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity("finalizer-device", "finalizer-context")); + RenderNodeCache.PublishAtomically( + [ + new RenderNodeCachePublication( + cache, + identity, + [new RenderNodeCachedValue(target, bounds, EffectiveScale.At(1))]), + ]); + return new WeakReference(cache); + } - // Act - node.Cache.IncrementRenderCount(); + private sealed class ThrowingRenderTarget(Exception failure) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + 1, + 1, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + 1, + 1) + { + public int DisposeCalls { get; private set; } - // Assert - Assert.That(node.Cache.IsCached, Is.False); - Assert.That(node.Cache.CanCache(), Is.False); - Assert.That(node.Cache.IsCacheRejected, Is.False); + protected override void Dispose(bool disposing) + { + bool fail = disposing && !IsDisposed; + if (fail) + DisposeCalls++; + base.Dispose(disposing); + if (fail) + throw failure; + } } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs new file mode 100644 index 0000000000..eb707cdf6c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs @@ -0,0 +1,643 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Cache; + +[TestFixture] +public sealed class StructuralAndProgramCacheTests +{ + private const string FirstSource = + "uniform float gain; half4 apply(half4 color) { return color * gain; }"; + private const string SecondSource = + "uniform float gain; half4 apply(half4 color) { return half4(color.rgb * gain, color.a); }"; + + [Test] + public void ParameterOnlyAnimation_ReusesOneStructuralPlanForOneHundredFrames() + { + using var source = new CpuRenderTarget(8, 8); + source.Value.Canvas.Clear(new SKColor(160, 96, 32, 224)); + using Bitmap sourceBitmap = source.Snapshot(); + using var node = new ExecutableParameterShaderNode(source); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 8, 8), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + ushort[]? firstPixels = null; + ushort[]? finalPixels = null; + + for (int frame = 0; frame < 100; frame++) + { + node.Value = frame / 100f; + using RenderNodeRasterization raster = renderer.Rasterize(); + Assert.That(raster.Bitmap, Is.Not.Null); + if (frame == 0) + firstPixels = raster.Bitmap!.GetPixelSpan().ToArray(); + if (frame == 99) + finalPixels = raster.Bitmap!.GetPixelSpan().ToArray(); + } + + StructuralPlanCacheStatistics statistics = renderer.StructuralPlanCacheStatistics; + double maximumFinalDifference = MaximumScaledDifference(sourceBitmap, finalPixels!, 0.99f); + Assert.Multiple(() => + { + Assert.That(statistics.Compilations, Is.EqualTo(1)); + Assert.That(statistics.Misses, Is.EqualTo(1)); + Assert.That(statistics.Hits, Is.EqualTo(99)); + Assert.That(statistics.Replacements, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.Creations, Is.EqualTo(1)); + Assert.That(renderer.ProgramCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(renderer.ProgramCacheStatistics.Hits, Is.EqualTo(99)); + Assert.That(renderer.LastExecutionStatistics.ProgramCacheHits, Is.EqualTo(1)); + Assert.That(finalPixels, Is.Not.EqualTo(firstPixels), + "a warmed plan and program must bind the current frame's direct uniform value"); + Assert.That(finalPixels, Has.Some.Not.Zero, + "the final animated frame must produce a non-vacuous result"); + Assert.That(maximumFinalDifference, Is.LessThan(0.002), + "the final warmed frame must bind the authored 0.99 direct-uniform value"); + }); + } + + private static double MaximumScaledDifference(Bitmap source, ushort[] actual, float scale) + { + ReadOnlySpan expected = source.GetPixelSpan(); + Assert.That(actual, Has.Length.EqualTo(expected.Length)); + double maximum = 0; + for (int index = 0; index < actual.Length; index++) + { + float sourceValue = (float)BitConverter.UInt16BitsToHalf(expected[index]); + float actualValue = (float)BitConverter.UInt16BitsToHalf(actual[index]); + maximum = Math.Max(maximum, Math.Abs(actualValue - (sourceValue * scale))); + } + + return maximum; + } + + [Test] + public void BoundsOnlyRuntimeChange_RebindsCurrentBoundsWithoutRecompiling() + { + using var cache = new StructuralPlanCache(); + using var node = new ParameterShaderNode + { + Bounds = new Rect(1, 2, 8, 6), + }; + + using (CompiledRenderRequest first = Compile(cache, node)) + { + Assert.That(first.ExecutionPlan.ShaderRuns.Single().Output.Bounds, Is.EqualTo(node.Bounds)); + } + + node.Bounds = new Rect(4, 3, 17, 11); + using CompiledRenderRequest second = Compile(cache, node); + + Assert.Multiple(() => + { + Assert.That(second.ExecutionPlan.ShaderRuns.Single().Output.Bounds, Is.EqualTo(node.Bounds)); + Assert.That(second.SelectedOutputBounds, Is.EqualTo(node.Bounds)); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); + } + + [Test] + public void DeclaredStructuralToggle_CompilesExactlyOneReplacement() + { + using var cache = new StructuralPlanCache(); + using var node = new ParameterShaderNode(); + + using (Compile(cache, node)) + { + } + + node.StructuralVariant = 1; + using (Compile(cache, node)) + { + } + using (Compile(cache, node)) + { + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Replacements, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); + } + + [Test] + public void FusionMode_IsPartOfStructuralIdentity() + { + using var cache = new StructuralPlanCache(); + using var node = new ParameterShaderNode(); + + using (Compile(cache, node, FusionMode.Enabled)) + { + } + using (Compile(cache, node, FusionMode.Disabled)) + { + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Replacements, Is.EqualTo(1)); + }); + } + + [Test] + public void OpacityBoundsEligibilityChange_ReplacesStructuralPlan() + { + var inputBounds = new Rect(2, 3, 12, 8); + AssertOpacityEligibilityChangeReplacesStructuralPlan( + inputBounds, + new Rect(2, 3, 11, 8), + EffectiveScale.Unbounded, + EffectiveScale.Unbounded); + } + + [Test] + public void OpacityScaleEligibilityChange_ReplacesStructuralPlan() + { + var bounds = new Rect(2, 3, 12, 8); + AssertOpacityEligibilityChangeReplacesStructuralPlan( + bounds, + bounds, + EffectiveScale.Unbounded, + EffectiveScale.At(1)); + } + + [Test] + public void ForcedHashCollision_UsesFullIdentityAndThenWarmsReplacement() + { + using var cache = new StructuralPlanCache(); + using var firstNode = new ParameterShaderNode { StructuralVariant = 0 }; + using var secondNode = new ParameterShaderNode { StructuralVariant = 1 }; + using var equivalentNode = new ParameterShaderNode { StructuralVariant = 1, Value = 0.8f }; + using RenderRequest firstRequest = CreateRequest(FusionMode.Enabled); + using RenderRequest secondRequest = CreateRequest(FusionMode.Enabled); + using RenderRequest equivalentRequest = CreateRequest(FusionMode.Enabled); + RecordedRenderGraph firstGraph = new RenderRequestRecorder(firstRequest).Record(firstNode); + RecordedRenderGraph secondGraph = new RenderRequestRecorder(secondRequest).Record(secondNode); + RecordedRenderGraph equivalentGraph = new RenderRequestRecorder(equivalentRequest).Record(equivalentNode); + StructuralPlanIdentity firstIdentity = StructuralPlanIdentity.Create( + firstRequest.Options.PlanIdentity, + firstGraph, + SkslBackendBudget.Unlimited); + StructuralPlanIdentity secondIdentity = StructuralPlanIdentity.Create( + secondRequest.Options.PlanIdentity, + secondGraph, + SkslBackendBudget.Unlimited); + StructuralPlanIdentity equivalentIdentity = StructuralPlanIdentity.Create( + equivalentRequest.Options.PlanIdentity, + equivalentGraph, + SkslBackendBudget.Unlimited); + const int forcedBucket = 0x1234; + + _ = GetOrCompile(cache, firstIdentity, firstGraph, forcedBucket); + _ = GetOrCompile(cache, secondIdentity, secondGraph, forcedBucket); + ExecutionIslandPlan warmed = GetOrCompile( + cache, + equivalentIdentity, + equivalentGraph, + forcedBucket); + + Assert.Multiple(() => + { + Assert.That(firstIdentity, Is.Not.EqualTo(secondIdentity)); + Assert.That(secondIdentity, Is.EqualTo(equivalentIdentity)); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Replacements, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); + } + + [Test] + public void Renderer_PersistsStructuralCacheAcrossRequests() + { + using var node = new EmptyNode(); + using var renderer = new RenderNodeRenderer(node); + + using (renderer.Rasterize()) + { + } + using (renderer.Rasterize()) + { + } + + Assert.Multiple(() => + { + Assert.That(renderer.StructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(renderer.StructuralPlanCacheStatistics.Hits, Is.EqualTo(1)); + }); + } + + [Test] + public void RenderInputReadbackSelection_ReplacesStructuralPlanAndRebindsSnapshots() + { + using var node = new MutableTargetCommandReadbackNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 8, 8), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using (renderer.Rasterize()) + { + } + + node.ReadFirstInput = false; + using (renderer.Rasterize()) + { + } + + Assert.Multiple(() => + { + Assert.That(node.SnapshotCounts, Is.EqualTo(new[] { 1, 1 })); + Assert.That(renderer.StructuralPlanCacheStatistics.Compilations, Is.EqualTo(2)); + Assert.That(renderer.StructuralPlanCacheStatistics.Misses, Is.EqualTo(2)); + Assert.That(renderer.StructuralPlanCacheStatistics.Replacements, Is.EqualTo(1)); + Assert.That(renderer.StructuralPlanCacheStatistics.Hits, Is.Zero); + }); + } + + [Test] + public void NestedRequestFamily_ReusesEveryCurrentPlanAndTrimsRemovedMembers() + { + using var cache = new StructuralPlanCache(); + using var child = new EmptyNode(); + using var nested = new NestedParentNode(child); + using var flat = new EmptyNode(); + + using (Compile(cache, nested)) + { + } + using (Compile(cache, nested)) + { + } + + StructuralPlanCacheStatistics warmed = cache.Statistics; + Assert.Multiple(() => + { + Assert.That(warmed.Compilations, Is.EqualTo(2)); + Assert.That(warmed.Misses, Is.EqualTo(2)); + Assert.That(warmed.Hits, Is.EqualTo(2)); + Assert.That(warmed.Replacements, Is.Zero); + Assert.That(warmed.RetainedPlans, Is.EqualTo(2)); + }); + + using (Compile(cache, flat)) + { + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Hits, Is.EqualTo(3)); + Assert.That(cache.Statistics.RetainedPlans, Is.EqualTo(1)); + }); + } + + [Test] + public void TargetLayerScope_EmptyRegionClass_CompilesOneReplacement() + { + using var cache = new StructuralPlanCache(); + using var node = new MutableTargetLayerScopeNode(); + + using (Compile(cache, node)) + { + } + + node.Region = TargetRegion.Region(new Rect(0, 0, 8, 8)); + using (Compile(cache, node)) + { + } + using (Compile(cache, node)) + { + } + + Assert.Multiple(() => + { + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Replacements, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); + } + + private static ExecutionIslandPlan GetOrCompile( + StructuralPlanCache cache, + StructuralPlanIdentity identity, + RecordedRenderGraph graph, + int forcedBucket) + { + var planner = new ExecutionIslandPlanner(); + return cache.GetOrCompile( + identity, + graph, + () => planner.Plan( + graph, + RenderRequestCompiler.ResolveRoots(graph), + FusionMode.Enabled, + SkslBackendBudget.Unlimited), + forcedBucket); + } + + private static void AssertOpacityEligibilityChangeReplacesStructuralPlan( + Rect changedInputBounds, + Rect changedOpacityBounds, + EffectiveScale changedInputScale, + EffectiveScale changedOpacityScale) + { + var eligibleBounds = new Rect(2, 3, 12, 8); + using var cache = new StructuralPlanCache(); + using RenderRequest eligibleRequest = CreateRequest(FusionMode.Enabled); + using RenderRequest changedRequest = CreateRequest(FusionMode.Enabled); + RecordedRenderGraph eligibleGraph = CreateOpacityGraph( + eligibleRequest.Id, + eligibleBounds, + eligibleBounds, + EffectiveScale.Unbounded, + EffectiveScale.Unbounded); + RecordedRenderGraph changedGraph = CreateOpacityGraph( + changedRequest.Id, + changedInputBounds, + changedOpacityBounds, + changedInputScale, + changedOpacityScale); + StructuralPlanIdentity eligibleIdentity = StructuralPlanIdentity.Create( + eligibleRequest.Options.PlanIdentity, + eligibleGraph, + SkslBackendBudget.Unlimited); + StructuralPlanIdentity changedIdentity = StructuralPlanIdentity.Create( + changedRequest.Options.PlanIdentity, + changedGraph, + SkslBackendBudget.Unlimited); + const int forcedBucket = 0x4f50; + + _ = GetOrCompile( + cache, + eligibleIdentity, + eligibleGraph, + forcedBucket); + _ = GetOrCompile( + cache, + changedIdentity, + changedGraph, + forcedBucket); + + Assert.Multiple(() => + { + Assert.That(eligibleIdentity, Is.Not.EqualTo(changedIdentity)); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Replacements, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.Zero); + }); + } + + private static RecordedRenderGraph CreateOpacityGraph( + RenderRequestId requestId, + Rect inputBounds, + Rect opacityBounds, + EffectiveScale inputScale, + EffectiveScale opacityScale) + { + var input = new RenderFragmentReference( + RenderFragmentKind.ContributeValues, + inputBounds, + inputScale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [], + payload: null, + static _ => true); + var opacity = new RenderFragmentReference( + RenderFragmentKind.Opacity, + opacityBounds, + opacityScale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + [input], + new OpacityRenderFragmentPayload( + 0.625f, + OpacityRenderNode.CreateFusionDescription(0.625f)), + static _ => true); + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance( + typeof(StructuralAndProgramCacheTests), + "opacity-structural-cache-test"); + foreach (RenderFragmentReference reference in new[] { input, opacity }) + { + RenderValueId[] inputs = reference.Inputs + .SelectMany(static item => item.ValueIds) + .ToArray(); + reference.ValueIds = [builder.AddValue(inputs, provenance, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenance, reference); + } + + builder.PublishRoot(opacity.Id!.Value); + return builder.Build(); + } + + private static CompiledRenderRequest Compile( + StructuralPlanCache cache, + RenderNode node, + FusionMode fusionMode = FusionMode.Enabled) + { + RenderRequest request = CreateRequest(fusionMode); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler(cache).Compile(request, graph); + } + catch + { + request.Dispose(); + throw; + } + } + + private static RenderRequest CreateRequest(FusionMode fusionMode) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: fusionMode)); + + private sealed class ParameterShaderNode : RenderNode + { + public Rect Bounds { get; set; } = new(2, 3, 12, 8); + + public float Value { get; set; } = 0.25f; + + public int StructuralVariant { get; set; } + + public bool UseCustomBinder { get; set; } + + public object BinderStructuralKey { get; set; } = "binder-v1"; + + public ShaderDescription? LastDescription { get; private set; } + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription source = OpaqueRenderDescription.Create( + ("source-frame", Value), + static (_, _) => { }, + OpaqueRenderBoundsContract.Source(Bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + RenderFragmentHandle input = context.OpaqueSource(source); + string shaderSource = StructuralVariant == 0 ? FirstSource : SecondSource; + LastDescription = ShaderDescription.CurrentPixel( + shaderSource, + bindings => + { + if (UseCustomBinder) + { + } + else + { + bindings.Uniform("gain", Value); + } + }); + context.Publish(context.Shader(input, LastDescription)); + } + + private static void BindFloat( + ShaderUniformWriter writer, + float value, + ShaderExecutionContext context) + => writer.Set(value); + } + + private sealed class EmptyNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + } + } + + private sealed class NestedParentNode(RenderNode child) : RenderNode + { + public override void Process(RenderNodeContext context) + => _ = context.RecordNestedTarget(child, new Rect(0, 0, 8, 8)); + } + + private sealed class MutableTargetLayerScopeNode : RenderNode + { + public TargetRegion Region { get; set; } = TargetRegion.Empty; + + public override void Process(RenderNodeContext context) + => context.Publish(context.TargetLayerScope([], Region)); + } + + private sealed class MutableTargetCommandReadbackNode : RenderNode + { + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + public bool ReadFirstInput { get; set; } = true; + + public int[] SnapshotCounts { get; } = new int[2]; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle first = context.OpaqueSource(CreateSource("first")); + RenderFragmentHandle second = context.OpaqueSource(CreateSource("second")); + int selectedInput = ReadFirstInput ? 0 : 1; + RenderFragmentHandle command = context.TargetCommand( + [first, second], + TargetCommandDescription.CreateRequestLocal( + session => session.Inputs[selectedInput].UseSnapshot( + _ => SnapshotCounts[selectedInput]++), + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None, + inputReadbacks: ReadFirstInput + ? [RenderInputReadback.All, RenderInputReadback.None] + : [RenderInputReadback.None, RenderInputReadback.All])); + context.PublishRange([first, second, command]); + } + + private static OpaqueRenderDescription CreateSource(string key) + => OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private sealed class ExecutableParameterShaderNode(RenderTarget source) : RenderNode + { + public float Value { get; set; } + + public override void Process(RenderNodeContext context) + { + RenderResource target = context.Borrow( + source); + RenderFragmentHandle input = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + target, + new Rect(0, 0, 8, 8), + EffectiveScale.At(1), + new PixelRect(0, 0, 8, 8), + default, + RenderHitTestContract.OutputBounds)); + ShaderDescription shader = ShaderDescription.CurrentPixel( + FirstSource, + bindings => bindings.Uniform("gain", Value)); + context.Publish(context.Shader(input, shader)); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ClearRenderNodeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ClearRenderNodeTests.cs index dfc74bcfe5..bb9e302887 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ClearRenderNodeTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ClearRenderNodeTests.cs @@ -49,25 +49,36 @@ public void Update_ShouldReturnTrueForDifferentColor() } [Test] - public void Process_ShouldReturnRenderNodeOperation() + public void Render_ShouldRecordAndExecuteTargetCommand() { // Arrange var color = new Color(255, 0, 0, 255); var node = new ClearRenderNode(color); - var context = new RenderNodeContext([]); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 100, 100), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); using var renderTarget = RenderTarget.CreateNull(100, 100); using var canvas = new ImmediateCanvas(renderTarget); // Act - var operations = node.Process(context); + RenderNodeMeasurement measurement = renderer.Measure(); // Assert - Assert.That(operations, Is.Not.Null); - Assert.That(operations.Length, Is.EqualTo(1)); - Assert.That(operations[0], Is.InstanceOf()); - Assert.That(operations[0].Bounds, Is.EqualTo(Rect.Empty)); - Assert.That(() => operations[0].Render(canvas), Throws.Nothing); - - operations[0].Dispose(); + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.False); + Assert.That(measurement.HasTargetEffects, Is.True); + Assert.That(measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 100, 100))); + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + }); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs index 0befa22848..ea7458e161 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs @@ -1,4 +1,6 @@ -using Beutl.Graphics.Rendering; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -53,13 +55,46 @@ public void SetChild_ShouldReplaceChildAtIndex() } [Test] - public void Process_ShouldReturnContextInput() + public void SetChild_CommitsReplacementBeforeOldChildDisposalFailure() { - var node = new ContainerRenderNode(); - var context = new RenderNodeContext([]); - var result = node.Process(context); + using var node = new ContainerRenderNode(); + var oldChild = new ThrowingDisposeRenderNode(); + var replacement = new ContainerRenderNode(); + node.AddChild(oldChild); + + Assert.That( + () => node.SetChild(0, replacement), + Throws.TypeOf() + .With.Message.EqualTo("child disposal failed")); + Assert.That(node.Children[0], Is.SameAs(replacement)); + Assert.That(replacement.IsDisposed, Is.False); + } - Assert.That(result, Is.EqualTo(context.Input)); + [Test] + public void Measure_ShouldPassThroughChildOutput() + { + var node = new ContainerRenderNode(); + var bounds = new Rect(5, 10, 20, 30); + node.AddChild(new RectangleRenderNode(bounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement result = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(result.HasFragments, Is.True); + Assert.That(result.HasContributingValues, Is.True); + Assert.That(result.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(result.OutputBounds, Is.EqualTo(bounds)); + }); } [Test] @@ -78,4 +113,21 @@ public void OnDispose_ShouldDisposeAllChildren() Assert.That(child1.IsDisposed, Is.True); Assert.That(child2.IsDisposed, Is.True); } + + private sealed class ThrowingDisposeRenderNode : RenderNode + { + private bool _hasThrown; + + public override void Process(RenderNodeContext context) + => context.PassThrough(); + + protected override void OnDispose(bool disposing) + { + if (!_hasThrown) + { + _hasThrown = true; + throw new InvalidOperationException("child disposal failed"); + } + } + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomEffectSynchronizationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomEffectSynchronizationTests.cs new file mode 100644 index 0000000000..c090f7831e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomEffectSynchronizationTests.cs @@ -0,0 +1,548 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +[NonParallelizable] +public sealed class CustomEffectSynchronizationTests +{ + private static readonly Rect s_sourceBounds = new(3, 4, 18, 14); + private static readonly Rect s_targetDomain = new(0, 0, 28, 24); + + [Test] + [Category("GpuPassFusionGpu")] + public void Snapshot_RetainsCompletionWaitForCpuReadback() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget target = RenderTarget.Create(8, 8) + ?? throw new InvalidOperationException("Could not create the GPU readback target."); + target.Value.Canvas.Clear(SKColors.CornflowerBlue); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + using (target.Snapshot()) + { + } + + Assert.That(flushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSampling }), + "CPU readback must submit and wait before ReadPixels consumes the surface."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void BackendInterop_RetainsCompletionWaitBeforeTextureExposure() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget target = RenderTarget.Create(8, 8) + ?? throw new InvalidOperationException("Could not create the GPU interop target."); + Assert.That(target.Texture, Is.Not.Null); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + target.PrepareForSampling(RenderTargetSamplingIntent.BackendInterop); + + Assert.That(flushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSampling }), + "Vulkan texture exposure must wait because Skia ordering does not cross the backend boundary."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void SameContextTextureSampling_SubmitsWithoutCompletionWait() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget producer = RenderTarget.Create(8, 8) + ?? throw new InvalidOperationException("Could not create the GPU producer target."); + using RenderTarget consumer = RenderTarget.Create(8, 8) + ?? throw new InvalidOperationException("Could not create the GPU consumer target."); + Assert.That(producer.Value.Context, Is.Not.Null); + Assert.That(consumer.Value.Context, Is.Not.Null); + Assert.That(producer.Value.Context!.Handle, Is.EqualTo(consumer.Value.Context!.Handle)); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + { + producer.PrepareForSampling( + RenderTargetSamplingIntent.SameContextTextureSampling(consumer.Value.Context)); + } + + Assert.That(flushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSamplingSubmit }), + "Sampling ordered within one Skia context needs submission but not CPU completion."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void CrossContextTextureSampling_RetainsCompletionWait() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using var producer = new CpuRenderTarget(new PixelSize(8, 8)); + using RenderTarget consumer = RenderTarget.Create(8, 8) + ?? throw new InvalidOperationException("Could not create the GPU consumer target."); + Assert.That(producer.Value.Context, Is.Null); + Assert.That(consumer.Value.Context, Is.Not.Null); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + { + producer.PrepareForSampling( + RenderTargetSamplingIntent.SameContextTextureSampling(consumer.Value.Context)); + } + + Assert.That(flushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSampling }), + "A context mismatch must conservatively retain the completion wait."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ExecutorManagedCustomEffect_CpuTargetsDoNotFlushInitializedSharedContext() + { + VulkanTestEnvironment.EnsureAvailable(); + using FilterEffectRenderNode effectNode = CreateFilterNode(new CopyingCustomEffect()); + using RenderNodeRenderer effectRenderer = CreateRenderer(effectNode); + using var referenceNode = new EllipseRenderNode(s_sourceBounds, Brushes.Resource.White, null); + using RenderNodeRenderer referenceRenderer = CreateRenderer(referenceNode); + + using RenderNodeRasterization actual = effectRenderer.Rasterize(); + using RenderNodeRasterization expected = referenceRenderer.Rasterize(); + + using var destination = new CpuRenderTarget(new PixelSize( + (int)s_targetDomain.Width, + (int)s_targetDomain.Height)); + using var canvas = new ImmediateCanvas(destination, logicalSize: s_targetDomain.Size); + var flushes = new List(); + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + effectRenderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.GetPixelSpan().SequenceEqual(expected.Bitmap!.GetPixelSpan()), Is.True, + "A deferred draw must retain its source after the callback disposes the source target."); + Assert.That(actual.Bitmap.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That(flushes, Is.EqualTo(new[] { ImmediateCanvasFlushKind.SourceSurface }), + "CPU executor canvases must not flush the initialized shared GPU context or submit a raster surface."); + }); + AssertFlushCounts( + flushes, + canvasSubmit: 0, + canvasClose: 0, + sourceSurface: 1, + prepareForSampling: 0); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ExecutorManagedCustomEffect_GpuDeferredDrawSurvivesSourceDisposeWithoutInternalFlush() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffectRenderNode effectNode = CreateFilterNode(new CopyingCustomEffect()); + using RenderNodeRenderer effectRenderer = CreateGpuRenderer(effectNode); + using FilterEffectRenderNode referenceNode = CreateFilterNode( + new CopyingCustomEffect(synchronizeSource: true)); + using RenderNodeRenderer referenceRenderer = CreateGpuRenderer(referenceNode); + var size = new PixelSize((int)s_targetDomain.Width, (int)s_targetDomain.Height); + using RenderTarget actualTarget = RenderTarget.Create(size.Width, size.Height) + ?? throw new InvalidOperationException("Could not create the GPU custom-effect target."); + using RenderTarget expectedTarget = RenderTarget.Create(size.Width, size.Height) + ?? throw new InvalidOperationException("Could not create the GPU reference target."); + + var actualCanvas = new ImmediateCanvas(actualTarget, logicalSize: s_targetDomain.Size); + actualCanvas.Clear(); + var executionFlushes = new List(); + using (ImmediateCanvas.ObserveFlushes(executionFlushes.Add)) + effectRenderer.Render(actualCanvas); + + var callerCloseFlushes = new List(); + using (ImmediateCanvas.ObserveFlushes(callerCloseFlushes.Add)) + actualCanvas.Dispose(); + + var readbackFlushes = new List(); + Bitmap actual; + using (ImmediateCanvas.ObserveFlushes(readbackFlushes.Add)) + actual = actualTarget.Snapshot(); + using (actual) + { + using (var expectedCanvas = new ImmediateCanvas( + expectedTarget, + logicalSize: s_targetDomain.Size)) + { + expectedCanvas.Clear(); + referenceRenderer.Render(expectedCanvas); + } + + using Bitmap expected = expectedTarget.Snapshot(); + Assert.Multiple(() => + { + Assert.That(actual.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(expected.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), Is.True, + "The queued GPU copy must remain byte-exact after the callback disposes its source."); + Assert.That(actual.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That(executionFlushes, + Is.EqualTo(new[] + { + ImmediateCanvasFlushKind.CanvasSubmit, + ImmediateCanvasFlushKind.CanvasSubmit, + ImmediateCanvasFlushKind.SourceSurface, + }), + "Each executor-owned GPU canvas submits its queued work; only the final caller-owned draw flushes a source."); + Assert.That(callerCloseFlushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.CanvasClose }), + "The caller-owned canvas retains its explicit close-time synchronization."); + Assert.That(readbackFlushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSampling }), + "The final CPU readback retains its explicit sampling synchronization."); + }); + AssertFlushCounts( + executionFlushes, + canvasSubmit: 2, + canvasClose: 0, + sourceSurface: 1, + prepareForSampling: 0); + AssertFlushCounts( + callerCloseFlushes, + canvasSubmit: 0, + canvasClose: 1, + sourceSurface: 0, + prepareForSampling: 0); + AssertFlushCounts( + readbackFlushes, + canvasSubmit: 0, + canvasClose: 0, + sourceSurface: 0, + prepareForSampling: 1); + } + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ExecutorManagedCustomEffect_CrossContextCopyFlushesSourceThenSubmitsDestination() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var bounds = new Rect(0, 0, 16, 12); + using var source = new CpuRenderTarget(new PixelSize( + (int)bounds.Width, + (int)bounds.Height)); + source.Value.Canvas.Clear(SKColors.OrangeRed); + source.Value.Flush(); + using Bitmap expected = source.Snapshot(); + using var targets = new EffectTargets + { + new EffectTarget(source, bounds, EffectiveScale.At(1)), + }; + var effect = new CopyingCustomEffect(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(bounds); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + deviceGridOffset: default, + useExecutorManagedCanvas: true); + var flushes = new List(); + + Assert.That(source.Value.Context, Is.Null); + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + activator.Apply(context); + + RenderTarget actualTarget = activator.CurrentTargets.Single().RenderTarget!; + Assert.That(actualTarget.Value.Context, Is.Not.Null); + using Bitmap actual = actualTarget.Snapshot(); + Assert.Multiple(() => + { + Assert.That(actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), Is.True); + Assert.That(flushes, Is.EqualTo(new[] + { + ImmediateCanvasFlushKind.SourceSurface, + ImmediateCanvasFlushKind.CanvasSubmit, + }), "A CPU source crossing to the GPU must flush before the GPU destination submits."); + }); + AssertFlushCounts( + flushes, + canvasSubmit: 1, + canvasClose: 0, + sourceSurface: 1, + prepareForSampling: 0); + }); + } + + [Test] + public void ExecutorManagedCustomEffect_ExplicitMappedInputSamplingStillPreparesSource() + { + using FilterEffectRenderNode node = CreateFilterNode(new SamplingCustomEffect()); + using RenderNodeRenderer renderer = CreateRenderer(node); + using var destination = new CpuRenderTarget(new PixelSize( + (int)s_targetDomain.Width, + (int)s_targetDomain.Height)); + using var canvas = new ImmediateCanvas(destination, logicalSize: s_targetDomain.Size); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(flushes, Is.EqualTo(new[] + { + ImmediateCanvasFlushKind.PrepareForSamplingSubmit, + ImmediateCanvasFlushKind.SourceSurface, + }), "Same-context shader sampling submits without waiting before the final source draw."); + }); + AssertFlushCounts( + flushes, + canvasSubmit: 0, + canvasClose: 0, + sourceSurface: 1, + prepareForSampling: 0, + prepareForSamplingSubmit: 1); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void PublicFilterEffectActivator_RetainsLegacyCanvasAndSourceFlushes() + { + VulkanTestEnvironment.EnsureAvailable(); + var bounds = new Rect(0, 0, 16, 12); + using var source = new CpuRenderTarget(new PixelSize( + (int)bounds.Width, + (int)bounds.Height)); + source.Value.Canvas.Clear(SKColors.OrangeRed); + source.Value.Flush(); + using Bitmap expected = source.Snapshot(); + using var targets = new EffectTargets + { + new EffectTarget(source, bounds, EffectiveScale.At(1)), + }; + var effect = new CopyingCustomEffect(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(bounds); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + activator.Apply(context); + + using Bitmap actual = activator.CurrentTargets.Single().RenderTarget!.Snapshot(); + Assert.Multiple(() => + { + Assert.That(actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), Is.True); + Assert.That(flushes, Is.EqualTo(new[] + { + ImmediateCanvasFlushKind.SourceSurface, + ImmediateCanvasFlushKind.CanvasClose, + }), "The public standalone activator must retain its legacy source and context flushes."); + }); + AssertFlushCounts( + flushes, + canvasSubmit: 0, + canvasClose: 1, + sourceSurface: 1, + prepareForSampling: 0); + } + + private static void AssertFlushCounts( + IReadOnlyCollection flushes, + int canvasSubmit, + int canvasClose, + int sourceSurface, + int prepareForSampling, + int prepareForSamplingSubmit = 0) + { + Assert.Multiple(() => + { + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.CanvasSubmit), + Is.EqualTo(canvasSubmit)); + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.CanvasClose), + Is.EqualTo(canvasClose)); + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.SourceSurface), + Is.EqualTo(sourceSurface)); + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling), + Is.EqualTo(prepareForSampling)); + Assert.That( + flushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSamplingSubmit), + Is.EqualTo(prepareForSamplingSubmit)); + }); + } + + private static FilterEffectRenderNode CreateFilterNode(FilterEffect effect) + { + var node = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + node.AddChild(new EllipseRenderNode(s_sourceBounds, Brushes.Resource.White, null)); + return node; + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_targetDomain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static RenderNodeRenderer CreateGpuRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_targetDomain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + [SuppressResourceClassGeneration] + private sealed partial class CopyingCustomEffect(bool synchronizeSource = false) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect( + synchronizeSource, + static (synchronize, execution) => execution.ForEach((_, source) => + { + if (synchronize) + { + using Bitmap snapshot = source.RenderTarget!.Snapshot(); + } + + EffectTarget replacement = execution.CreateTargetLike(source); + using (ImmediateCanvas canvas = execution.Open(replacement)) + { + canvas.Clear(); + source.Draw(canvas); + } + + return replacement; + }), + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource; + } + + [SuppressResourceClassGeneration] + private sealed partial class SamplingCustomEffect : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect( + 0, + static (_, execution) => execution.ForEach((_, source) => + { + using EffectTarget destination = execution.CreateTargetLike(source); + bool sampled = execution.UseMappedInputShader( + source, + destination, + 0, + static (_, _) => { }); + if (!sampled) + { + throw new InvalidOperationException("The CPU source could not be sampled."); + } + + return source; + }), + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource; + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget(PixelSize size) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create a CPU custom-effect test surface."), + size.Width, + size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomTargetClampConsistencyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomTargetClampConsistencyTests.cs index 6f2d66bc0d..199e56d928 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomTargetClampConsistencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/CustomTargetClampConsistencyTests.cs @@ -11,7 +11,12 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering; public class CustomTargetClampConsistencyTests { private static CustomFilterEffectContext Context(float workingScale) - => new(new EffectTargets(), outputScale: 1f, workingScale: workingScale); + => new( + new EffectTargets(), + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: workingScale); [Test] public void CreateTarget_WithinBudget_KeepsWorkingScale_AndOpenMatches() @@ -32,6 +37,29 @@ public void CreateTarget_WithinBudget_KeepsWorkingScale_AndOpenMatches() }); } + [Test] + public void CreateTarget_FractionalBounds_PreservesLegacyLocalBufferPlacement() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var bounds = new Rect(5.5f, 3.5f, 181.75f, 101.25f); + CustomFilterEffectContext context = Context(workingScale: 1f); + using EffectTarget target = context.CreateTarget(bounds); + using ImmediateCanvas canvas = context.Open(target); + + Assert.Multiple(() => + { + Assert.That(target.RenderTarget!.Width, Is.EqualTo(181)); + Assert.That(target.RenderTarget.Height, Is.EqualTo(101)); + Assert.That(target.RasterBounds, + Is.EqualTo(new Rect(bounds.Position, new Size(181, 101)))); + Assert.That(canvas.LogicalSize, Is.EqualTo(bounds.Size)); + Assert.That(canvas.Transform.Transform(default(Point)), Is.EqualTo(default(Point))); + }); + }); + } + [Test] public void CreateTarget_BufferBudgetExceeded_ClampsDensity_AndOpenMatchesClamp() { @@ -46,7 +74,7 @@ public void CreateTarget_BufferBudgetExceeded_ClampsDensity_AndOpenMatchesClamp( Assert.That(target.Scale.IsUnbounded, Is.False); Assert.That(target.Scale.Value, Is.LessThan(2f), "CreateTarget did not clamp the density for an over-budget buffer"); - float expectedFit = RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, 2f); + float expectedFit = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 2f); Assert.That(target.Scale.Value, Is.EqualTo(expectedFit).Within(1e-4)); // Open must tag the canvas with the clamped density. diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DirectSkiaFilterReplayTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DirectSkiaFilterReplayTests.cs new file mode 100644 index 0000000000..d21397ec29 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DirectSkiaFilterReplayTests.cs @@ -0,0 +1,1242 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.Media.Pixel; +using Beutl.Media.Source; +using Beutl.Serialization; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +public sealed class DirectSkiaFilterReplayTests +{ + private static readonly Rect s_sourceBounds = new(24, 20, 40, 32); + private static readonly float[] s_blurSigmas = [1, 2, 3]; + private static readonly PixelSize s_deepPatternSize = new(384, 216); + private static readonly Rect s_deepTargetDomain = new(default, s_deepPatternSize.ToSize(1)); + private const int DeepAlternatingPairCount = 4; + private const float DeepAlternatingBlurSigma = 3; + + [Test] + public void PureBuiltInBlurGroup_ReplaysDirectlyWithoutIntermediateTargets() + { + using FilterEffectRenderNode node = CreateFilterNode(CreateBlurGroup()); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(result.Bitmap!.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.Zero, + "A root pure built-in Skia chain should replay its vector input directly into the destination."); + }); + } + + [Test] + public void SeparateBuiltInBlurNodes_ReplayWithoutSynchronousCanvasFlushes() + { + using FilterEffectRenderNode node = CreateSerialBlurNodes(); + using RenderNodeRenderer renderer = CreateRenderer(node); + Rect bounds = GetBlurChainBounds(); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + using RenderTarget target = new CpuRenderTarget(deviceBounds.Size); + using var canvas = new ImmediateCanvas( + target, + density: 1, + maxWorkingScale: 4, + logicalSize: deviceBounds.ToRect(1).Size); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + using (canvas.PushTransform(Matrix.CreateTranslation(-bounds.X, -bounds.Y))) + renderer.Render(canvas); + + Assert.That(flushes, Is.Empty, + "A direct Blur chain must not submit or synchronously flush an intermediate canvas."); + } + + [Test] + public void PureBuiltInBlurGroup_MatchesNestedSkiaImageFilterReferenceExactly() + { + using FilterEffectRenderNode node = CreateFilterNode(CreateBlurGroup()); + using RenderNodeRenderer renderer = CreateRenderer(node); + using RenderNodeRasterization actual = renderer.Rasterize(); + Rect expectedBounds = GetBlurChainBounds(); + using Bitmap expected = RenderNestedSkiaReference(expectedBounds); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(expectedBounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "Direct replay must be byte-identical to drawing the same source under the nested Skia filter chain."); + }); + } + + [Test] + public void SeparateBuiltInBlurNodes_ReplayDirectlyWithExactNestedSkiaPixels() + { + using FilterEffectRenderNode node = CreateSerialBlurNodes(); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using RenderNodeRasterization actual = renderer.Rasterize(); + Rect expectedBounds = GetBlurChainBounds(); + using Bitmap expected = RenderNestedSkiaReference(expectedBounds); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(expectedBounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "Separate Blur nodes must preserve the same nested Skia image-filter pixels."); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.Zero, + "Serial pure built-in Skia nodes should recursively replay into the root destination."); + }); + } + + [Test] + public void ConcreteImageSource_BuiltInBlurGroup_ReplaysDirectlyWithExactNestedSkiaPixels() + { + using ImageSource.Resource source = CreateImageSourceResource(); + Rect sourceBounds = new(default, source.FrameSize.ToSize(1)); + using var node = new FilterEffectRenderNode( + CreateBlurGroup().ToResource(CompositionContext.Default)); + node.AddChild(new ImageSourceRenderNode(source, Brushes.Resource.White, null)); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using RenderNodeRasterization actual = renderer.Rasterize(); + Rect expectedBounds = GetBlurChainBounds(sourceBounds); + using Bitmap expected = RenderNestedSkiaImageReference( + source, + expectedBounds); + + Assert.Multiple(() => + { + Assert.That(renderer.Measure().EffectiveScale, Is.EqualTo(EffectiveScale.At(1))); + Assert.That(actual.Bounds, Is.EqualTo(expectedBounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "A native-density image source under Blur must match the same nested Skia image-filter draw."); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.Zero, + "A native-density image source should replay through the pure built-in Blur chain into the destination."); + }); + } + + [Test] + public void ConcreteImageSource_DestinationDensityMismatch_RemainsMaterialized() + { + const float sourceDensity = 1; + const float destinationDensity = 2; + using ImageSource.Resource source = CreateImageSourceResource(); + using var image = new ImageSourceRenderNode(source, Brushes.Resource.White, null); + using RenderNodeRenderer sourceRenderer = CreateRenderer(image); + Assert.That(sourceRenderer.Measure().EffectiveScale, Is.EqualTo(EffectiveScale.At(sourceDensity))); + + using var node = new FilterEffectRenderNode( + CreateBlurGroup().ToResource(CompositionContext.Default)); + node.AddChild(new ImageSourceRenderNode(source, Brushes.Resource.White, null)); + using RenderNodeRenderer renderer = CreateRenderer( + node, + outputDensity: destinationDensity, + maxWorkingDensity: destinationDensity); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(destinationDensity))); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "A native-density image cannot replay directly when the destination uses a different density."); + }); + } + + [Test] + public void PureBuiltInBlurGroup_PartialRequestedRegion_ReplaysDirectlyWithExactPixels() + { + Rect completeBounds = GetBlurChainBounds(); + var requestedRegion = new Rect(18, 16, 28, 24); + using FilterEffectRenderNode node = CreateFilterNode(CreateBlurGroup()); + using RenderNodeRenderer renderer = CreateRenderer( + node, + requestedRegion: requestedRegion); + + using RenderNodeRasterization actual = renderer.Rasterize(); + using Bitmap completeReference = RenderNestedSkiaReference(completeBounds); + using Bitmap expected = ExtractGlobalRegion( + completeReference, + completeBounds, + requestedRegion); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "A clipped root request must match the same region of the complete nested Skia render."); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.Zero, + "A partial root request must not force a pure built-in Skia segment to materialize."); + }); + } + + [Test] + public void SelectedStaticPrefixCache_IsReusedBeforeDynamicBlurTail() + { + var prefix = new CacheableEllipseSourceNode(); + prefix.Cache.RecordStableRequests(); + Blur blur = CreateBlur(1); + FilterEffect.Resource resource = blur.ToResource(CompositionContext.Default); + using var tail = new FilterEffectRenderNode(resource); + tail.AddChild(prefix); + using RenderNodeRenderer renderer = CreateRenderer( + tail, + cacheOptions: RenderCacheOptions.Enabled); + + using RenderNodeRasterization cold = renderer.Rasterize(); + blur.Sigma.CurrentValue = new Size(4, 4); + bool updateOnly = false; + resource.Update(blur, CompositionContext.Default, ref updateOnly); + Assert.That(tail.Update(resource), Is.True); + using RenderNodeRasterization warm = renderer.Rasterize(); + + var referencePrefix = new CacheableEllipseSourceNode(); + using var referenceTail = new FilterEffectRenderNode( + CreateBlur(4).ToResource(CompositionContext.Default)); + referenceTail.AddChild(referencePrefix); + using RenderNodeRenderer referenceRenderer = CreateRenderer(referenceTail); + using RenderNodeRasterization expected = referenceRenderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(prefix.Cache.IsCached, Is.True); + Assert.That(prefix.ExecuteCount, Is.EqualTo(1), + "Changing the Blur tail must reuse the selected static-prefix cache entry."); + Assert.That(cold.Bitmap, Is.Not.Null); + Assert.That(warm.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(warm.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That( + warm.Bitmap!.GetPixelSpan().SequenceEqual(expected.Bitmap!.GetPixelSpan()), + Is.True, + "A cached prefix followed by a changed Blur tail must match an uncached render of that tail."); + Assert.That( + warm.Bitmap.GetPixelSpan().SequenceEqual(cold.Bitmap!.GetPixelSpan()), + Is.False, + "The changed Blur sigma must make the cache-reuse fixture observably dynamic."); + }); + } + + [Test] + public void CachedBuiltInBlurPrefix_AnimatedBlurTail_MatchesUncachedFramesExactly() + { + var source = new CacheableEllipseSourceNode(); + using var prefix = new FilterEffectRenderNode( + CreateBlur(3).ToResource(CompositionContext.Default)); + prefix.AddChild(source); + prefix.SettleConstruction(); + prefix.Cache.RecordStableRequests(); + + Blur tailEffect = CreateBlur(1); + FilterEffect.Resource tailResource = tailEffect.ToResource(CompositionContext.Default); + using var tail = new FilterEffectRenderNode(tailResource); + tail.AddChild(prefix); + using RenderNodeRenderer renderer = CreateRenderer( + tail, + cacheOptions: RenderCacheOptions.Enabled); + + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization coldReference = RasterizeMaterializedBlurPrefixTail( + prefixSigma: 3, + tailSigma: 1); + + tailEffect.Sigma.CurrentValue = new Size(4, 4); + bool updateOnly = false; + tailResource.Update(tailEffect, CompositionContext.Default, ref updateOnly); + Assert.That(tail.Update(tailResource), Is.True); + + using RenderNodeRasterization warm = renderer.Rasterize(); + using RenderNodeRasterization warmReference = RasterizeMaterializedBlurPrefixTail( + prefixSigma: 3, + tailSigma: 4); + + Assert.Multiple(() => + { + Assert.That(prefix.Cache.IsCached, Is.True); + Assert.That(source.ExecuteCount, Is.EqualTo(1), + "The warmed static Blur prefix must be replayed from its selected cache entry."); + Assert.That(cold.Bounds, Is.EqualTo(coldReference.Bounds)); + Assert.That(warm.Bounds, Is.EqualTo(warmReference.Bounds)); + Assert.That(cold.Bitmap, Is.Not.Null); + Assert.That(warm.Bitmap, Is.Not.Null); + Assert.That(coldReference.Bitmap, Is.Not.Null); + Assert.That(warmReference.Bitmap, Is.Not.Null); + Assert.That( + cold.Bitmap!.GetPixelSpan().SequenceEqual(coldReference.Bitmap!.GetPixelSpan()), + Is.True, + "The direct cache-capture miss must match an uncached render byte for byte."); + Assert.That( + warm.Bitmap!.GetPixelSpan().SequenceEqual(warmReference.Bitmap!.GetPixelSpan()), + Is.True, + "The cached Blur prefix followed by the changed tail must match an uncached render."); + Assert.That( + warm.Bitmap.GetPixelSpan().SequenceEqual(cold.Bitmap.GetPixelSpan()), + Is.False, + "Changing the outer Blur must make the two cached frames visibly different."); + }); + } + + [Test] + public void BlurCustomBlurGroup_MaterializesAndInvokesCustomCallbackOnce() + { + int callbackCount = 0; + var group = new FilterEffectGroup(); + group.Children.Add(CreateBlur(1)); + group.Children.Add(new CallbackCustomEffect(() => callbackCount++)); + group.Children.Add(CreateBlur(2)); + using FilterEffectRenderNode node = CreateFilterNode(group); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(callbackCount, Is.EqualTo(1)); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "A segment containing CustomEffect must retain its materialized compatibility boundary."); + }); + } + + [Test] + public void BuiltInBlur_DynamicCustomInputProducingNoValues_CompletesWithoutDoubleUse() + { + int callbackCount = 0; + using FilterEffectRenderNode node = CreateDynamicCustomBlurChain( + outputCount: 0, + CreateBlur(2), + () => callbackCount++); + using RenderNodeRenderer renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(measurement.ValueCardinality.Maximum, Is.Null, + "The outer Blur must probe the dynamically declared CustomEffect at execution time."); + Assert.That(result.Bitmap, Is.Not.Null); + Assert.That(result.Bitmap!.GetPixelSpan().ToArray(), Has.All.Zero, + "A runtime-empty value sequence must contribute no pixels to the premeasured output bounds."); + Assert.That(callbackCount, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero, + "Completing the empty dynamic input must not leave a second fragment use outstanding."); + }); + } + + [Test] + public void BuiltInBlur_DynamicCustomInputProducingOneValue_MatchesMaterializedReferenceExactly() + { + int callbackCount = 0; + int referenceCallbackCount = 0; + using FilterEffectRenderNode actualNode = CreateDynamicCustomBlurChain( + outputCount: 1, + CreateBlur(2), + () => callbackCount++); + using FilterEffectRenderNode referenceNode = CreateDynamicCustomBlurChain( + outputCount: 1, + new PublicSkiaBlurEffect(2), + () => referenceCallbackCount++); + using RenderNodeRenderer actualRenderer = CreateRenderer(actualNode); + using RenderNodeRenderer referenceRenderer = CreateRenderer(referenceNode); + + RenderNodeMeasurement measurement = actualRenderer.Measure(); + using RenderNodeRasterization actual = actualRenderer.Rasterize(); + using RenderNodeRasterization expected = referenceRenderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(measurement.ValueCardinality.Maximum, Is.Null, + "The outer Blur must probe the dynamically declared CustomEffect at execution time."); + Assert.That(callbackCount, Is.EqualTo(1)); + Assert.That(referenceCallbackCount, Is.EqualTo(1)); + Assert.That(actual.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Bitmap!.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Bitmap.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.Bitmap.GetPixelSpan()), + Is.True, + "A runtime-single CustomEffect result must match the materialized public-Skia semantics byte for byte."); + Assert.That(actualRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void BuiltInBlur_DynamicCustomInputProducingTwoValues_StaysMaterializedAndMatchesReferenceExactly() + { + int callbackCount = 0; + int referenceCallbackCount = 0; + using FilterEffectRenderNode actualNode = CreateDynamicCustomBlurChain( + outputCount: 2, + CreateBlur(2), + () => callbackCount++); + using FilterEffectRenderNode referenceNode = CreateDynamicCustomBlurChain( + outputCount: 2, + new PublicSkiaBlurEffect(2), + () => referenceCallbackCount++); + using RenderNodeRenderer actualRenderer = CreateRenderer(actualNode); + using RenderNodeRenderer referenceRenderer = CreateRenderer(referenceNode); + + RenderNodeMeasurement measurement = actualRenderer.Measure(); + using RenderNodeRasterization actual = actualRenderer.Rasterize(); + using RenderNodeRasterization expected = referenceRenderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(measurement.ValueCardinality.Maximum, Is.Null, + "The outer Blur must probe the dynamically declared CustomEffect at execution time."); + Assert.That(callbackCount, Is.EqualTo(1)); + Assert.That(referenceCallbackCount, Is.EqualTo(1)); + Assert.That(actual.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Bitmap!.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Bitmap.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.Bitmap.GetPixelSpan()), + Is.True, + "A runtime-multiple CustomEffect result must retain per-value materialized Blur semantics."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.EqualTo(referenceRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions), + "Runtime-multiple values must keep the same materialization boundary as the public-Skia fallback."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "Runtime-multiple values must not collapse into one direct destination replay."); + Assert.That(actualRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void PublicSkiaFactory_RemainsOnTheMaterializedCompatibilityPath() + { + using FilterEffectRenderNode node = CreateFilterNode(new PublicSkiaBlurEffect()); + using RenderNodeRenderer renderer = CreateRenderer(node); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "A public Skia factory can observe its activator and must not be assumed pure."); + }); + } + + [Test] + public void PureBuiltInBlurGroup_WithDifferentWorkingDensity_RemainsMaterialized() + { + const float outputDensity = 1; + const float workingDensity = 2; + using var node = new FixedWorkingScaleFilterRenderNode( + CreateBlurGroup().ToResource(CompositionContext.Default), + workingDensity); + node.AddChild(CreateSource()); + using RenderNodeRenderer renderer = CreateRenderer( + node, + outputDensity, + maxWorkingDensity: workingDensity); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(workingDensity))); + Assert.That(result.OutputScale, Is.EqualTo(outputDensity)); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "Direct replay cannot substitute a segment whose working density differs from the destination."); + }); + } + + [Test] + public void BuiltInBlur_MultipleLeafValues_MatchesMaterializedPublicSkiaFallbackExactly() + { + using var actualNode = new FilterEffectRenderNode( + CreateBlur(2).ToResource(CompositionContext.Default)); + actualNode.AddChild(new TwoValueExpansionNode()); + using RenderNodeRenderer actualRenderer = CreateRenderer(actualNode); + + using var referenceNode = new FilterEffectRenderNode( + new PublicSkiaBlurEffect().ToResource(CompositionContext.Default)); + referenceNode.AddChild(new TwoValueExpansionNode()); + using RenderNodeRenderer referenceRenderer = CreateRenderer(referenceNode); + + using RenderNodeRasterization actual = actualRenderer.Rasterize(); + using RenderNodeRasterization expected = referenceRenderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Bitmap!.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Bitmap.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.Bitmap.GetPixelSpan()), + Is.True, + "Blur over a multi-value leaf must preserve the materialized per-value filter semantics."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "A leaf that can yield multiple values cannot be replayed directly through one Blur paint."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.EqualTo(referenceRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions), + "Built-in Blur must retain the same materialization boundary as the public Skia fallback."); + }); + } + + [Test] + public void DeepAlternatingBuiltInBlurAndCombiningCopy_MatchesExplicitlySynchronizedControlExactly() + { + int actualCopyCount = 0; + int referenceCopyCount = 0; + using ImageSource.Resource source = CreatePatternImageSourceResource(); + using FilterEffectRenderNode actualNode = CreateDeepAlternatingBlurCopyChain( + source, + synchronizeSource: false, + () => actualCopyCount++); + using FilterEffectRenderNode referenceNode = CreateDeepAlternatingBlurCopyChain( + source, + synchronizeSource: true, + () => referenceCopyCount++); + using RenderNodeRenderer actualRenderer = CreateRenderer( + actualNode, + maxWorkingDensity: 1, + requestedRegion: s_deepTargetDomain, + targetDomain: s_deepTargetDomain); + using RenderNodeRenderer referenceRenderer = CreateRenderer( + referenceNode, + maxWorkingDensity: 1, + requestedRegion: s_deepTargetDomain, + targetDomain: s_deepTargetDomain); + + var actualFlushes = new List(); + var referenceFlushes = new List(); + using RenderNodeRasterization actual = RasterizeWithObservedFlushes(actualRenderer, actualFlushes); + using RenderNodeRasterization expected = RasterizeWithObservedFlushes(referenceRenderer, referenceFlushes); + + AssertMatchingRgbaF16Rasterization( + actual, + expected, + "Removing synchronous source waits from a deep known-bounds custom chain must not change its pixels."); + Assert.Multiple(() => + { + Assert.That(actualCopyCount, Is.EqualTo(DeepAlternatingPairCount)); + Assert.That(referenceCopyCount, Is.EqualTo(DeepAlternatingPairCount)); + Assert.That(actual.Bitmap!.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That( + referenceFlushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling), + Is.EqualTo( + actualFlushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling) + + DeepAlternatingPairCount), + "The control must synchronously sample once at every custom-copy boundary."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "Each custom-copy boundary must remain materialized."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.EqualTo(referenceRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions)); + Assert.That(actualRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(referenceRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void DeepAlternatingBuiltInBlurAndCombiningCopy_PartialRequestedRegionMatchesExplicitlySynchronizedControlExactly() + { + var requestedRegion = new Rect(53, 31, 191, 113); + int actualCopyCount = 0; + int referenceCopyCount = 0; + using ImageSource.Resource source = CreatePatternImageSourceResource(); + using FilterEffectRenderNode actualNode = CreateDeepAlternatingBlurCopyChain( + source, + synchronizeSource: false, + () => actualCopyCount++); + using FilterEffectRenderNode referenceNode = CreateDeepAlternatingBlurCopyChain( + source, + synchronizeSource: true, + () => referenceCopyCount++); + using RenderNodeRenderer actualRenderer = CreateRenderer( + actualNode, + maxWorkingDensity: 1, + requestedRegion: requestedRegion, + targetDomain: s_deepTargetDomain); + using RenderNodeRenderer referenceRenderer = CreateRenderer( + referenceNode, + maxWorkingDensity: 1, + requestedRegion: requestedRegion, + targetDomain: s_deepTargetDomain); + + var actualFlushes = new List(); + var referenceFlushes = new List(); + using RenderNodeRasterization actual = RasterizeWithObservedFlushes(actualRenderer, actualFlushes); + using RenderNodeRasterization expected = RasterizeWithObservedFlushes(referenceRenderer, referenceFlushes); + + AssertMatchingRgbaF16Rasterization( + actual, + expected, + "Removing synchronous source waits from a partial deep known-bounds custom request must not change its pixels."); + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(actualCopyCount, Is.EqualTo(DeepAlternatingPairCount)); + Assert.That(referenceCopyCount, Is.EqualTo(DeepAlternatingPairCount)); + Assert.That(actual.Bitmap!.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That( + referenceFlushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling), + Is.EqualTo( + actualFlushes.Count(static item => item == ImmediateCanvasFlushKind.PrepareForSampling) + + DeepAlternatingPairCount), + "The cropped control must synchronously sample once at every custom-copy boundary."); + Assert.That( + actualRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.EqualTo(referenceRenderer.LastExecutionStatistics.IntermediateTargetAcquisitions)); + Assert.That(actualRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(referenceRenderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + private static FilterEffectRenderNode CreateFilterNode(FilterEffect effect) + { + var node = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + node.AddChild(CreateSource()); + return node; + } + + private static FilterEffectRenderNode CreateSerialBlurNodes() + { + RenderNode current = CreateSource(); + FilterEffectRenderNode? outer = null; + foreach (float sigma in s_blurSigmas) + { + outer = new FilterEffectRenderNode( + CreateBlur(sigma).ToResource(CompositionContext.Default)); + outer.AddChild(current); + current = outer; + } + + return outer!; + } + + private static FilterEffectRenderNode CreateDynamicCustomBlurChain( + int outputCount, + FilterEffect blur, + Action callback) + { + var custom = new FilterEffectRenderNode( + new RuntimeCardinalityCustomEffect(outputCount, callback) + .ToResource(CompositionContext.Default)); + custom.AddChild(CreateSource()); + + var outer = new FilterEffectRenderNode( + blur.ToResource(CompositionContext.Default)); + outer.AddChild(custom); + return outer; + } + + private static FilterEffectRenderNode CreateDeepAlternatingBlurCopyChain( + ImageSource.Resource source, + bool synchronizeSource, + Action copyCallback) + { + RenderNode current = new ImageSourceRenderNode(source, Brushes.Resource.White, null); + FilterEffectRenderNode? outer = null; + for (int i = 0; i < DeepAlternatingPairCount; i++) + { + outer = WrapFilter(current, CreateBlur(DeepAlternatingBlurSigma)); + current = outer; + + outer = WrapFilter(current, new CombiningCopyCustomEffect( + synchronizeSource, + copyCallback)); + current = outer; + } + + return outer!; + } + + private static FilterEffectRenderNode WrapFilter(RenderNode input, FilterEffect effect) + { + var node = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + node.AddChild(input); + return node; + } + + private static EllipseRenderNode CreateSource() + => new(s_sourceBounds, Brushes.Resource.White, null); + + private static FilterEffectGroup CreateBlurGroup() + { + var group = new FilterEffectGroup(); + foreach (float sigma in s_blurSigmas) + group.Children.Add(CreateBlur(sigma)); + return group; + } + + private static Rect GetBlurChainBounds() + => GetBlurChainBounds(s_sourceBounds); + + private static Rect GetBlurChainBounds(Rect sourceBounds) + => sourceBounds.Inflate(new Thickness(s_blurSigmas.Sum() * 3)); + + private static Blur CreateBlur(float sigma) + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(sigma, sigma); + return blur; + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + float outputDensity = 1, + float maxWorkingDensity = 4, + Rect? requestedRegion = null, + RenderCacheOptions? cacheOptions = null, + Rect? targetDomain = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = outputDensity, + MaxWorkingScale = maxWorkingDensity, + TargetDomain = targetDomain, + RequestedRegion = requestedRegion, + CacheOptions = cacheOptions ?? RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static void AssertMatchingRgbaF16Rasterization( + RenderNodeRasterization actual, + RenderNodeRasterization expected, + string message) + { + Assert.Multiple(() => + { + Assert.That(actual.Bounds, Is.EqualTo(expected.Bounds)); + Assert.That(actual.Bitmap, Is.Not.Null); + Assert.That(expected.Bitmap, Is.Not.Null); + Assert.That(actual.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(expected.Bitmap!.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(actual.Bitmap.Width, Is.EqualTo(expected.Bitmap.Width)); + Assert.That(actual.Bitmap.Height, Is.EqualTo(expected.Bitmap.Height)); + Assert.That( + actual.Bitmap.GetPixelSpan().SequenceEqual(expected.Bitmap.GetPixelSpan()), + Is.True, + $"{message} {DescribePixelDifference(actual.Bitmap, expected.Bitmap)}"); + }); + } + + private static RenderNodeRasterization RasterizeWithObservedFlushes( + RenderNodeRenderer renderer, + ICollection flushes) + { + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + return renderer.Rasterize(); + } + + private static string DescribePixelDifference(Bitmap actual, Bitmap expected) + { + ReadOnlySpan actualChannels = actual.GetPixelSpan(); + ReadOnlySpan expectedChannels = expected.GetPixelSpan(); + int differingChannels = 0; + int differingPixels = 0; + int maximumBitDelta = 0; + for (int i = 0; i < actualChannels.Length; i += 4) + { + bool pixelDiffers = false; + for (int channel = 0; channel < 4; channel++) + { + int delta = Math.Abs(actualChannels[i + channel] - expectedChannels[i + channel]); + if (delta == 0) + continue; + + differingChannels++; + pixelDiffers = true; + maximumBitDelta = Math.Max(maximumBitDelta, delta); + } + + if (pixelDiffers) + differingPixels++; + } + + return $"Differing RGBAF16 channels: {differingChannels}; pixels: {differingPixels}; maximum half-bit delta: {maximumBitDelta}."; + } + + private static RenderNodeRasterization RasterizeMaterializedBlurPrefixTail( + float prefixSigma, + float tailSigma) + { + using var prefix = new FilterEffectRenderNode( + new PublicSkiaBlurEffect(prefixSigma).ToResource(CompositionContext.Default)); + prefix.AddChild(CreateSource()); + using var tail = new FilterEffectRenderNode( + CreateBlur(tailSigma).ToResource(CompositionContext.Default)); + tail.AddChild(prefix); + using RenderNodeRenderer renderer = CreateRenderer(tail); + return renderer.Rasterize(); + } + + private static Bitmap ExtractGlobalRegion( + Bitmap complete, + Rect completeBounds, + Rect requestedRegion) + { + PixelRect completePixels = PixelRect.FromRect(completeBounds, 1); + PixelRect requestedPixels = PixelRect.FromRect(requestedRegion, 1); + return complete.ExtractSubset(new PixelRect( + requestedPixels.X - completePixels.X, + requestedPixels.Y - completePixels.Y, + requestedPixels.Width, + requestedPixels.Height)); + } + + private static Bitmap RenderNestedSkiaReference(Rect outputBounds) + { + PixelRect deviceBounds = PixelRect.FromRect(outputBounds, 1); + using RenderTarget target = new CpuRenderTarget(deviceBounds.Size); + using var canvas = new ImmediateCanvas( + target, + density: 1, + maxWorkingScale: 4, + logicalSize: deviceBounds.ToRect(1).Size); + canvas.Clear(); + + using SKImageFilter inner = SKImageFilter.CreateBlur( + s_blurSigmas[0], + s_blurSigmas[0]); + using SKImageFilter middle = SKImageFilter.CreateBlur( + s_blurSigmas[1], + s_blurSigmas[1], + inner); + using SKImageFilter outer = SKImageFilter.CreateBlur( + s_blurSigmas[2], + s_blurSigmas[2], + middle); + using var paint = new SKPaint { ImageFilter = outer }; + Rect rasterBounds = deviceBounds.ToRect(1); + using (canvas.PushTransform(Matrix.CreateTranslation(-rasterBounds.X, -rasterBounds.Y))) + using (canvas.PushBlendMode(BlendMode.SrcOver)) + using (canvas.PushTransform(Matrix.Identity)) + using (canvas.PushPaint(paint)) + { + canvas.DrawEllipse(s_sourceBounds, Brushes.Resource.White, null); + } + + return target.Snapshot(); + } + + private static Bitmap RenderNestedSkiaImageReference( + ImageSource.Resource source, + Rect outputBounds) + { + PixelRect deviceBounds = PixelRect.FromRect(outputBounds, 1); + using RenderTarget target = new CpuRenderTarget(deviceBounds.Size); + using var canvas = new ImmediateCanvas( + target, + density: 1, + maxWorkingScale: 4, + logicalSize: deviceBounds.ToRect(1).Size); + canvas.Clear(); + + using SKImageFilter inner = SKImageFilter.CreateBlur( + s_blurSigmas[0], + s_blurSigmas[0]); + using SKImageFilter middle = SKImageFilter.CreateBlur( + s_blurSigmas[1], + s_blurSigmas[1], + inner); + using SKImageFilter outer = SKImageFilter.CreateBlur( + s_blurSigmas[2], + s_blurSigmas[2], + middle); + using var paint = new SKPaint { ImageFilter = outer }; + Rect rasterBounds = deviceBounds.ToRect(1); + using (canvas.PushTransform(Matrix.CreateTranslation(-rasterBounds.X, -rasterBounds.Y))) + using (canvas.PushBlendMode(BlendMode.SrcOver)) + using (canvas.PushTransform(Matrix.Identity)) + using (canvas.PushPaint(paint)) + { + canvas.DrawImageSource(source, Brushes.Resource.White, null); + } + + return target.Snapshot(); + } + + private static ImageSource.Resource CreateImageSourceResource() + { + var source = new ImageSource(); + source.ReadFrom(TestMediaHelper.CreateTestImageUri(40, 32, Colors.White)); + return source.ToResource(CompositionContext.Default); + } + + private static ImageSource.Resource CreatePatternImageSourceResource() + { + using var bitmap = new Bitmap( + s_deepPatternSize.Width, + s_deepPatternSize.Height, + BitmapColorType.Bgra8888, + BitmapAlphaType.Premul, + BitmapColorSpace.Srgb); + Span pixels = bitmap.GetPixelSpan(); + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + uint value = MixPatternCoordinates((uint)x, (uint)y); + pixels[(y * bitmap.Width) + x] = new Bgra8888( + (byte)value, + (byte)(value >> 8), + (byte)(value >> 16), + byte.MaxValue); + } + } + + using var stream = new MemoryStream(); + bitmap.Save(stream, EncodedImageFormat.Png); + var source = new ImageSource(); + source.ReadFrom(UriHelper.CreateBase64DataUri("image/png", stream.ToArray())); + return source.ToResource(CompositionContext.Default); + } + + private static uint MixPatternCoordinates(uint x, uint y) + { + uint value = 20_040_719u ^ (x * 0x9e37_79b9u) ^ (y * 0x85eb_ca6bu); + value ^= value >> 16; + value *= 0x7feb_352du; + value ^= value >> 15; + value *= 0x846c_a68bu; + return value ^ (value >> 16); + } + + private sealed class FixedWorkingScaleFilterRenderNode( + FilterEffect.Resource effect, + float workingDensity) : FilterEffectRenderNode(effect) + { + private readonly RenderScaleContract _scale = RenderScaleContract.Custom( + _ => workingDensity); + + protected override RenderScaleContract? GetWorkingScaleContract() => _scale; + } + + private sealed class CacheableEllipseSourceNode : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + private static readonly RenderResourceSlot s_probeSlot = new(); + private readonly ExecutionProbe _probe = new(); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) + { + Brush.Resource fillResource = Brushes.Resource.White; + RenderResource fill = context.Borrow(fillResource); + RenderResource probe = context.Borrow(_probe); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + "cacheable-ellipse", + static (session, _) => session.UseResource(s_probeSlot, currentProbe => + { + currentProbe.Record(); + session.UseResource(s_fillSlot, currentFill => + { + using OpaqueRenderOutput output = session.CreateOutput(s_sourceBounds); + output.Canvas.Use(canvas => + canvas.DrawEllipse(s_sourceBounds, currentFill, null)); + session.Publish(output); + }); + }), + OpaqueRenderBoundsContract.Source(s_sourceBounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [s_fillSlot.Bind(fill), s_probeSlot.Bind(probe)]); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class TwoValueExpansionNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle seed = context.OpaqueSource( + OpaqueRenderDescription.CreateRequestLocal( + static session => + { + using OpaqueRenderOutput output = session.CreateOutput(s_sourceBounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.Transparent)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_sourceBounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle expanded = context.OpaqueExpand( + [seed], + OpaqueRenderDescription.CreateRequestLocal( + static session => + { + using OpaqueRenderOutput red = session.CreateOutput(session.OutputBounds); + red.Canvas.Use(canvas => canvas.Clear(Colors.Red)); + session.Publish(red); + + using OpaqueRenderOutput blue = session.CreateOutput(session.OutputBounds); + blue.Canvas.Use(canvas => canvas.Clear(Colors.Blue)); + session.Publish(blue); + }, + OpaqueRenderBoundsContract.FullInputs(static inputs => inputs.Single()), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Exactly(2), + RenderScaleContract.MaterializeAtWorkingScale)); + context.Publish(expanded); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget(PixelSize size) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU direct-replay test surface."), + size.Width, + size.Height); + + [SuppressResourceClassGeneration] + private sealed partial class CallbackCustomEffect(Action callback) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect( + 0, + (_, _) => callback(), + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + [SuppressResourceClassGeneration] + private sealed partial class RuntimeCardinalityCustomEffect( + int outputCount, + Action callback) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect( + (OutputCount: outputCount, Callback: callback), + static (state, execution) => + { + state.Callback(); + switch (state.OutputCount) + { + case 0: + while (execution.Targets.Count > 0) + { + int index = execution.Targets.Count - 1; + execution.Targets[index].Dispose(); + execution.Targets.RemoveAt(index); + } + + break; + case 1: + break; + case 2: + execution.Targets.Add(execution.Targets.Single().Clone()); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(state.OutputCount), + state.OutputCount, + "The test effect supports zero, one, or two outputs."); + } + }, + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + [SuppressResourceClassGeneration] + private sealed partial class CombiningCopyCustomEffect( + bool synchronizeSource, + Action callback) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect( + 0, + (_, execution) => + { + callback(); + Rect combinedBounds = execution.Targets.CalculateBounds(); + EffectTarget combined = execution.CreateTarget(combinedBounds); + using (ImmediateCanvas canvas = execution.Open(combined)) + { + canvas.Clear(); + foreach (EffectTarget source in execution.Targets) + { + if (synchronizeSource) + { + using Bitmap snapshot = source.RenderTarget!.Snapshot(); + } + + using (canvas.PushTransform(Matrix.CreateTranslation( + source.Bounds.Position - combinedBounds.Position))) + { + source.Draw(canvas); + } + } + } + + for (int i = execution.Targets.Count - 1; i >= 0; i--) + { + execution.Targets[i].Dispose(); + execution.Targets.RemoveAt(i); + } + + execution.Targets.Add(combined); + }, + static (_, bounds) => bounds); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + [SuppressResourceClassGeneration] + private sealed partial class PublicSkiaBlurEffect(float sigma = 2) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.AppendSkiaFilter( + new Size(sigma, sigma), + static (sigma, input, _) => SKImageFilter.CreateBlur(sigma.Width, sigma.Height, input), + static (sigma, bounds) => bounds.Inflate(new Thickness(sigma.Width * 3, sigma.Height * 3)), + static (sigma, region) => region.Inflate(new Thickness(sigma.Width * 3, sigma.Height * 3))); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DrawBackdropRenderNodeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DrawBackdropRenderNodeTests.cs new file mode 100644 index 0000000000..ba49e11b5f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/DrawBackdropRenderNodeTests.cs @@ -0,0 +1,88 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class DrawBackdropRenderNodeTests +{ + private static readonly Rect s_domain = new(0, 0, 120, 90); + + [Test] + public void BuiltInBackdrop_RecordsOverAZeroAreaCanvasWithoutReportingAPhantomHit() + { + using var root = new ContainerRenderNode(); + using (var context = new GraphicsContext2D(root)) + { + context.DrawBackdrop(context.Snapshot()); + } + + using RenderNodeRenderer renderer = CreateRenderer(root); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(renderer.HitTest(default), Is.False); + }); + } + + [Test] + public void RawBackdrop_RecordsOverAZeroAreaCanvasWithoutReportingAPhantomHit() + { + using var root = new ContainerRenderNode(); + using (var context = new GraphicsContext2D(root)) + { + context.DrawBackdrop(new StubBackdrop()); + } + + using RenderNodeRenderer renderer = CreateRenderer(root); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(renderer.HitTest(default), Is.False); + }); + } + + [Test] + public void Backdrop_KeepsOutputBoundsHitTestingOverAPositiveAreaCanvas() + { + using var root = new ContainerRenderNode(); + using (var context = new GraphicsContext2D(root, s_domain.Size)) + { + context.DrawBackdrop(new StubBackdrop()); + } + + using RenderNodeRenderer renderer = CreateRenderer(root); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.QueryBounds, Is.EqualTo(s_domain)); + Assert.That(renderer.HitTest(new Point(60, 45)), Is.True); + Assert.That(renderer.HitTest(new Point(200, 45)), Is.False); + }); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode root) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + private sealed class StubBackdrop : IBackdrop + { + public void Draw(ImmediateCanvas canvas) + { + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EllipseRenderNodeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EllipseRenderNodeTests.cs index 03557f5164..d8d271471d 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EllipseRenderNodeTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EllipseRenderNodeTests.cs @@ -1,6 +1,7 @@ using Beutl.Composition; using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; using Beutl.Media; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -44,20 +45,93 @@ public void Update_ShouldReturnTrue_WhenPropertiesDoNotMatch() } [Test] - public void Process_ShouldReturnCorrectRenderNodeOperation() + public void Update_ShouldNotMarkChanges_WhenAllPropertiesMatch() + { + var rect = new Rect(0, 0, 100, 100); + var fillResource = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(rect, fillResource, null); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(rect, fillResource, null), Is.False); + Assert.That(node.HasChanges, Is.False); + }); + } + + [Test] + public void Update_ShouldMarkChanges_WhenPropertiesDoNotMatch() + { + var rect1 = new Rect(0, 0, 100, 100); + var rect2 = new Rect(0, 0, 200, 200); + var fillResource1 = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + var fillResource2 = new SolidColorBrush(Colors.Blue).ToResource(CompositionContext.Default); + Pen pen = new() { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 1 } }; + var penResource = pen.ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(rect1, fillResource1, null); + + node.HasChanges = false; + bool rectChanged = node.Update(rect2, fillResource1, null); + bool rectMarked = node.HasChanges; + + node.HasChanges = false; + bool fillChanged = node.Update(rect2, fillResource2, null); + bool fillMarked = node.HasChanges; + + node.HasChanges = false; + bool penChanged = node.Update(rect2, fillResource2, penResource); + bool penMarked = node.HasChanges; + + Assert.Multiple(() => + { + Assert.That(rectChanged, Is.True); + Assert.That(rectMarked, Is.True); + Assert.That(fillChanged, Is.True); + Assert.That(fillMarked, Is.True); + Assert.That(penChanged, Is.True); + Assert.That(penMarked, Is.True); + }); + } + + [Test] + public void ChangedParameters_ShouldRevokeAnAdmittedCache() + { + var rect = new Rect(0, 0, 100, 100); + var fillResource = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(rect, fillResource, null); + + for (int frame = 0; frame < RenderNodeCache.StableRequestCount; frame++) + { + node.Update(rect, fillResource, null); + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } + + Assert.That(node.Cache.CanCapture, Is.True, "a stable ellipse must become a cache candidate"); + + node.Update(new Rect(0, 0, 200, 200), fillResource, null); + RenderNodeCacheHelper.BeginLifecycle(node); + + Assert.That(node.Cache.CanCapture, Is.False); + } + + [Test] + public void Measure_ShouldReportRecordedFragment() { var rect = new Rect(0, 0, 100, 100); Brush fill = new SolidColorBrush(Colors.Red); Pen pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 1 } }; var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new EllipseRenderNode(rect, fillResource, penResource); - var operations = node.Process(context); - - Assert.That(operations, Is.Not.Null); - Assert.That(operations.Length, Is.EqualTo(1)); + using var node = new EllipseRenderNode(rect, fillResource, penResource); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + }); } [Test] @@ -68,13 +142,11 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideEllipse() Pen pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 1 } }; var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new EllipseRenderNode(rect, fillResource, penResource); - var operations = node.Process(context); + using var node = new EllipseRenderNode(rect, fillResource, penResource); + using var renderer = CreateRenderer(node); var point = new Point(50, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } [Test] @@ -85,13 +157,11 @@ public void HitTest_ShouldReturnFalse_WhenPointIsOutsideEllipse() Pen pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 1 } }; var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new EllipseRenderNode(rect, fillResource, penResource); - var operations = node.Process(context); + using var node = new EllipseRenderNode(rect, fillResource, penResource); + using var renderer = CreateRenderer(node); var point = new Point(150, 150); - Assert.That(operations[0].HitTest(point), Is.False); + Assert.That(renderer.HitTest(point), Is.False); } [Test] @@ -100,12 +170,19 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideEllipseStroke() var rect = new Rect(25, 25, 75, 75); Pen pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 50 } }; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new EllipseRenderNode(rect, null, penResource); - var operations = node.Process(context); + using var node = new EllipseRenderNode(rect, null, penResource); + using var renderer = CreateRenderer(node); var point = new Point(30, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityRoutingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityRoutingTests.cs new file mode 100644 index 0000000000..6011dc37ad --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityRoutingTests.cs @@ -0,0 +1,360 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Particles; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics3D; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Covers the six render-node sites that read GetOriginal().Id directly and now route through +/// . +/// +/// +/// Each site keeps a -typed identity element, so the routing has to stay allocation-free to be +/// worth taking. The recorded end-to-end outcomes below are what each node actually does with a detached +/// resource, measured rather than reasoned about. Which site a detached resource reaches first depends on which +/// of a node's resources is detached, so the outcome is recorded per input shape rather than per node. +/// Geometry.Resource now builds its path from itself, so a detached geometry no longer fails ahead of the +/// routed identity read; covers that path. +/// +[TestFixture] +public sealed class EngineResourceIdentityRoutingTests +{ + private const int Iterations = 20000; + private const int Rounds = 5; + + private static GeometryHitTestIdentityShape s_geometrySink; + private static Guid s_geometryClipSink; + private static (Guid Id, int Version, ClipOperation Operation) s_geometryClipStateSink; + private static ParticleSnapshotIdentityShape s_particleSink; + private static (Type Owner, Guid Id, int Segment) s_filterEffectSink; + private static SceneSnapshotIdentityShape s_sceneSnapshotSink; + private static SceneRuntimeIdentityShape s_sceneRuntimeSink; + + [Test] + public void ADetachedResourceOfEveryRoutedSiteType_DerivesAnIdentityInsteadOfThrowing() + { + using var geometry = new EllipseGeometry.Resource(); + using var brush = new SolidColorBrush.Resource(); + using var pen = new Pen.Resource(); + using var emitter = new ParticleEmitter.Resource(); + using var effect = new ShakeEffect.Resource(); + using var scene = new Scene3D.Resource(); + EngineObject.Resource[] resources = [geometry, brush, pen, emitter, effect, scene]; + + using (Assert.EnterMultipleScope()) + { + foreach (EngineObject.Resource resource in resources) + { + Assert.That(resource.GetOriginal(), Is.Null, + $"{resource.GetType()} is detached, so it has no backing id"); + Guid first = Guid.Empty; + Assert.DoesNotThrow(() => first = EngineResourceIdentity.Of(resource)); + Assert.That(EngineResourceIdentity.Of(resource), Is.EqualTo(first), + "the synthesized identity is held weakly against the resource and survives between reads"); + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + } + } + } + + /// + /// The one site this change rescues end to end. A detached reaches + /// 's identity read before anything else dereferences it, so the routing + /// turns a into a complete render. Both the node's constructor and + /// GraphicsContext2D.DrawGeometry take a publicly constructible Brush.Resource?, so this is + /// an ordinary plugin shape rather than a contrived one. + /// + [Test] + public void GeometryRenderNode_WithADetachedFill_RendersInsteadOfThrowing() + { + var geometry = new EllipseGeometry { Width = { CurrentValue = 40 }, Height = { CurrentValue = 30 } }; + using Beutl.Media.Geometry.Resource geometryResource = geometry.ToResource(CompositionContext.Default); + using var fill = new SolidColorBrush.Resource(); + using var node = new GeometryRenderNode(geometryResource, fill, null); + + Exception? failure = RecordAndCaptureFailure(node); + + Assert.That(failure, Is.Null, + "with the geometry attached, the detached fill's identity read is the first dereference, " + + "so the routing has to rescue it"); + } + + [Test] + public void GeometryRenderNode_WithADetachedGeometry_ReachesItsRoutedIdentityRead() + { + using var geometry = new EllipseGeometry.Resource { Width = 40, Height = 30 }; + using var node = new GeometryRenderNode(geometry, null, null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(RecordAndCaptureFailure(node), Is.Null, + "GetRenderBounds no longer dereferences the backing object, so recording reaches the routed read"); + Assert.That(RecordedFragmentCount(node), Is.EqualTo(1)); + } + } + + [Test] + public void GeometryClipRenderNode_WithADetachedGeometry_RecordsItsScope() + { + using var geometry = new EllipseGeometry.Resource { Width = 40, Height = 30 }; + using var node = new GeometryClipRenderNode(geometry, ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode(new Rect(0, 0, 8, 8), null, null)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(RecordAndCaptureFailure(node), Is.Null, + "the routed identity read and the Bounds read one statement later both survive detachment now"); + Assert.That(RecordedFragmentCount(node), Is.EqualTo(1)); + } + } + + [Test] + public void ParticleRenderNode_NeverReachesItsIdentityRead_BecauseADetachedEmitterHasNoAliveParticles() + { + using var emitter = new ParticleEmitter.Resource(); + using var node = new ParticleRenderNode(emitter); + + using (Assert.EnterMultipleScope()) + { + Assert.That(emitter.GetAliveParticles().Length, Is.Zero, + "the simulator field is initialized inline but only Update ever simulates"); + Assert.That(RecordAndCaptureFailure(node), Is.Null); + Assert.That(RecordedFragmentCount(node), Is.Zero); + } + } + + [Test] + public void Scene3DRenderNode_NeverReachesItsIdentityReads_BecauseADetachedSceneHasNoCamera() + { + using var scene = new Scene3D.Resource(); + using var node = new Scene3DRenderNode(scene); + + using (Assert.EnterMultipleScope()) + { + Assert.That(scene.Camera, Is.Null); + Assert.That(RecordAndCaptureFailure(node), Is.Null); + Assert.That(RecordedFragmentCount(node), Is.Zero); + } + } + + + [Test] + public void EveryRoutedSite_BuildsItsIdentityWithoutAllocating() + { + var geometry = new EllipseGeometry { Width = { CurrentValue = 40 }, Height = { CurrentValue = 30 } }; + var fill = new SolidColorBrush(Colors.Red); + var pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 2 } }; + using Beutl.Media.Geometry.Resource geometryResource = geometry.ToResource(CompositionContext.Default); + using SolidColorBrush.Resource fillResource = fill.ToResource(CompositionContext.Default); + using Pen.Resource penResource = pen.ToResource(CompositionContext.Default); + var emitter = new ParticleEmitter(); + using var emitterResource = + (ParticleEmitter.Resource)emitter.ToResource(new CompositionContext(TimeSpan.FromSeconds(1))); + var effect = new ShakeEffect(); + using FilterEffect.Resource effectResource = effect.ToResource(CompositionContext.Default); + var scene = new Scene3D(); + using var sceneResource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + var bounds = new Rect(0, 0, 32, 24); + + (string Site, long Before, long After)[] measurements = + [ + Compare( + "GeometryRenderNode.cs:48,50,52", + () => s_geometrySink = new GeometryHitTestIdentityShape( + geometryResource.GetOriginal()!.Id, + geometryResource.Version, + fillResource.GetOriginal()!.Id, + fillResource.Version, + penResource.GetOriginal()!.Id, + penResource.Version), + () => s_geometrySink = new GeometryHitTestIdentityShape( + EngineResourceIdentity.Of(geometryResource), + geometryResource.Version, + EngineResourceIdentity.Of(fillResource), + fillResource.Version, + EngineResourceIdentity.Of(penResource), + penResource.Version)), + Compare( + "GeometryClipRenderNode.cs:46", + () => + { + s_geometryClipSink = geometryResource.GetOriginal()!.Id; + s_geometryClipStateSink = + (s_geometryClipSink, geometryResource.Version, ClipOperation.Intersect); + }, + () => + { + s_geometryClipSink = EngineResourceIdentity.Of(geometryResource); + s_geometryClipStateSink = + (s_geometryClipSink, geometryResource.Version, ClipOperation.Intersect); + }), + Compare( + "ParticleRenderNode.cs:55", + () => s_particleSink = new ParticleSnapshotIdentityShape( + emitterResource.GetOriginal()!.Id, + emitterResource.Version), + () => s_particleSink = new ParticleSnapshotIdentityShape( + EngineResourceIdentity.Of(emitterResource), + emitterResource.Version)), + Compare( + "FilterEffectRenderNode.cs:201", + () => s_filterEffectSink = + (typeof(FilterEffectRenderNode), effectResource.GetOriginal()!.Id, 0), + () => s_filterEffectSink = + (typeof(FilterEffectRenderNode), EngineResourceIdentity.Of(effectResource), 0)), + Compare( + "Scene3DRenderNode.cs:97", + () => s_sceneSnapshotSink = new SceneSnapshotIdentityShape( + sceneResource.GetOriginal()!.Id, + sceneResource.Version), + () => s_sceneSnapshotSink = new SceneSnapshotIdentityShape( + EngineResourceIdentity.Of(sceneResource), + sceneResource.Version)), + Compare( + "Scene3DRenderNode.cs:117", + () => s_sceneRuntimeSink = new SceneRuntimeIdentityShape( + sceneResource.GetOriginal()!.Id, + sceneResource.Version, + bounds), + () => s_sceneRuntimeSink = new SceneRuntimeIdentityShape( + EngineResourceIdentity.Of(sceneResource), + sceneResource.Version, + bounds)), + ]; + + using (Assert.EnterMultipleScope()) + { + foreach ((string site, long before, long after) in measurements) + { + TestContext.Out.WriteLine( + $"{site}: {before} -> {after} bytes per {Iterations} builds"); + Assert.That(after, Is.LessThanOrEqualTo(before), $"{site} got more expensive"); + Assert.That(after, Is.Zero, $"{site} must build its identity without allocating"); + } + + // The sinks give each measured identity somewhere to be stored. Two of them are written but never + // otherwise read, which is CS0414 and so a build break under this repository's 0-warning bar; these + // reads exist to answer that compiler warning and assert nothing a reader should rely on. + _ = s_geometryClipStateSink; + _ = s_filterEffectSink; + } + } + + private static (string Site, long Before, long After) Compare(string site, Action before, Action after) + => (site, Measure(before), Measure(after)); + + private static long Measure(Action build) + { + for (int index = 0; index < 200; index++) + build(); + + long best = long.MaxValue; + for (int round = 0; round < Rounds; round++) + { + long start = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < Iterations; index++) + build(); + best = Math.Min(best, GC.GetAllocatedBytesForCurrentThread() - start); + } + + return best; + } + + private static Exception? RecordAndCaptureFailure(RenderNode node) + { + try + { + _ = RecordedFragmentCount(node); + return null; + } + catch (Exception ex) + { + return Unwrap(ex); + } + } + + private static int RecordedFragmentCount(RenderNode node) + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + return new RenderRequestRecorder(request).Record(node).PublicationRoots.Count(); + } + + private static Exception Unwrap(Exception exception) + { + Exception current = exception; + while (current.InnerException is { } inner) + current = inner; + return current; + } + + private static Type? DeepestFrameType(Exception exception) + => new System.Diagnostics.StackTrace(exception).GetFrame(0)?.GetMethod()?.DeclaringType; + + private static RenderRequest CreateRequest(RenderRequestOwner owner) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + targetDomain: new Rect(0, 0, 32, 32), + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); + + private static RenderFragmentReference SingleRoot(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } + + private readonly record struct GeometryHitTestIdentityShape( + Guid GeometryId, + int GeometryVersion, + Guid? FillId, + int? FillVersion, + Guid? PenId, + int? PenVersion); + + private readonly record struct ParticleSnapshotIdentityShape(Guid ResourceId, int Version); + + private readonly record struct SceneSnapshotIdentityShape(Guid SceneId, int Version); + + private readonly record struct SceneRuntimeIdentityShape(Guid SceneId, int Version, Rect Bounds); + + private sealed class TwiceBorrowingNode(EngineObject.Resource resource) : RenderNode + { + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + public override void Process(RenderNodeContext context) + { + RenderResource first = context.Borrow(resource); + RenderResource second = context.Borrow(resource); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + s_bounds, + static (session, bounds) => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [EngineResourceIdentityRoutingSlots.First.Bind(first), EngineResourceIdentityRoutingSlots.Second.Bind(second)]); + context.Publish(context.OpaqueSource(description)); + } + } +} + +internal static class EngineResourceIdentityRoutingSlots +{ + internal static readonly RenderResourceSlot First = new(); + internal static readonly RenderResourceSlot Second = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityTests.cs new file mode 100644 index 0000000000..a6c8727583 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityTests.cs @@ -0,0 +1,43 @@ +using Beutl.Engine; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Covers the derivation every CWT-synthesized cache-key helper in the renderer routes through. +/// +/// +/// returns null for a resource that never went through +/// — a shape the public +/// FilterEffectContext.RegisterBrush/RegisterPen entry points accept. +/// +[TestFixture] +public sealed class EngineResourceIdentityTests +{ + private const int Iterations = 20000; + + [Test] + public void ADetachedResource_HasNoBackingObjectId() + { + using var detached = new EngineObject.Resource(); + + Assert.That(detached.GetOriginal(), Is.Null); + } + + + + + + private static long MeasureBytesPerCall(Func read) + { + for (int index = 0; index < 200; index++) + _ = read(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < Iterations; index++) + _ = read(); + long after = GC.GetAllocatedBytesForCurrentThread(); + return (after - before) / Iterations; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ExecutionProbe.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ExecutionProbe.cs new file mode 100644 index 0000000000..1aef507f09 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ExecutionProbe.cs @@ -0,0 +1,32 @@ +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// A stable observation sink a state-passing render callback can carry as part of its identity. +/// +/// +/// A test that counts executions cannot capture a local or a node field: the callback must not capture, and a +/// render node is disposable and so cannot be an identity. One probe instance per node keeps the identity +/// stable across frames while still letting the callback record what happened. +/// +internal sealed class ExecutionProbe +{ + public int Count { get; private set; } + + public void Record() => Count++; +} + +/// The same sink for a callback that observes the live execution session. +internal sealed class SessionProbe(Action? observe) +{ + public void Observe(TSession session) => observe?.Invoke(session); +} + +/// The same sink for a callback that records a value rather than only that it ran. +internal sealed class RecordingProbe +{ + private readonly List _records = []; + + public IReadOnlyList Records => _records; + + public void Record(T value) => _records.Add(value); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs new file mode 100644 index 0000000000..f279fd1aac --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs @@ -0,0 +1,687 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class DeferredCallbackFailureTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + [TestCase(GuardedCanvasViolation.AuthorDispose)] + [TestCase(GuardedCanvasViolation.Snapshot)] + [TestCase(GuardedCanvasViolation.NestedDraw)] + [TestCase(GuardedCanvasViolation.SaveLayer)] + [TestCase(GuardedCanvasViolation.OpacityLayer)] + [TestCase(GuardedCanvasViolation.BlendLayer)] + [TestCase(GuardedCanvasViolation.MaskLayer)] + [TestCase(GuardedCanvasViolation.PaintLayer)] + [TestCase(GuardedCanvasViolation.NativeTarget)] + [TestCase(GuardedCanvasViolation.HiddenAllocation)] + [TestCase(GuardedCanvasViolation.HiddenFlush)] + public void GuardedCallbackCanvas_RejectsAuthorEscapeHatchesWithoutLeakingTargets( + GuardedCanvasViolation violation) + { + using var node = new GuardedCanvasViolationNode(violation); + var factory = new FailureTestTargetFactory(); + using var renderer = FailureTestSupport.CreateRenderer(node, factory, useRenderCache: false); + + Assert.That(() => renderer.Rasterize(), Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(factory.Targets, Has.All.Matches(target => !target.IsDisposed)); + }); + } + + [TestCase(GeometryFailure.UndeclaredInputReadback)] + [TestCase(GeometryFailure.DuplicateInputReadback)] + [TestCase(GeometryFailure.Callback)] + [TestCase(GeometryFailure.InvalidShrink)] + [TestCase(GeometryFailure.SecondCanvasOpen)] + [TestCase(GeometryFailure.UseAfterCanvasClose)] + public void GeometryDeferredPhases_PreserveTheFirstFailureAndInvalidateTheSession( + GeometryFailure failurePoint) + { + using var node = new GeometryFailureNode(failurePoint); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + Type expectedType = failurePoint switch + { + GeometryFailure.InvalidShrink => typeof(ArgumentException), + GeometryFailure.UseAfterCanvasClose => typeof(ObjectDisposedException), + _ => typeof(InvalidOperationException), + }; + Assert.That( + () => renderer.Rasterize(), + failurePoint == GeometryFailure.Callback + ? Throws.TypeOf(expectedType).And.Message.EqualTo("geometry-callback-primary") + : Throws.TypeOf(expectedType)); + + Assert.Multiple(() => + { + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(node.RetainedSession, Is.Not.Null); + Assert.That(() => _ = node.RetainedSession!.OutputBounds, Throws.TypeOf()); + }); + } + + [Test] + public void GeometryOutputAcquisitionFailure_HappensBeforeCallbackAndReturnsPriorTargets() + { + using var node = new GeometryFailureNode(GeometryFailure.Callback); + var factory = new FailureTestTargetFactory(failAt: 2); + using var renderer = FailureTestSupport.CreateRenderer( + node, factory, useRenderCache: false, intent: RenderIntent.Delivery); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("could not allocate")); + Assert.That(node.CallbackEntries, Is.Zero); + Assert.That(factory.CreateCalls, Is.EqualTo(3)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void GeometryOutputAcquisitionFailure_DropsInPreviewWithoutEnteringTheCallback() + { + using var node = new GeometryFailureNode(GeometryFailure.Callback); + var factory = new FailureTestTargetFactory(failAt: 2); + using var renderer = FailureTestSupport.CreateRenderer( + node, factory, useRenderCache: false); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(node.CallbackEntries, Is.Zero); + Assert.That(factory.CreateCalls, Is.EqualTo(3)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void CallbackCanvasOpenFailure_PreservesProviderExceptionAndKeepsTheFacadeOneShot() + { + var primary = new InvalidOperationException("callback-canvas-open-primary"); + var token = new RenderExecutionSessionToken(); + var canvas = new RenderCallbackCanvas( + token, + density: 1, + s_bounds, + () => throw primary, + CallbackCanvasCapability.Draw); + + InvalidOperationException? failure = Assert.Throws( + () => canvas.Use(static _ => { })); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(() => canvas.Use(static _ => { }), Throws.TypeOf()); + }); + token.Complete(); + Assert.That(() => _ = canvas.LogicalBounds, Throws.TypeOf()); + } + + [Test] + public void SessionCompletion_PreservesBodyFailureWhenAnActiveCanvasAlsoFailsCompletion() + { + var primary = new InvalidOperationException("session-body-primary"); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas(target, logicalSize: s_bounds.Size); + token.EnterCanvas(canvas, facade: null); + + InvalidOperationException? failure = Assert.Throws( + () => token.RunAndComplete(() => throw primary)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(token.ThrowIfInactive, Throws.TypeOf()); + }); + } + + [Test] + public void SessionCompletion_SurfacesActiveCanvasFailureWithoutAPrimaryFailure() + { + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas(target, logicalSize: s_bounds.Size); + token.EnterCanvas(canvas, facade: null); + + InvalidOperationException? failure = Assert.Throws( + () => token.RunAndComplete(static () => { })); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("canvas is still active")); + Assert.That(token.ThrowIfInactive, Throws.TypeOf()); + }); + } + + [TestCase(OpaqueTopology.Source)] + [TestCase(OpaqueTopology.Map)] + [TestCase(OpaqueTopology.Combine)] + [TestCase(OpaqueTopology.Expand)] + public void EveryOpaqueTopology_PropagatesItsDeferredCallbackFailure(OpaqueTopology topology) + { + var primary = new InvalidOperationException($"opaque-{topology}"); + using var node = new OpaqueTopologyFailureNode(topology, primary); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(node.FaultingCallbackEntries, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [TestCase(DynamicOutputFailure.MissingRequiredOutput)] + [TestCase(DynamicOutputFailure.ExceedsMaximum)] + [TestCase(DynamicOutputFailure.OutOfDeclaredBounds)] + public void OpaqueDynamicOutputValidation_RejectsInvalidPublicationAtomically( + DynamicOutputFailure failurePoint) + { + using var node = new DynamicOutputFailureNode(failurePoint); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + Exception? failure = Assert.Catch(() => renderer.Rasterize()); + + Type expectedType = failurePoint == DynamicOutputFailure.OutOfDeclaredBounds + ? typeof(ArgumentException) + : typeof(InvalidOperationException); + string expectedMessage = failurePoint switch + { + DynamicOutputFailure.MissingRequiredOutput => "published 0 values outside its declared cardinality [1, 1]", + DynamicOutputFailure.ExceedsMaximum => "published 2 values outside its declared cardinality [0, 1]", + DynamicOutputFailure.OutOfDeclaredBounds => "contained by the declared output bounds", + _ => throw new ArgumentOutOfRangeException(nameof(failurePoint), failurePoint, null), + }; + + Assert.Multiple(() => + { + Assert.That(failure!.GetType(), Is.EqualTo(expectedType)); + Assert.That(failure!.Message, Does.Contain(expectedMessage)); + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(node.ReachedFailurePoint, Is.EqualTo(failurePoint)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void UndeclaredResourceUse_IsRejectedInsideTheRealOpaqueSession() + { + using var node = new UndeclaredResourceNode(); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("was not declared")); + Assert.That(node.Borrowed.DisposeCalls, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void SuccessfulDeferredCallback_SealsSessionInputOutputAndCanvasFacadesAfterReturn() + { + using var node = new RetainedFacadeNode(); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(() => _ = node.Session!.OutputBounds, Throws.TypeOf()); + Assert.That(() => _ = node.Input!.Bounds, Throws.TypeOf()); + Assert.That(() => _ = node.Output!.Bounds, Throws.TypeOf()); + Assert.That(() => _ = node.CanvasFacade!.LogicalBounds, Throws.TypeOf()); + Assert.That(node.ImmediateCanvas, Is.Not.Null); + Assert.That( + () => node.ImmediateCanvas!.Clear(), + Throws.TypeOf()); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [TestCase(HiddenRendererCall.Render, false)] + [TestCase(HiddenRendererCall.Rasterize, false)] + [TestCase(HiddenRendererCall.Measure, false)] + [TestCase(HiddenRendererCall.HitTest, false)] + [TestCase(HiddenRendererCall.Render, true)] + [TestCase(HiddenRendererCall.Rasterize, true)] + [TestCase(HiddenRendererCall.Measure, true)] + [TestCase(HiddenRendererCall.HitTest, true)] + public void DeferredCallback_RejectsHiddenRendererLaunchAndClearsTheGuard( + HiddenRendererCall call, + bool constructBeforeCallback) + { + using var hiddenRoot = new RectangleRenderNode(s_bounds, Brushes.Resource.White, null); + using var preconstructed = constructBeforeCallback ? CreateHiddenRenderer(hiddenRoot) : null; + using var node = new HiddenRendererLaunchNode(hiddenRoot, preconstructed, call); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("cannot be launched")); + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + + using RenderNodeRenderer outside = preconstructed ?? CreateHiddenRenderer(hiddenRoot); + Assert.That(() => outside.Measure(), Throws.Nothing, + "The execution-callback guard must be cleared even when the callback fails."); + } + + private static RenderNodeRenderer CreateHiddenRenderer(RenderNode root) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + public enum GuardedCanvasViolation + { + AuthorDispose, + Snapshot, + NestedDraw, + SaveLayer, + OpacityLayer, + BlendLayer, + MaskLayer, + PaintLayer, + NativeTarget, + HiddenAllocation, + HiddenFlush, + } + + public enum GeometryFailure + { + UndeclaredInputReadback, + DuplicateInputReadback, + Callback, + InvalidShrink, + SecondCanvasOpen, + UseAfterCanvasClose, + } + + public enum OpaqueTopology + { + Source, + Map, + Combine, + Expand, + } + + public enum DynamicOutputFailure + { + MissingRequiredOutput, + ExceedsMaximum, + OutOfDeclaredBounds, + } + + public enum HiddenRendererCall + { + Render, + Rasterize, + Measure, + HitTest, + } + + private sealed class HiddenRendererLaunchNode( + RenderNode hiddenRoot, + RenderNodeRenderer? preconstructed, + HiddenRendererCall call) : RenderNode + { + public int CallbackEntries { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + ExecuteDeferred))); + } + + private void ExecuteDeferred(OpaqueRenderSession session) + { + CallbackEntries++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => + { + RenderNodeRenderer renderer = preconstructed ?? CreateHiddenRenderer(hiddenRoot); + try + { + switch (call) + { + case HiddenRendererCall.Render: + renderer.Render(canvas); + break; + case HiddenRendererCall.Rasterize: + using (renderer.Rasterize()) + { + } + break; + case HiddenRendererCall.Measure: + _ = renderer.Measure(); + break; + case HiddenRendererCall.HitTest: + _ = renderer.HitTest(new Point(1, 1)); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + finally + { + if (preconstructed is null) + renderer.Dispose(); + } + }); + session.Publish(output); + } + } + + private sealed class GuardedCanvasViolationNode(GuardedCanvasViolation violation) : RenderNode + { + public int CallbackEntries { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + CallbackEntries++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + using SKSurface hiddenSurface = SKSurface.Create(new SKImageInfo(1, 1)); + output.Canvas.Use(canvas => InvokeViolation(canvas, hiddenSurface)); + session.Publish(output); + }))); + } + + + private void InvokeViolation(ImmediateCanvas canvas, SKSurface hiddenSurface) + { + switch (violation) + { + case GuardedCanvasViolation.AuthorDispose: + canvas.Dispose(); + break; + case GuardedCanvasViolation.Snapshot: + _ = canvas.Snapshot(); + break; + case GuardedCanvasViolation.NestedDraw: + canvas.DrawNode(this); + break; + case GuardedCanvasViolation.SaveLayer: + canvas.PushLayer().Dispose(); + break; + case GuardedCanvasViolation.OpacityLayer: + canvas.PushOpacity(0.5f).Dispose(); + break; + case GuardedCanvasViolation.BlendLayer: + canvas.PushBlendMode(BlendMode.Multiply).Dispose(); + break; + case GuardedCanvasViolation.MaskLayer: + canvas.PushOpacityMask(Brushes.Resource.White, s_bounds).Dispose(); + break; + case GuardedCanvasViolation.PaintLayer: + using (var paint = new SKPaint()) + canvas.PushPaint(paint).Dispose(); + break; + case GuardedCanvasViolation.NativeTarget: + using (RenderTarget.GetRenderTarget(canvas)) + { + } + break; + case GuardedCanvasViolation.HiddenAllocation: + using (canvas.CreateExecutionView()) + { + } + break; + case GuardedCanvasViolation.HiddenFlush: + canvas.DrawSurface(hiddenSurface, default); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private sealed class GeometryFailureNode(GeometryFailure failurePoint) : RenderNode + { + public int CallbackEntries { get; private set; } + + public GeometrySession? RetainedSession { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription()); + bool readback = failurePoint == GeometryFailure.DuplicateInputReadback; + GeometryDescription description = GeometryDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + RetainedSession = session; + switch (failurePoint) + { + case GeometryFailure.UndeclaredInputReadback: + session.Input.UseSnapshot(static _ => { }); + break; + case GeometryFailure.DuplicateInputReadback: + session.Input.UseSnapshot(static _ => { }); + session.Input.UseSnapshot(static _ => { }); + break; + case GeometryFailure.Callback: + throw new InvalidOperationException("geometry-callback-primary"); + case GeometryFailure.InvalidShrink: + session.SetOutputBounds(new Rect(-1, -1, 12, 12)); + break; + case GeometryFailure.SecondCanvasOpen: + session.Canvas.Use(static canvas => canvas.Clear(Colors.Red)); + session.Canvas.Use(static canvas => canvas.Clear(Colors.Blue)); + break; + case GeometryFailure.UseAfterCanvasClose: + { + ImmediateCanvas? retained = null; + session.Canvas.Use(canvas => retained = canvas); + retained!.Clear(); + break; + } + default: + throw new ArgumentOutOfRangeException(); + } + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: readback); + context.Publish(context.Geometry(source, description)); + } + } + + private sealed class OpaqueTopologyFailureNode( + OpaqueTopology topology, + InvalidOperationException failure) : RenderNode + { + public int FaultingCallbackEntries { get; private set; } + + public override void Process(RenderNodeContext context) + { + void Fail(OpaqueRenderSession _) + { + FaultingCallbackEntries++; + throw failure; + } + + if (topology == OpaqueTopology.Source) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + Fail, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale))); + return; + } + + RenderFragmentHandle first = context.OpaqueSource(FailureTestSupport.SourceDescription()); + if (topology == OpaqueTopology.Map) + { + OpaqueRenderDescription map = OpaqueRenderDescription.CreateRequestLocal( + Fail, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply); + context.Publish(context.OpaqueMap(first, map)); + return; + } + + RenderFragmentHandle second = context.OpaqueSource(FailureTestSupport.SourceDescription()); + OpaqueRenderDescription many = OpaqueRenderDescription.CreateRequestLocal( + Fail, + OpaqueRenderBoundsContract.FullInputs( + static bounds => bounds.Aggregate(default(Rect), static (result, value) => result.Union(value))), + RenderHitTestContract.AnyInput, + topology == OpaqueTopology.Combine ? RenderValueCardinality.Single : RenderValueCardinality.Dynamic, + RenderScaleContract.Vector); + RenderFragmentHandle output = topology == OpaqueTopology.Combine + ? context.OpaqueCombine([first, second], many) + : context.OpaqueExpand([first, second], many); + context.Publish(output); + } + } + + private sealed class DynamicOutputFailureNode(DynamicOutputFailure failurePoint) : RenderNode + { + public int CallbackEntries { get; private set; } + + public DynamicOutputFailure? ReachedFailurePoint { get; private set; } + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + ReachedFailurePoint = failurePoint; + switch (failurePoint) + { + case DynamicOutputFailure.MissingRequiredOutput: + return; + case DynamicOutputFailure.ExceedsMaximum: + { + using OpaqueRenderOutput first = session.CreateOutput(new Rect(0, 0, 4, 8)); + using OpaqueRenderOutput second = session.CreateOutput(new Rect(4, 0, 4, 8)); + session.Publish(first); + session.Publish(second); + return; + } + case DynamicOutputFailure.OutOfDeclaredBounds: + using (session.CreateOutput(new Rect(-1, 0, 9, 8))) + { + } + return; + default: + throw new ArgumentOutOfRangeException(); + } + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + failurePoint == DynamicOutputFailure.ExceedsMaximum + ? RenderValueCardinality.Range(0, 1) + : RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class UndeclaredResourceNode : RenderNode + { + public FailureTestDisposable Borrowed { get; } = new(); + + public override void Process(RenderNodeContext context) + { + RenderResource borrowed = context.Borrow( + Borrowed); + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => session.UseResource(borrowed, static _ => { }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class RetainedFacadeNode : RenderNode + { + public OpaqueRenderSession? Session { get; private set; } + + public RenderExecutionInput? Input { get; private set; } + + public OpaqueRenderOutput? Output { get; private set; } + + public RenderCallbackCanvas? CanvasFacade { get; private set; } + + public ImmediateCanvas? ImmediateCanvas { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription()); + OpaqueRenderDescription map = OpaqueRenderDescription.CreateRequestLocal( + session => + { + Session = session; + Input = session.Inputs.Single(); + OpaqueRenderOutput output = session.CreateOutput(s_bounds); + Output = output; + CanvasFacade = output.Canvas; + output.Canvas.Use(canvas => + { + ImmediateCanvas = canvas; + session.Inputs.Single().Draw(canvas); + }); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply); + context.Publish(context.OpaqueMap(source, map)); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/GeometrySessionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/GeometrySessionTests.cs new file mode 100644 index 0000000000..f6e592865d --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/GeometrySessionTests.cs @@ -0,0 +1,177 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class GeometrySessionTests +{ + + + [Test] + public void Create_RejectsACapturingCallbackAndNamesTheStateParameter() + { + var color = Colors.Red; + ArgumentException? rejection = Assert.Throws( + () => GeometryDescription.Create( + "under-specified", + (session, _) => session.Canvas.Use(canvas => canvas.Clear(color)), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + + Assert.Multiple(() => + { + Assert.That(rejection!.ParamName, Is.EqualTo("render")); + Assert.That(rejection.Message, Does.Contain("state")); + }); + } + + private static void RenderNothing(GeometrySession session, (string Kind, int Value) state) + { + } + + [Test] + public void Description_StructuralIdentityUsesFullValueEquality() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource firstObject = registry.RegisterBorrowed(new object()); + RenderResource firstString = registry.RegisterBorrowed(new string('a', 1)); + RenderResource secondObject = registry.RegisterBorrowed(new object()); + RenderResource secondString = registry.RegisterBorrowed(new string('b', 1)); + + GeometryDescription first = CreateDescription( + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: true, + resources: [GeometrySessionSlots.Object.Bind(firstObject), GeometrySessionSlots.Text.Bind(firstString)]); + GeometryDescription equal = CreateDescription( + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: true, + resources: [GeometrySessionSlots.Object.Bind(secondObject), GeometrySessionSlots.Text.Bind(secondString)]); + GeometryDescription differentBounds = CreateDescription( + RenderBoundsContract.FullInput, + RenderHitTestContract.AnyInput, + requiresReadback: true, + resources: [GeometrySessionSlots.Object.Bind(secondObject), GeometrySessionSlots.Text.Bind(secondString)]); + GeometryDescription differentHitTest = CreateDescription( + RenderBoundsContract.Identity, + RenderHitTestContract.None, + requiresReadback: true, + resources: [GeometrySessionSlots.Object.Bind(secondObject), GeometrySessionSlots.Text.Bind(secondString)]); + GeometryDescription differentReadback = CreateDescription( + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: false, + resources: [GeometrySessionSlots.Object.Bind(secondObject), GeometrySessionSlots.Text.Bind(secondString)]); + GeometryDescription differentResourceOrder = CreateDescription( + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: true, + resources: [GeometrySessionSlots.Text.Bind(secondString), GeometrySessionSlots.Object.Bind(secondObject)]); + + Assert.Multiple(() => + { + Assert.That(first.StructuralIdentity, Is.EqualTo(equal.StructuralIdentity)); + Assert.That( + first.StructuralIdentity.GetHashCode(), + Is.EqualTo(equal.StructuralIdentity.GetHashCode())); + Assert.That(first.StructuralIdentity, Is.Not.EqualTo(differentBounds.StructuralIdentity)); + Assert.That(first.StructuralIdentity, Is.Not.EqualTo(differentHitTest.StructuralIdentity)); + Assert.That(first.StructuralIdentity, Is.Not.EqualTo(differentReadback.StructuralIdentity)); + Assert.That(first.StructuralIdentity, Is.Not.EqualTo(differentResourceOrder.StructuralIdentity)); + }); + + static GeometryDescription CreateDescription( + RenderBoundsContract bounds, + RenderHitTestContract hitTest, + bool requiresReadback, + IEnumerable resources) + { + return GeometryDescription.CreateRequestLocal( + static _ => { }, + bounds, + hitTest, + requiresReadback: requiresReadback, + resources: resources); + } + } + + + [Test] + public void Session_AllowsOnlyContainedShrinkAndDiscardWins() + { + Rect allocated = new(10, 20, 30, 40); + GeometrySession session = CreateSession(allocated, out RenderExecutionSessionToken token, out RenderTarget target); + try + { + var shrink = new Rect(12, 23, 8, 9); + session.SetOutputBounds(shrink); + Assert.That(session.OutputBounds, Is.EqualTo(shrink)); + Assert.That( + () => session.SetOutputBounds(new Rect(0, 0, 100, 100)), + Throws.TypeOf()); + + session.DiscardOutput(); + session.SetOutputBounds(new Rect(13, 24, 1, 1)); + Assert.Multiple(() => + { + Assert.That(session.IsOutputDiscarded, Is.True); + Assert.That(session.OutputBounds, Is.EqualTo(new Rect(13, 24, 1, 1))); + }); + } + finally + { + token.Complete(); + target.Dispose(); + } + } + + private static GeometrySession CreateSession( + Rect bounds, + out RenderExecutionSessionToken token, + out RenderTarget target) + { + token = new RenderExecutionSessionToken(); + var input = new RenderExecutionInput( + token, + bounds, + EffectiveScale.At(1), + static (_, _, _, _) => { }, + static (_, _) => { }, + createShader: null, + createSnapshot: null, + readbackDeclared: false); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + RenderTarget outputTarget = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + target = outputTarget; + var canvas = new RenderCallbackCanvas( + token, + density: 1, + bounds, + () => new ImmediateCanvas(outputTarget, 1, float.PositiveInfinity, bounds.Size), + CallbackCanvasCapability.Draw); + return new GeometrySession( + token, + input, + bounds, + bounds, + deviceBounds, + outputScale: 1, + workingScale: 1, + maxWorkingScale: float.PositiveInfinity, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + canvas, + []); + } +} + +internal static class GeometrySessionSlots +{ + internal static readonly RenderResourceSlot Geometry = new(); + internal static readonly RenderResourceSlot Object = new(); + internal static readonly RenderResourceSlot Text = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs new file mode 100644 index 0000000000..755b59e80a --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs @@ -0,0 +1,1359 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class NestedTargetAndCleanupFailureTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + [TestCase(TargetCallbackFailure.CommandCallback)] + [TestCase(TargetCallbackFailure.UndeclaredTargetReadback)] + [TestCase(TargetCallbackFailure.MissingTargetReadback)] + [TestCase(TargetCallbackFailure.UndeclaredInputReadback)] + [TestCase(TargetCallbackFailure.DuplicateInputReadback)] + [TestCase(TargetCallbackFailure.ScopeCallback)] + [TestCase(TargetCallbackFailure.ScopeMissingReplay)] + [TestCase(TargetCallbackFailure.ScopeDoubleReplay)] + [TestCase(TargetCallbackFailure.RawCommandCallback)] + [TestCase(TargetCallbackFailure.RawScopeCallback)] + [TestCase(TargetCallbackFailure.RawScopeMissingReplay)] + [TestCase(TargetCallbackFailure.RawScopeDoubleReplay)] + public void TargetCommandScopeAndRawFailures_DischargeAllStateAndSealTheSession( + TargetCallbackFailure failurePoint) + { + using var node = new TargetCallbackFailureNode(failurePoint); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain(ExpectedTargetCallbackFailureMessage(failurePoint))); + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(node.VerifyRetainedSession is not null, Is.True); + }); + Action verifyRetainedSession = node.VerifyRetainedSession + ?? throw new AssertionException("The deferred callback did not expose its retained-session probe."); + Assert.That(verifyRetainedSession, Throws.TypeOf()); + } + + private static string ExpectedTargetCallbackFailureMessage(TargetCallbackFailure failurePoint) + => failurePoint switch + { + TargetCallbackFailure.CommandCallback => "target-command-primary", + TargetCallbackFailure.UndeclaredTargetReadback => "did not declare target readback", + TargetCallbackFailure.MissingTargetReadback => "consume its snapshot exactly once", + TargetCallbackFailure.UndeclaredInputReadback => "CPU readback was not declared", + TargetCallbackFailure.DuplicateInputReadback => "snapshot is a one-shot lease", + TargetCallbackFailure.ScopeCallback => "target-scope-primary", + TargetCallbackFailure.ScopeMissingReplay or TargetCallbackFailure.ScopeDoubleReplay + => "A target scope input must be replayed exactly once", + TargetCallbackFailure.RawCommandCallback => "raw-command-primary", + TargetCallbackFailure.RawScopeCallback => "raw-scope-primary", + TargetCallbackFailure.RawScopeMissingReplay or TargetCallbackFailure.RawScopeDoubleReplay + => "A raw target scope input must be replayed exactly once", + _ => throw new ArgumentOutOfRangeException(nameof(failurePoint), failurePoint, null), + }; + + [Test] + public void TargetCaptureAllocationFailure_DischargesTheRootAndPublishesNoCapture() + { + using var node = new TargetCaptureNode(); + var factory = new FailureTestTargetFactory(failAt: 1); + using var renderer = FailureTestSupport.CreateRenderer(node, factory, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("could not allocate")); + Assert.That(factory.CreateCalls, Is.EqualTo(2)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [TestCase(TransientMaterializationKind.Layer)] + [TestCase(TransientMaterializationKind.TargetLayerScope)] + public void TransientMaterializationFailure_ReleasesItsValueBeforePropagating( + TransientMaterializationKind kind) + { + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using var node = new TransientMaterializationFailureNode( + kind, + () => registry.Statistics.LeasedTargets); + using RenderRequest request = FailureTestSupport.CreateFrameRequest(useRenderCache: false); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using var canvas = new ImmediateCanvas(destination); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(node.PrimaryFailure)); + Assert.That(node.LeasedTargetsObservedBySource, Is.EqualTo(1), + "The failure must occur after the transient target has been acquired."); + Assert.That(node.LeasedTargetsObservedByCaller, Is.Zero, + "The transient target must be released before control returns to the replaying caller."); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void TargetCapturePostAllocationFailure_ReleasesItsValueBeforePropagating() + { + var primary = new InvalidOperationException("target-capture-post-allocation"); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using var node = new TargetCapturePostAllocationFailureNode( + primary, + () => registry.Statistics.LeasedTargets); + using RenderRequest request = FailureTestSupport.CreateFrameRequest(useRenderCache: false); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using var canvas = new ImmediateCanvas(destination); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + int? leasedTargetsObservedByHook = null; + var executor = new RenderRequestExecutor( + targets, + afterCaptureAllocation: kind => + { + if (kind == RenderFragmentKind.TargetCapture) + { + leasedTargetsObservedByHook = registry.Statistics.LeasedTargets; + throw primary; + } + }); + + InvalidOperationException? failure = Assert.Throws( + () => executor.Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(leasedTargetsObservedByHook, Is.EqualTo(1)); + Assert.That(node.LeasedTargetsObservedByCaller, Is.Zero, + "The capture target must be released before control returns to the replaying caller."); + Assert.That(registry.Statistics.Creates, Is.EqualTo(1)); + Assert.That(registry.Statistics.PeakLiveTargets, Is.EqualTo(1)); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void Graphics3DBackendBoundaryFailure_RemainsPrimaryAndPublishesNoOutput() + { + var primary = new InvalidOperationException("graphics3d-backend-primary"); + using var node = new BackendBoundaryFailureNode(primary); + using var renderer = FailureTestSupport.CreateRenderer(node, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(node.ExecuteCalls, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void NestedChildRegionAnalysisFailure_FailsTheFamilyBeforeAllocationAndPreservesThePrimary() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Enabled, + owner: owner); + using var child = new NestedRegionAnalysisFailureNode(); + using var parent = new NestedPlanningFailureParentNode(child); + child.Cache.RecordStableRequests(); + parent.Cache.RecordStableRequests(); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderRequest nested = graph.NestedRequests.Single(); + var factory = new FailureTestTargetFactory(); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws(() => + { + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + new RenderRequestExecutor(targets).Execute(compiled, canvas); + }); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(NestedRegionAnalysisFailureNode.PrimaryFailure)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(failure)); + Assert.That(owner.SecondaryFailures, Is.Empty); + Assert.That(owner.CleanupFailures, Is.Empty); + Assert.That(owner.IsCleanedUp, Is.True); + Assert.That(nested.Request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(child.ExecuteCalls, Is.Zero); + Assert.That(parent.ExecuteCalls, Is.Zero); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(registry.Statistics.OwnedTargets, Is.Zero); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + Assert.That(child.Cache.IsCached, Is.False); + Assert.That(parent.Cache.IsCached, Is.False); + }); + } + + [Test] + public void NestedRequest_ExecutesAndCompletesWithinTheParentRequestFamily() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + using var child = new NestedChildNode(); + using var parent = new NestedParentNode(child); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderRequest nested = graph.NestedRequests.Single(); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + new RenderRequestExecutor(targets).Execute(compiled, canvas); + + Assert.Multiple(() => + { + Assert.That(child.ExecuteCalls, Is.EqualTo(1), + "A declared separate-target nested request must execute as part of the parent plan."); + Assert.That(nested.Request.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void NestedTarget_ParentReadsThePreparedFullDomainWithShiftedChildPixels() + { + var fullDomain = new Rect(0, 0, 10, 7); + var childBounds = new Rect(2, 1, 5, 4); + using var child = new ShiftedNestedChildNode(childBounds); + using var parent = new NestedOutputConsumerNode(child, fullDomain); + using var renderer = new RenderNodeRenderer( + parent, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = fullDomain, + RequestedRegion = fullDomain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The parent did not publish its nested target output."); + var inside = bitmap.SKBitmap.GetPixel(4, 3); + var outside = bitmap.SKBitmap.GetPixel(0, 0); + + Assert.Multiple(() => + { + Assert.That(inside.Red, Is.GreaterThan(240)); + Assert.That(inside.Alpha, Is.GreaterThan(240)); + Assert.That(outside.Alpha, Is.Zero, + "The child must preserve its shifted origin inside the full transparent target domain."); + Assert.That(parent.NestedTarget, Is.Not.Null); + Assert.That(parent.NestedTarget!.Target.LogicalBounds, Is.EqualTo(fullDomain)); + Assert.That(parent.NestedTarget.Target.DeviceBounds, Is.EqualTo(new PixelRect(0, 0, 10, 7))); + Assert.That(parent.NestedTarget.Target.IsDisposed, Is.True, + "The prepared child lease must remain live through the parent callback and discharge afterward."); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void NestedTarget_MetadataQueriesRecurseWithoutExecutingOrAllocating() + { + using var child = new NestedChildNode(); + using var parent = new NestedParentNode(child); + var factory = new FailureTestTargetFactory(); + using var renderer = new RenderNodeRenderer( + parent, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + bool hit = renderer.HitTest(new Point(1, 1)); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(hit, Is.True); + Assert.That(child.ExecuteCalls, Is.Zero); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.OwnedTargets, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void NestedChildExecutionFailure_FailsTheWholeFamilyWithOnePrimaryAndSkipsParentGpuWork() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + var primary = new InvalidOperationException("nested-child-primary"); + var executionOrder = new List(); + using var child = new NestedExecutionNode("child", executionOrder, failure: primary); + using var parent = new NestedExecutionNode( + "parent", + executionOrder, + nestedRoot: child, + parentOptions: options); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget nested = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested request."); + using RenderRequest nestedRequest = nested.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + var factory = new FailureTestTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + Assert.That(owner.SecondaryFailures, Is.Empty, + "Propagating one child failure to the parent must not record it twice."); + Assert.That(executionOrder, Is.EqualTo(new[] { "child" })); + Assert.That(child.ExecuteCalls, Is.EqualTo(1)); + Assert.That(parent.ExecuteCalls, Is.Zero, + "Parent GPU work must not start after a nested child has failed."); + Assert.That(factory.CreateCalls, Is.GreaterThan(0), + "The child must fail after acquiring execution storage so lease cleanup is exercised."); + Assert.That(nestedRequest.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(owner.IsCleanedUp, Is.True); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + Assert.That(parent.NestedRequest!.Target.IsDisposed, Is.True, + "Family failure must reject and discharge the staged child target."); + }); + } + + // FR-039 keeps main's allocation-failure outcome: a declined nested target left the 3D scene untextured + // inside an otherwise complete preview frame. Delivery still fails fast. + [Test] + public void NestedTargetAllocationFailureInPreview_DropsTheNestedValueAndStillRendersTheParent() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + var executionOrder = new List(); + using var child = new NestedExecutionNode("child", executionOrder); + using var parent = new NestedExecutionNode( + "parent", + executionOrder, + nestedRoot: child, + parentOptions: options); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget nested = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested request."); + using RenderRequest nestedRequest = nested.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + var factory = new FailureTestTargetFactory(failAt: 0); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + Assert.DoesNotThrow(() => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(owner.PrimaryFailure, Is.Null); + Assert.That(executionOrder, Is.EqualTo(new[] { "parent" }), + "The nested subtree is dropped; the parent still renders."); + Assert.That(child.ExecuteCalls, Is.Zero); + Assert.That(parent.ExecuteCalls, Is.EqualTo(1)); + Assert.That(nested.Target.IsReady, Is.False); + Assert.That(nestedRequest.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void NestedTargetAllocationFailureInDelivery_StillFailsFast() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + var executionOrder = new List(); + using var child = new NestedExecutionNode("child", executionOrder); + using var parent = new NestedExecutionNode( + "parent", + executionOrder, + nestedRoot: child, + parentOptions: options); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget nested = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested request."); + using RenderRequest nestedRequest = nested.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + var factory = new FailureTestTargetFactory(failAt: 0); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Delivery, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("could not allocate")); + Assert.That(parent.ExecuteCalls, Is.Zero); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + }); + } + + [Test] + public void ParentFailureAfterNestedCacheStaging_RejectsEveryFamilyCaptureAndFailsBothRequests() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Enabled, + owner: owner); + var primary = new InvalidOperationException("nested-parent-primary"); + var executionOrder = new List(); + using var child = new NestedExecutionNode("child-cache", executionOrder); + child.Cache.RecordStableRequests(); + var parentCache = new NestedExecutionNode("parent-cache", executionOrder); + parentCache.Cache.RecordStableRequests(); + var parentFailure = new NestedExecutionNode("parent-failure", executionOrder, failure: primary); + using var parent = new NestedContainerNode(child, options, parentCache, parentFailure); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget nested = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested request."); + using RenderRequest nestedRequest = nested.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler( + renderCacheContext: FailureTestSupport.CacheResolutionContext).Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + Assert.That(owner.SecondaryFailures, Is.Empty, + "A successful child body is not a secondary failure when its family later aborts."); + Assert.That( + executionOrder, + Is.EqualTo(new[] { "child-cache", "parent-cache", "parent-failure" })); + Assert.That(child.ExecuteCalls, Is.EqualTo(1)); + Assert.That(parentCache.ExecuteCalls, Is.EqualTo(1)); + Assert.That(parentFailure.ExecuteCalls, Is.EqualTo(1)); + Assert.That(nestedRequest.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(child.Cache.IsCached, Is.False, + "A nested capture must remain staged until the whole family commits."); + Assert.That(parentCache.Cache.IsCached, Is.False, + "A parent capture must not publish when later parent execution fails."); + Assert.That(owner.IsCleanedUp, Is.True); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void TwoLevelNestedRequests_ExecuteDepthFirstAndCompleteEveryRequest() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + var executionOrder = new List(); + using var grandchild = new NestedExecutionNode("grandchild", executionOrder); + using var child = new NestedExecutionNode( + "child", + executionOrder, + nestedRoot: grandchild, + parentOptions: options); + using var parent = new NestedExecutionNode( + "parent", + executionOrder, + nestedRoot: child, + parentOptions: options); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget childRecording = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested child request."); + RecordedNestedRenderTarget grandchildRecording = child.NestedRequest + ?? throw new AssertionException("The child did not record its nested grandchild request."); + using RenderRequest childRequest = childRecording.Request; + using RenderRequest grandchildRequest = grandchildRecording.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + new RenderRequestExecutor(targets).Execute(compiled, canvas); + + Assert.Multiple(() => + { + Assert.That(executionOrder, Is.EqualTo(new[] { "grandchild", "child", "parent" })); + Assert.That(grandchild.ExecuteCalls, Is.EqualTo(1)); + Assert.That(child.ExecuteCalls, Is.EqualTo(1)); + Assert.That(parent.ExecuteCalls, Is.EqualTo(1)); + Assert.That(grandchildRequest.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(childRequest.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(owner.PrimaryFailure, Is.Null); + Assert.That(owner.IsCleanedUp, Is.True); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void ExecutionPrimary_SurvivesCleanupFaultAndCleanupContinuesInStrictLifoOrder() + { + var order = new List(); + var primary = new InvalidOperationException("execution-primary"); + var cleanup = new InvalidOperationException("cleanup-secondary"); + using var node = new PrimaryAndCleanupFailureNode(order, primary, cleanup); + using var renderer = FailureTestSupport.CreateRenderer( + node, + useRenderCache: true, + purpose: RenderRequestPurpose.Frame); + node.Cache.RecordStableRequests(); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(order, Is.EqualTo(new[] { "last", "throwing", "first" })); + Assert.That(node.Resources, Has.All.Matches(resource => resource.DisposeCalls == 1)); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void LaterExecutionFailure_RejectsAnEarlierStagedCacheCaptureWithoutPartialPublication() + { + using var successful = new CacheableChildNode(throwOnExecute: false); + using var faulting = new CacheableChildNode(throwOnExecute: true); + successful.Cache.RecordStableRequests(); + faulting.Cache.RecordStableRequests(); + using var root = new ContainerRenderNode(); + root.AddChild(successful); + root.AddChild(faulting); + using var renderer = FailureTestSupport.CreateRenderer( + root, + useRenderCache: true, + purpose: RenderRequestPurpose.Frame); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("later-cache-candidate-failure")); + Assert.That(successful.ExecuteCalls, Is.EqualTo(1)); + Assert.That(faulting.ExecuteCalls, Is.EqualTo(1)); + Assert.That(successful.Cache.IsCached, Is.False); + Assert.That(faulting.Cache.IsCached, Is.False); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + + root.RemoveChild(successful); + root.RemoveChild(faulting); + } + + [Test] + public void CleanupFaultBeforeCachePublication_RejectsCaptureAndDischargesEveryResource() + { + var order = new List(); + var cleanup = new InvalidOperationException("pre-publication-cleanup"); + using var node = new CleanupFailureCacheableNode(order, cleanup); + node.Cache.RecordStableRequests(); + using var renderer = FailureTestSupport.CreateRenderer( + node, + useRenderCache: true, + purpose: RenderRequestPurpose.Frame); + + AggregateException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Flatten().InnerExceptions, Does.Contain(cleanup)); + Assert.That(order, Is.EqualTo(new[] { "last", "throwing", "first" })); + Assert.That(node.Resources, Has.All.Matches(resource => resource.DisposeCalls == 1)); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void AtomicCacheTransfer_CommitsTheWholeReplacementBeforeOldStorageCleanupFailure() + { + var oldDisposalFailure = new InvalidOperationException("old-cache-dispose-primary"); + using var node = new CacheableChildNode(throwOnExecute: false); + using (var seedRequest = FailureTestSupport.CreateFrameRequest(useRenderCache: false)) + { + RecordedRenderGraph seedGraph = new RenderRequestRecorder(seedRequest).Record(node); + RenderFragmentReference seedRoot = RenderRequestCompiler.ResolveRoots(seedGraph).Single(); + var seedIdentity = new RenderOutputCacheIdentity( + "deliberately-stale-cache-identity", + RenderFragmentOutputIdentity.Create(seedRoot, seedGraph.RequestId), + s_bounds, + RequiredRegion.Region(s_bounds), + density: 1, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity("stale-device", "stale-context")); + RenderNodeCache.PublishAtomically( + [ + new RenderNodeCachePublication( + node.Cache, + seedIdentity, + [new RenderNodeCachedValue( + new FailureTestRenderTarget(new PixelSize(8, 8), oldDisposalFailure), + s_bounds, + EffectiveScale.At(1))]), + ]); + } + node.Cache.RecordStableRequests(); + using var renderer = FailureTestSupport.CreateRenderer( + node, + useRenderCache: true, + purpose: RenderRequestPurpose.Frame); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(oldDisposalFailure)); + Assert.That(node.ExecuteCalls, Is.EqualTo(1)); + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.CacheCount, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void NestedPostCommitCleanupFailure_ReconcilesEveryFamilyDiagnosticScope() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Enabled, + owner: owner); + var oldDisposalFailure = new InvalidOperationException("nested-old-cache-dispose-primary"); + var executionOrder = new List(); + using var child = new NestedExecutionNode("child-cache-cleanup", executionOrder); + SeedThrowingStaleCache(child, oldDisposalFailure); + child.Cache.RecordStableRequests(); + using var parent = new NestedExecutionNode( + "parent-cache-cleanup", + executionOrder, + nestedRoot: child, + parentOptions: options); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(parent); + RecordedNestedRenderTarget nested = parent.NestedRequest + ?? throw new AssertionException("The parent did not record its nested request."); + using RenderRequest nestedRequest = nested.Request; + using CompiledRenderRequest compiled = new RenderRequestCompiler( + renderCacheContext: FailureTestSupport.CacheResolutionContext).Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(oldDisposalFailure)); + Assert.That(child.Cache.IsCached, Is.True); + }); + } + + private static void SeedThrowingStaleCache(RenderNode node, Exception disposalFailure) + { + using var seedRequest = FailureTestSupport.CreateFrameRequest(useRenderCache: false); + RecordedRenderGraph seedGraph = new RenderRequestRecorder(seedRequest).Record(node); + RenderFragmentReference seedRoot = RenderRequestCompiler.ResolveRoots(seedGraph).Single(); + var seedIdentity = new RenderOutputCacheIdentity( + "nested-deliberately-stale-cache-identity", + RenderFragmentOutputIdentity.Create(seedRoot, seedGraph.RequestId), + s_bounds, + RequiredRegion.Region(s_bounds), + density: 1, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity("nested-stale-device", "nested-stale-context")); + RenderNodeCache.PublishAtomically( + [ + new RenderNodeCachePublication( + node.Cache, + seedIdentity, + [new RenderNodeCachedValue( + new FailureTestRenderTarget(new PixelSize(8, 8), disposalFailure), + s_bounds, + EffectiveScale.At(1))]), + ]); + } + + public enum TargetCallbackFailure + { + CommandCallback, + UndeclaredTargetReadback, + MissingTargetReadback, + UndeclaredInputReadback, + DuplicateInputReadback, + ScopeCallback, + ScopeMissingReplay, + ScopeDoubleReplay, + RawCommandCallback, + RawScopeCallback, + RawScopeMissingReplay, + RawScopeDoubleReplay, + } + + public enum TransientMaterializationKind + { + Layer, + TargetLayerScope, + } + + private sealed class TransientMaterializationFailureNode( + TransientMaterializationKind kind, + Func getLeasedTargetCount) : RenderNode + { + public InvalidOperationException PrimaryFailure { get; } = + new($"transient-{kind}-failure"); + + public int? LeasedTargetsObservedBySource { get; private set; } + + public int? LeasedTargetsObservedByCaller { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription( + execute: _ => + { + LeasedTargetsObservedBySource = getLeasedTargetCount(); + throw PrimaryFailure; + })); + RenderFragmentHandle transient = kind switch + { + TransientMaterializationKind.Layer => context.Layer([source], s_bounds), + TransientMaterializationKind.TargetLayerScope => context.TargetLayerScope( + [source], + TargetRegion.Region(s_bounds)), + _ => throw new ArgumentOutOfRangeException(), + }; + TargetScopeDescription caller = TargetScopeDescription.CreateRequestLocal( + session => + { + try + { + session.Canvas.Use(_ => session.ReplayInput()); + } + catch (InvalidOperationException ex) when (ReferenceEquals(ex, PrimaryFailure)) + { + LeasedTargetsObservedByCaller = getLeasedTargetCount(); + throw; + } + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent); + context.Publish(context.TargetScope(transient, caller)); + } + } + + private sealed class TargetCapturePostAllocationFailureNode( + InvalidOperationException primaryFailure, + Func getLeasedTargetCount) : RenderNode + { + public int? LeasedTargetsObservedByCaller { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.ContributeValues(context.TargetCapture( + TargetCaptureDescription.Create( + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale))); + TargetScopeDescription caller = TargetScopeDescription.CreateRequestLocal( + session => session.Canvas.Use(_ => + { + try + { + session.ReplayInput(); + } + catch (InvalidOperationException ex) when (ReferenceEquals(ex, primaryFailure)) + { + LeasedTargetsObservedByCaller = getLeasedTargetCount(); + throw; + } + }), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent); + context.Publish(context.TargetScope(capture, caller)); + } + } + + private sealed class TargetCallbackFailureNode(TargetCallbackFailure failurePoint) : RenderNode + { + public int CallbackEntries { get; private set; } + + public Action? VerifyRetainedSession { get; private set; } + + public override void Process(RenderNodeContext context) + { + if (failurePoint is TargetCallbackFailure.RawCommandCallback) + { + RawTargetCommandDescription rawCommand = RawTargetCommandDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + VerifyRetainedSession = () => _ = session.Intent; + throw new InvalidOperationException("raw-command-primary"); + }, + s_bounds, + RenderHitTestContract.OutputBounds); + context.Publish(context.RawTargetCommand(rawCommand)); + return; + } + + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription()); + if (failurePoint is TargetCallbackFailure.CommandCallback + or TargetCallbackFailure.UndeclaredTargetReadback + or TargetCallbackFailure.MissingTargetReadback + or TargetCallbackFailure.UndeclaredInputReadback + or TargetCallbackFailure.DuplicateInputReadback) + { + bool targetReadback = failurePoint == TargetCallbackFailure.MissingTargetReadback; + bool inputReadback = failurePoint == TargetCallbackFailure.DuplicateInputReadback; + TargetCommandDescription command = TargetCommandDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + VerifyRetainedSession = () => _ = session.Intent; + switch (failurePoint) + { + case TargetCallbackFailure.CommandCallback: + throw new InvalidOperationException("target-command-primary"); + case TargetCallbackFailure.UndeclaredTargetReadback: + session.UseSnapshot(static _ => { }); + break; + case TargetCallbackFailure.MissingTargetReadback: + break; + case TargetCallbackFailure.UndeclaredInputReadback: + session.Inputs.Single().UseSnapshot(static _ => { }); + break; + case TargetCallbackFailure.DuplicateInputReadback: + session.Inputs.Single().UseSnapshot(static _ => { }); + session.Inputs.Single().UseSnapshot(static _ => { }); + break; + } + }, + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds, + targetReadback ? TargetAccess.Readback : TargetAccess.ReadWrite, + inputReadbacks: inputReadback ? [RenderInputReadback.All] : null); + context.Publish(source); + context.Publish(context.TargetCommand([source], command)); + return; + } + + if (failurePoint is TargetCallbackFailure.ScopeCallback + or TargetCallbackFailure.ScopeMissingReplay + or TargetCallbackFailure.ScopeDoubleReplay) + { + TargetScopeDescription scope = TargetScopeDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + VerifyRetainedSession = () => _ = session.Intent; + switch (failurePoint) + { + case TargetCallbackFailure.ScopeCallback: + throw new InvalidOperationException("target-scope-primary"); + case TargetCallbackFailure.ScopeMissingReplay: + break; + case TargetCallbackFailure.ScopeDoubleReplay: + session.Canvas.Use(_ => + { + session.ReplayInput(); + session.ReplayInput(); + }); + break; + } + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent); + context.Publish(context.TargetScope(source, scope)); + return; + } + + RawTargetScopeDescription rawScope = RawTargetScopeDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + VerifyRetainedSession = () => _ = session.Intent; + switch (failurePoint) + { + case TargetCallbackFailure.RawScopeCallback: + throw new InvalidOperationException("raw-scope-primary"); + case TargetCallbackFailure.RawScopeMissingReplay: + break; + case TargetCallbackFailure.RawScopeDoubleReplay: + session.ReplayInput(); + session.ReplayInput(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply); + context.Publish(context.RawTargetScope(source, rawScope)); + } + } + + private sealed class TargetCaptureNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + context.Publish(context.ContributeValues(capture)); + } + } + + private sealed class BackendBoundaryFailureNode(InvalidOperationException failure) : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + _ => + { + ExecuteCalls++; + throw failure; + }))); + } + } + + private sealed class NestedPlanningFailureParentNode(RenderNode child) : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + _ = context.RecordNestedTarget(child, s_bounds); + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }))); + } + } + + private sealed class NestedRegionAnalysisFailureNode : RenderNode + { + public static InvalidOperationException PrimaryFailure { get; } = + new("nested-region-analysis-primary"); + + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + })); + RenderBoundsContract bounds = RenderBoundsContract.Create( + static value => value, + ThrowDuringRequiredInputBounds); + GeometryDescription geometry = GeometryDescription.CreateRequestLocal( + static _ => { }, + bounds, + RenderHitTestContract.AnyInput); + context.Publish(context.Geometry(source, geometry)); + } + + private static Rect ThrowDuringRequiredInputBounds(Rect _) + => throw PrimaryFailure; + } + + private sealed class NestedParentNode(RenderNode child) : RenderNode + { + public override void Process(RenderNodeContext context) + { + _ = context.RecordNestedTarget(child, s_bounds); + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription())); + } + } + + private sealed class NestedChildNode : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.Green)); + session.Publish(output); + }))); + } + } + + private sealed class ShiftedNestedChildNode(Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.Red)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class NestedOutputConsumerNode( + RenderNode child, + Rect targetDomain) : RenderNode + { + public RecordedNestedRenderTarget? NestedTarget { get; private set; } + + public override void Process(RenderNodeContext context) + { + NestedTarget = context.RecordNestedTarget(child, targetDomain); + RecordedNestedRenderTarget nested = NestedTarget; + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => session.UseNestedTarget( + nested.Binding, + image => + { + using OpaqueRenderOutput output = session.CreateOutput(targetDomain); + output.Canvas.Use(canvas => + { + canvas.Clear(Colors.Transparent); + image.Draw(canvas); + }); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(targetDomain), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: [NestedTargetAndCleanupFailureSlots.Nested.Bind(nested.Binding)]); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class NestedExecutionNode( + string name, + ICollection executionOrder, + RenderNode? nestedRoot = null, + RenderRequestOptions? parentOptions = null, + Exception? failure = null) : RenderNode + { + public int ExecuteCalls { get; private set; } + + public RecordedNestedRenderTarget? NestedRequest { get; private set; } + + public override void Process(RenderNodeContext context) + { + if ((nestedRoot is null) != (parentOptions is null)) + { + throw new InvalidOperationException( + "A nested root and its inherited parent options must be supplied together."); + } + + if (nestedRoot is not null) + { + NestedRequest = context.RecordNestedTarget(nestedRoot, s_bounds); + } + + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + executionOrder.Add(name); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.Cyan)); + if (failure is not null) + throw failure; + session.Publish(output); + }))); + } + } + + private sealed class NestedContainerNode : ContainerRenderNode + { + private readonly RenderNode _nestedRoot; + private readonly RenderRequestOptions _parentOptions; + + public NestedContainerNode( + RenderNode nestedRoot, + RenderRequestOptions parentOptions, + params RenderNode[] parentChildren) + { + _nestedRoot = nestedRoot; + _parentOptions = parentOptions; + foreach (RenderNode child in parentChildren) + AddChild(child); + } + + public RecordedNestedRenderTarget? NestedRequest { get; private set; } + + public override void Process(RenderNodeContext context) + { + NestedRequest = context.RecordNestedTarget(_nestedRoot, s_bounds); + context.PassThrough(); + } + } + + private sealed class PrimaryAndCleanupFailureNode : RenderNode + { + private readonly List _order; + private readonly InvalidOperationException _primary; + + public PrimaryAndCleanupFailureNode( + List order, + InvalidOperationException primary, + InvalidOperationException cleanup) + { + _order = order; + _primary = primary; + Resources = + [ + new OrderedDisposable("first", order), + new OrderedDisposable("throwing", order, cleanup), + new OrderedDisposable("last", order), + ]; + } + + public OrderedDisposable[] Resources { get; } + + public override void Process(RenderNodeContext context) + { + for (int index = 0; index < Resources.Length; index++) + _ = context.Own(Resources[index]); + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + _ => throw _primary))); + } + } + + private sealed class CacheableChildNode(bool throwOnExecute) : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + if (throwOnExecute) + throw new InvalidOperationException("later-cache-candidate-failure"); + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.Purple)); + session.Publish(output); + }))); + } + } + + private sealed class CleanupFailureCacheableNode : RenderNode + { + public CleanupFailureCacheableNode(List order, InvalidOperationException cleanup) + { + Resources = + [ + new OrderedDisposable("first", order), + new OrderedDisposable("throwing", order, cleanup), + new OrderedDisposable("last", order), + ]; + } + + public OrderedDisposable[] Resources { get; } + + public override void Process(RenderNodeContext context) + { + for (int index = 0; index < Resources.Length; index++) + _ = context.Own(Resources[index]); + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription())); + } + } + + private sealed class OrderedDisposable( + string name, + ICollection order, + Exception? failure = null) : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + order.Add(name); + if (failure is not null) + throw failure; + } + } +} + +internal static class NestedTargetAndCleanupFailureSlots +{ + internal static readonly RenderResourceSlot Nested = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs new file mode 100644 index 0000000000..8e85f16df5 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs @@ -0,0 +1,684 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class RecordingAndPlanningFailureTests +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + + [Test] + public void RecordingFailure_RollsBackOwnedResourcesAndInvalidatesEveryFacadeBeforeAllocation() + { + var resource = new FailureTestDisposable(); + var failure = new InvalidOperationException("recording-primary"); + using var node = new RecordingFailureNode(resource, failure); + var factory = new FailureTestTargetFactory(); + using var renderer = FailureTestSupport.CreateRenderer(node, factory, useRenderCache: false); + + InvalidOperationException? thrown = Assert.Throws(() => renderer.Measure()); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(resource.DisposeCalls, Is.EqualTo(1)); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(node.RetainedContext, Is.Not.Null); + Assert.That(node.RetainedHandle, Is.Not.Null); + Assert.That(() => _ = node.RetainedContext!.Inputs, Throws.TypeOf()); + Assert.That( + () => node.RetainedHandle!.TryGetMetadata(out _), + Throws.TypeOf()); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void PublishMappedInputs_MapperFailureAfterPublication_RollsBackOwnedResourcesAndInvalidatesHandles() + { + var resource = new FailureTestDisposable(); + var failure = new InvalidOperationException("mapped-input-failure"); + using var node = new MappedInputsFailureNode(resource, failure); + node.AddChild(new CacheableSourceNode()); + node.AddChild(new CacheableSourceNode()); + var factory = new FailureTestTargetFactory(); + using var renderer = FailureTestSupport.CreateRenderer(node, factory, useRenderCache: false); + + InvalidOperationException? thrown = Assert.Throws(() => renderer.Measure()); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(node.MapperCalls, Is.EqualTo(2)); + Assert.That(resource.DisposeCalls, Is.EqualTo(1)); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(node.RetainedContext, Is.Not.Null); + Assert.That(node.RetainedHandle, Is.Not.Null); + Assert.That(() => _ = node.RetainedContext!.Inputs, Throws.TypeOf()); + Assert.That( + () => node.RetainedHandle!.TryGetMetadata(out _), + Throws.TypeOf()); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void RecordingFailure_ReportsCleanupFaultWithoutReplacingThePrimary() + { + var cleanupFailure = new InvalidOperationException("recording-cleanup"); + var resource = new FailureTestDisposable(cleanupFailure); + var primaryFailure = new InvalidOperationException("recording-primary"); + using var owner = new RenderRequestOwner(); + using var node = new RecordingFailureNode(resource, primaryFailure); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); + + InvalidOperationException? thrown = Assert.Throws( + () => new RenderRequestRecorder(request).Record(node)); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(primaryFailure)); + Assert.That(resource.DisposeCalls, Is.EqualTo(1)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primaryFailure)); + Assert.That(owner.CleanupFailures, Has.Exactly(1).SameAs(cleanupFailure)); + Assert.That(owner.SecondaryFailures, Has.Exactly(1).SameAs(cleanupFailure)); + }); + } + + [TestCase(ResourceConflict.DuplicateOwn)] + [TestCase(ResourceConflict.OwnThenBorrow)] + [TestCase(ResourceConflict.BorrowThenOwn)] + public void RecordingOwnershipConflict_FailsAtomicallyThroughTheProductionRecorder(ResourceConflict conflict) + { + var resource = new FailureTestDisposable(); + using var node = new ResourceConflictNode(resource, conflict); + var factory = new FailureTestTargetFactory(); + using var renderer = FailureTestSupport.CreateRenderer(node, factory, useRenderCache: false); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Measure()); + string expectedMessage = conflict switch + { + ResourceConflict.DuplicateOwn or ResourceConflict.OwnThenBorrow => "already transferred", + ResourceConflict.BorrowThenOwn => "already borrowed", + _ => throw new ArgumentOutOfRangeException(nameof(conflict)), + }; + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain(expectedMessage)); + Assert.That( + resource.DisposeCalls, + Is.EqualTo(conflict is ResourceConflict.DuplicateOwn or ResourceConflict.OwnThenBorrow ? 1 : 0)); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void ApplyToCheckpoint_RestoresItemsBoundsAndResourcesWithoutReplacingThePrimaryFailure() + { + var resource = new FailureTestDisposable(); + var failure = new InvalidOperationException("apply-primary"); + using var context = new FilterEffectContext(s_bounds); + context.Shader(ShaderDescription.CurrentPixel("half4 apply(half4 color) { return color; }")); + Rect checkpointBounds = context.Bounds; + int checkpointItems = context.CountItems(); + + InvalidOperationException? thrown = Assert.Throws(() => + context.ApplyTransactional(() => + { + _ = context.Own(resource); + context.Geometry(GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Create( + static bounds => bounds.Inflate(new Thickness(3)), + static bounds => bounds.Inflate(new Thickness(3))), + RenderHitTestContract.AnyInput)); + throw failure; + })); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(context.Bounds, Is.EqualTo(checkpointBounds)); + Assert.That(context.CountItems(), Is.EqualTo(checkpointItems)); + Assert.That(resource.DisposeCalls, Is.EqualTo(1)); + }); + } + + [TestCase(BoundsFailure.Forward)] + [TestCase(BoundsFailure.BackwardRoi)] + public void BoundsAndRoiMappingFailure_IsPlanningAtomicAndNeverExecutesOrAllocates(BoundsFailure failurePoint) + { + using var node = new BoundsFailureNode(failurePoint); + var factory = new FailureTestTargetFactory(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + RequestedRegion = new Rect(2, 2, 2, 2), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + InvalidOperationException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo( + failurePoint == BoundsFailure.Forward ? "forward-bounds-failure" : "backward-roi-failure")); + Assert.That(node.ExecuteCalls, Is.Zero); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [TestCase(RecordingRecursion.DirectRecordNode)] + [TestCase(RecordingRecursion.DirectRecordSubtree)] + [TestCase(RecordingRecursion.IndirectRecordNode)] + [TestCase(RecordingRecursion.SeparateTarget)] + public void EveryRecordingRecursionShape_FailsWithAPathBeforeAllocation(RecordingRecursion shape) + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: s_bounds, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + var resource = new FailureTestDisposable(); + using var node = RecursionNode.Create(shape, options, resource); + using var request = new RenderRequest(options); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestRecorder(request).Record(node)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("cycle")); + Assert.That(failure.Message, Does.Contain("->")); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(failure)); + Assert.That(owner.SecondaryFailures, Is.Empty, + "One recording failure rethrown through nested catch boundaries is not a secondary failure."); + Assert.That(owner.CleanupFailures, Is.Empty); + Assert.That(resource.DisposeCalls, Is.EqualTo(1)); + Assert.That( + () => node.RetainedContext!.DisableRenderCache(), + Throws.InvalidOperationException, + "The failed parent recording context must be invalidated after rollback."); + }); + } + + [Test] + public void CacheLookupFailure_RemainsTheCompilerPrimaryAndCleansTheRecordedRequest() + { + using var node = new CacheableSourceNode(); + FailureTestSupport.WarmForCacheCapture(node); + using var request = FailureTestSupport.CreateFrameRequest(useRenderCache: true); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + var compiler = new RenderRequestCompiler( + renderCacheContext: FailureTestSupport.CacheResolutionContext, + renderCacheLookup: new ThrowingCacheLookup("cache-lookup-failure")); + + InvalidOperationException? failure = Assert.Throws( + () => compiler.Compile(request, graph)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("cache-lookup-failure")); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(node.ExecuteCalls, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + }); + } + + [Test] + public void CacheHitWithInvalidSubstitutionPayload_FailsExecutionWithoutPublishingAnything() + { + using var node = new CacheableSourceNode(); + FailureTestSupport.WarmForCacheCapture(node); + using var request = FailureTestSupport.CreateFrameRequest(useRenderCache: true); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + var compiler = new RenderRequestCompiler( + renderCacheContext: FailureTestSupport.CacheResolutionContext, + renderCacheLookup: new InvalidPayloadCacheLookup()); + using CompiledRenderRequest compiled = compiler.Compile(request, graph); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(8, 8); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("node-cache output payload")); + Assert.That(node.ExecuteCalls, Is.Zero, "A hit substitution must not execute its producer."); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void CachePublicationFailure_RejectsTheStagedCaptureAndReturnsEveryLease() + { + using var node = new CacheableSourceNode(); + FailureTestSupport.WarmForCacheCapture(node); + using var request = FailureTestSupport.CreateFrameRequest(useRenderCache: true); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler( + renderCacheContext: FailureTestSupport.CacheResolutionContext, + renderCacheLookup: RenderNodeCacheLookup.Instance) + .Compile(request, graph); + node.Cache.Dispose(); + using RenderTarget destination = FailureTestSupport.CreateCpuTarget(8, 8); + using var canvas = new ImmediateCanvas(destination); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = registry.BeginSession( + RenderIntent.Preview, + destination); + + Assert.That( + () => new RenderRequestExecutor(targets).Execute(compiled, canvas), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(node.ExecuteCalls, Is.EqualTo(1)); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + } + + private static Rect ThrowForward(Rect _) + => throw new InvalidOperationException("forward-bounds-failure"); + + private static Rect ThrowBackward(Rect _) + => throw new InvalidOperationException("backward-roi-failure"); + + public enum ResourceConflict + { + DuplicateOwn, + OwnThenBorrow, + BorrowThenOwn, + } + + public enum BoundsFailure + { + Forward, + BackwardRoi, + } + + public enum RecordingRecursion + { + DirectRecordNode, + DirectRecordSubtree, + IndirectRecordNode, + SeparateTarget, + } + + private sealed class RecordingFailureNode( + FailureTestDisposable resource, + InvalidOperationException failure) : RenderNode + { + public RenderNodeContext? RetainedContext { get; private set; } + + public RenderFragmentHandle? RetainedHandle { get; private set; } + + public override void Process(RenderNodeContext context) + { + RetainedContext = context; + _ = context.Own(resource); + RetainedHandle = context.OpaqueSource(FailureTestSupport.SourceDescription()); + context.Publish(RetainedHandle); + throw failure; + } + } + + private sealed class MappedInputsFailureNode( + FailureTestDisposable resource, + InvalidOperationException failure) : ContainerRenderNode + { + public int MapperCalls { get; private set; } + + public RenderNodeContext? RetainedContext { get; private set; } + + public RenderFragmentHandle? RetainedHandle { get; private set; } + + public override void Process(RenderNodeContext context) + { + RetainedContext = context; + context.PublishMappedInputs(input => + { + MapperCalls++; + if (MapperCalls == 1) + { + _ = context.Own(resource); + RenderFragmentHandle mapped = context.Opacity(input, 0.5f); + RetainedHandle = mapped; + return mapped; + } + + throw failure; + }); + } + } + + private sealed class ResourceConflictNode( + FailureTestDisposable resource, + ResourceConflict conflict) : RenderNode + { + public override void Process(RenderNodeContext context) + { + switch (conflict) + { + case ResourceConflict.DuplicateOwn: + _ = context.Own(resource); + _ = context.Own(resource); + break; + case ResourceConflict.OwnThenBorrow: + _ = context.Own(resource); + _ = context.Borrow(resource); + break; + case ResourceConflict.BorrowThenOwn: + _ = context.Borrow(resource); + _ = context.Own(resource); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private sealed class BoundsFailureNode(BoundsFailure failurePoint) : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(FailureTestSupport.SourceDescription( + _ => ExecuteCalls++)); + RenderBoundsContract bounds = failurePoint == BoundsFailure.Forward + ? RenderBoundsContract.Create(ThrowForward, static value => value) + : RenderBoundsContract.Create(static value => value, ThrowBackward); + GeometryDescription geometry = GeometryDescription.CreateRequestLocal( + static _ => { }, + bounds, + RenderHitTestContract.AnyInput); + context.Publish(context.Geometry(source, geometry)); + } + } + + private sealed class RecursionNode : RenderNode + { + private readonly RecordingRecursion _shape; + private readonly RenderRequestOptions _options; + private readonly FailureTestDisposable? _resource; + private RecursionNode? _other; + + private RecursionNode( + RecordingRecursion shape, + RenderRequestOptions options, + FailureTestDisposable? resource = null) + { + _shape = shape; + _options = options; + _resource = resource; + } + + public RenderNodeContext? RetainedContext { get; private set; } + + public static RecursionNode Create( + RecordingRecursion shape, + RenderRequestOptions options, + FailureTestDisposable? resource = null) + { + var result = new RecursionNode(shape, options, resource); + if (shape == RecordingRecursion.IndirectRecordNode) + { + result._other = new RecursionNode(shape, options) { _other = result }; + } + + return result; + } + + public override void Process(RenderNodeContext context) + { + RetainedContext = context; + if (_resource is not null) + _ = context.Own(_resource); + + switch (_shape) + { + case RecordingRecursion.DirectRecordNode: + _ = context.RecordNode(this, []); + break; + case RecordingRecursion.DirectRecordSubtree: + _ = context.RecordSubtree(this); + break; + case RecordingRecursion.IndirectRecordNode: + _ = context.RecordNode(_other!, []); + break; + case RecordingRecursion.SeparateTarget: + _ = context.RecordNestedTarget(this, s_bounds); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + protected override void OnDispose(bool disposing) + { + RecursionNode? other = Interlocked.Exchange(ref _other, null); + if (other is not null) + { + other._other = null; + other.Dispose(); + } + } + } + + private sealed class CacheableSourceNode : RenderNode + { + public int ExecuteCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(FailureTestSupport.SourceDescription( + session => + { + ExecuteCalls++; + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }))); + } + } + + private sealed class ThrowingCacheLookup(string message) : IRenderCacheLookup + { + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry) + => throw new InvalidOperationException(message); + } + + private sealed class InvalidPayloadCacheLookup : IRenderCacheLookup + { + public bool TryGet( + RenderCacheCandidate candidate, + RenderOutputCacheIdentity identity, + out RenderCacheEntry? entry) + { + entry = new RenderCacheEntry(identity, new object()); + return true; + } + } +} + +internal static class FailureTestSupport +{ + private static readonly Rect s_bounds = new(0, 0, 8, 8); + private static readonly OpaqueRenderDefinition> s_sourceDefinition = + OpaqueRenderDefinition>.Create( + static (session, execute) => execute(session), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + + public static RenderCacheResolutionContext CacheResolutionContext { get; } = new( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + new RenderCacheDeviceContextIdentity("failure-device", "failure-context"), + allowPersistentLookup: true, + allowCapturePublication: true); + + public static RenderNodeRenderer CreateRenderer( + RenderNode node, + IRenderTargetFactory? factory = null, + bool useRenderCache = false, + RenderRequestPurpose purpose = RenderRequestPurpose.Auxiliary, + RenderIntent intent = RenderIntent.Preview) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + Purpose = purpose, + }, + TargetFactory = factory, + }); + + public static RenderRequest CreateFrameRequest(bool useRenderCache) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + requestedRegion: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: useRenderCache ? RenderCacheOptions.Enabled : RenderCacheOptions.Disabled)); + + public static OpaqueRenderCall> SourceDescription( + Action? execute = null) + { + execute ??= static session => + { + using OpaqueRenderOutput output = session.CreateOutput(s_bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }; + + return s_sourceDefinition.Call(execute); + } + + public static void WarmForCacheCapture(RenderNode node) + { + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } + } + + public static RenderTarget CreateCpuTarget(int width = 8, int height = 8) + => new FailureTestRenderTarget(new PixelSize(width, height)); +} + +internal sealed class FailureTestDisposable(Exception? failure = null) : IDisposable +{ + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + if (failure is not null) + throw failure; + } +} + +internal sealed class FailureTestTargetFactory( + int? failAt = null, + Exception? createFailure = null, + Func? disposeFailure = null) : IRenderTargetFactory +{ + public List Targets { get; } = []; + + public int CreateCalls { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + int index = CreateCalls++; + if (createFailure is not null) + throw createFailure; + if (index == failAt) + return null; + + var target = new FailureTestRenderTarget(deviceSize, disposeFailure?.Invoke(index)); + Targets.Add(target); + return target; + } +} + +internal sealed class FailureTestRenderTarget : RenderTarget +{ + private readonly Exception? _disposeFailure; + + public FailureTestRenderTarget(PixelSize size, Exception? disposeFailure = null) + : base( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + size.Width, + size.Height) + { + _disposeFailure = disposeFailure; + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + bool shouldThrow = disposing && !IsDisposed && _disposeFailure is not null; + if (disposing && !IsDisposed) + DisposeCalls++; + base.Dispose(disposing); + if (shouldThrow) + throw _disposeFailure!; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderNodeRendererLifetimeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderNodeRendererLifetimeTests.cs new file mode 100644 index 0000000000..b2c818122c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderNodeRendererLifetimeTests.cs @@ -0,0 +1,292 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class RenderNodeRendererLifetimeTests +{ + [Test] + public void Rasterize_EmptySelection_ReturnsEmptyResultWithoutAllocating() + { + var bounds = new Rect(0, 0, 8, 8); + var emptySelection = new Rect(3, 4, 0, 2); + using var source = new TrackingRenderTarget(new PixelSize(8, 8)); + using var node = new ShaderNode(source, bounds); + using var factory = new TrackingTargetFactory(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + RequestedRegion = emptySelection, + OutputScale = 2, + MaxWorkingScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(emptySelection)); + Assert.That(rasterization.OutputScale, Is.EqualTo(2)); + Assert.That(rasterization.IsEmpty, Is.True); + Assert.That(rasterization.Bitmap, Is.Null); + Assert.That(factory.Targets, Is.Empty); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [TestCase(3, 4, 0, 2)] + [TestCase(20, 20, 2, 2)] + public void Render_EmptySelection_DrawsNothingAndLeavesNoLease( + int x, + int y, + int width, + int height) + { + var bounds = new Rect(0, 0, 8, 8); + using var source = new TrackingRenderTarget(new PixelSize(8, 8)); + using var node = new ShaderNode(source, bounds); + using var factory = new TrackingTargetFactory(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + RequestedRegion = new Rect(x, y, width, height), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + using RenderTarget target = RenderTarget.CreateNull(8, 8); + using var canvas = new ImmediateCanvas(target); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(factory.Targets, Is.Empty, + "A region that selects no pixels must not allocate an intermediate."); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(source.IsDisposed, Is.False, "The borrowed input stays caller-owned."); + }); + } + + [Test] + public void Rasterize_EmptySelection_SurfacesLeaseSessionCleanupFailure() + { + var bounds = new Rect(0, 0, 8, 8); + var cleanup = new InvalidOperationException("empty-selection-target-cleanup"); + using var source = new TrackingRenderTarget(new PixelSize(8, 8)); + using var node = new ShaderNode(source, bounds); + using var factory = new TrackingTargetFactory( + index => index == 0 ? cleanup : null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + using (RenderNodeRasterization seeded = renderer.Rasterize()) + { + Assert.That(seeded.Bitmap, Is.Not.Null); + } + + TrackingRenderTarget faultingTarget = factory.Targets[0]; + node.PublishOutput = false; + Exception? observed = null; + int emptyResults = 0; + const int safetyLimit = 512; + while (!faultingTarget.IsDisposed && emptyResults < safetyLimit) + { + try + { + using RenderNodeRasterization empty = renderer.Rasterize(); + Assert.That(empty.Bitmap, Is.Null); + emptyResults++; + } + catch (Exception ex) + { + observed = ex; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(observed, Is.SameAs(cleanup)); + Assert.That(emptyResults, Is.GreaterThan(0)); + Assert.That(faultingTarget.IsDisposed, Is.True); + Assert.That(faultingTarget.DisposeCalls, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + [Test] + public void Dispose_ReleasesRendererOwnedState_AndPreservesBorrowedState() + { + var bounds = new Rect(0, 0, 8, 8); + using var source = new TrackingRenderTarget(new PixelSize(8, 8)); + source.Value.Canvas.Clear(new SKColor(80, 120, 160, 192)); + using var node = new ShaderNode(source, bounds); + using var cacheSeed = new TrackingRenderTarget(new PixelSize(8, 8)); + RenderNodeCache.PublishAtomically( + [RenderCacheTestSupport.CreatePublication(node.Cache, cacheSeed, bounds)]); + using var factory = new TrackingTargetFactory(); + var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The lifetime test requires a non-empty rasterization."); + + Assert.Multiple(() => + { + Assert.That(renderer.StructuralPlanCacheStatistics.RetainedPlans, Is.EqualTo(1)); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.OwnedTargets, Is.GreaterThanOrEqualTo(1)); + Assert.That(factory.Targets, Is.Not.Empty); + Assert.That(factory.Targets, Has.All.Matches(target => !target.IsDisposed)); + }); + + renderer.Dispose(); + using RenderTarget cached = node.Cache.UseCache(out Rect cachedBounds); + + Assert.Multiple(() => + { + Assert.That(renderer.StructuralPlanCacheStatistics.RetainedPlans, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedBytes, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.OwnedTargets, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.OwnedBytes, Is.Zero); + Assert.That(factory.Targets, Has.All.Matches(target => target.IsDisposed)); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + Assert.That(factory.IsDisposed, Is.False, "The caller owns the target factory."); + Assert.That(node.IsDisposed, Is.False, "The caller owns the root node."); + Assert.That(node.Cache.IsDisposed, Is.False, "The root owns its render cache."); + Assert.That(cachedBounds, Is.EqualTo(bounds)); + Assert.That(cached.IsDisposed, Is.False); + Assert.That(source.IsDisposed, Is.False, "Borrowed materialized inputs remain caller-owned."); + Assert.That(bitmap.IsDisposed, Is.False, "A returned rasterization owns its bitmap independently."); + }); + + rasterization.Dispose(); + Assert.That(bitmap.IsDisposed, Is.True); + } + + private sealed class ShaderNode(RenderTarget source, Rect bounds) : RenderNode + { + public bool PublishOutput { get; set; } = true; + + public override void Process(RenderNodeContext context) + { + if (!PublishOutput) + return; + + RenderResource target = context.Borrow(source); + RenderFragmentHandle input = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + target, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + ShaderDescription shader = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.75f)); + context.Publish(context.Shader(input, shader)); + } + } + + private sealed class TrackingTargetFactory( + Func? disposeFailureAt = null) : IRenderTargetFactory, IDisposable + { + public List Targets { get; } = []; + + public bool IsDisposed { get; private set; } + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + var target = new TrackingRenderTarget( + deviceSize, + disposeFailureAt?.Invoke(Targets.Count)); + Targets.Add(target); + return target; + } + + public void Dispose() + { + IsDisposed = true; + } + } + + private sealed class TrackingRenderTarget : RenderTarget + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + private readonly Exception? _disposeFailure; + + public TrackingRenderTarget(PixelSize size, Exception? disposeFailure = null) + : base(CreateSurface(size), size.Width, size.Height) + { + _disposeFailure = disposeFailure; + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + bool shouldThrow = disposing && !IsDisposed && _disposeFailure is not null; + if (disposing && !IsDisposed) + DisposeCalls++; + + base.Dispose(disposing); + if (shouldThrow) + throw _disposeFailure!; + } + + private static SKSurface CreateSurface(PixelSize size) + => SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create the lifetime-test render target."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderRequestOwnerTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderRequestOwnerTests.cs new file mode 100644 index 0000000000..f267790ba1 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderRequestOwnerTests.cs @@ -0,0 +1,115 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class RenderRequestOwnerTests +{ + [Test] + public void PrimaryFailure_IsPreservedAndLaterFailuresAreSecondary() + { + var primary = new ApplicationException("render-primary"); + var later = new InvalidOperationException("render-secondary"); + var cleanup = new IOException("cleanup-secondary"); + using var owner = new RenderRequestOwner(); + owner.Register(() => throw cleanup); + + owner.RecordPrimaryFailure(primary); + owner.RecordPrimaryFailure(primary); + owner.RecordPrimaryFailure(later); + owner.Cleanup(); + + Exception thrown = Assert.Throws(() => owner.ThrowIfFailed())!; + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(primary)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + Assert.That(owner.SecondaryFailures, Is.EqualTo(new Exception[] { later, cleanup })); + Assert.That(owner.CleanupFailures, Is.EqualTo(new Exception[] { cleanup })); + }); + } + + [Test] + public void DischargeAndAcceptedCacheTransfer_PreventCleanupExactlyOnce() + { + int cleanupCount = 0; + using var owner = new RenderRequestOwner(); + RenderOwnershipToken discharged = owner.Register(() => cleanupCount++); + RenderOwnershipToken transferred = owner.Register(() => cleanupCount++); + RenderOwnershipToken pending = owner.Register(() => cleanupCount++); + + owner.Discharge(discharged); + owner.DischargeAfterAcceptedCacheTransfer(transferred); + owner.Cleanup(); + + Assert.Multiple(() => + { + Assert.That(cleanupCount, Is.EqualTo(1)); + Assert.That(discharged.State, Is.EqualTo(RenderOwnershipState.Discharged)); + Assert.That(transferred.State, Is.EqualTo(RenderOwnershipState.CacheTransferred)); + Assert.That(pending.State, Is.EqualTo(RenderOwnershipState.Discharged)); + Assert.That(() => owner.Discharge(discharged), Throws.TypeOf()); + Assert.That( + () => owner.DischargeAfterAcceptedCacheTransfer(transferred), + Throws.TypeOf()); + }); + } + + [Test] + public void ResourceCleanupAndCacheTransfer_HaveDistinctOwnershipOutcomes() + { + var releasedValue = new TrackedDisposable(); + var transferredValue = new TrackedDisposable(); + using var registry = new RenderRequestResourceRegistry(); + using var owner = new RenderRequestOwner(); + RenderResource released = registry.RegisterOwned(releasedValue); + RenderResource transferred = registry.RegisterOwned(transferredValue); + registry.Commit(released); + registry.Commit(transferred); + owner.Register(() => registry.Release(released)); + RenderOwnershipToken transferToken = owner.Register(() => registry.Release(transferred)); + + TrackedDisposable cachePayload = registry.TransferOwned(transferred); + owner.DischargeAfterAcceptedCacheTransfer(transferToken); + owner.Cleanup(); + + Assert.Multiple(() => + { + Assert.That(releasedValue.DisposeCount, Is.EqualTo(1)); + Assert.That(transferredValue.DisposeCount, Is.Zero); + Assert.That(cachePayload, Is.SameAs(transferredValue)); + }); + + cachePayload.Dispose(); + Assert.That(transferredValue.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public void TokenFromAnotherOwner_IsRejected() + { + using var first = new RenderRequestOwner(); + using var second = new RenderRequestOwner(); + RenderOwnershipToken token = first.Register(static () => { }); + + Assert.That(() => second.Discharge(token), Throws.TypeOf()); + } + + [Test] + public void RegisterAfterCleanup_IsRejected() + { + using var owner = new RenderRequestOwner(); + owner.Cleanup(); + + Assert.That(() => owner.Register(static () => { }), Throws.TypeOf()); + } + + private sealed class TrackedDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderResourceOwnershipTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderResourceOwnershipTests.cs new file mode 100644 index 0000000000..76fb016a25 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderResourceOwnershipTests.cs @@ -0,0 +1,138 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class RenderResourceOwnershipTests +{ + + [Test] + public void DuplicateOwnedAndOwnedBorrowedConflicts_AreRejectedBeforeAnotherTransfer() + { + var ownedValue = new TrackedDisposable(); + using var ownedRegistry = new RenderRequestResourceRegistry(); + RenderResource owned = ownedRegistry.RegisterOwned(ownedValue); + + Assert.Multiple(() => + { + Assert.That( + () => ownedRegistry.RegisterOwned(ownedValue), + Throws.TypeOf()); + Assert.That( + () => ownedRegistry.RegisterBorrowed(ownedValue), + Throws.TypeOf()); + }); + + ownedRegistry.Rollback(owned); + Assert.That(ownedValue.DisposeCount, Is.EqualTo(1)); + + var borrowedValue = new TrackedDisposable(); + using var borrowedRegistry = new RenderRequestResourceRegistry(); + RenderResource borrowed = borrowedRegistry.RegisterBorrowed(borrowedValue); + + Assert.That( + () => borrowedRegistry.RegisterOwned(borrowedValue), + Throws.TypeOf()); + + borrowedRegistry.Rollback(borrowed); + Assert.That(borrowedValue.DisposeCount, Is.Zero); + } + + [Test] + public void RolledBackOwnership_RemainsATombstoneForTheRequestFamily() + { + var value = new TrackedDisposable(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterOwned(value); + registry.Rollback(resource); + + Assert.Multiple(() => + { + Assert.That(value.DisposeCount, Is.EqualTo(1)); + Assert.That(() => registry.RegisterOwned(value), Throws.TypeOf()); + Assert.That( + () => registry.RegisterBorrowed(value), + Throws.TypeOf()); + }); + } + + [Test] + public void RolledBackBorrow_RemainsATombstoneForTheRequestFamily() + { + var value = new TrackedDisposable(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterBorrowed(value); + registry.Rollback(resource); + + Assert.Multiple(() => + { + Assert.That(value.DisposeCount, Is.Zero); + Assert.That(() => registry.RegisterOwned(value), Throws.TypeOf()); + }); + } + + [Test] + public void FinalRelease_DuringUseIsRejectedBeforeOwnershipMutation() + { + var value = new TrackedDisposable(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterOwned(value); + registry.Commit(resource); + + Assert.That( + () => registry.Use(resource, _ => + { + registry.Release(resource); + return 0; + }), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(resource.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Committed)); + Assert.That(resource.OwnershipState, Is.EqualTo(RenderResourceOwnershipState.RequestOwned)); + Assert.That(registry.Slots, Has.Count.EqualTo(1)); + Assert.That(value.DisposeCount, Is.Zero); + }); + + registry.Release(resource); + Assert.That(value.DisposeCount, Is.EqualTo(1)); + } + + + + [Test] + public void OwnedResource_CanTransferToPersistentCacheWithoutRequestDisposal() + { + var value = new TrackedDisposable(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterOwned(value); + registry.Commit(resource); + + TrackedDisposable transferred = registry.TransferOwned(resource); + registry.Release(resource); + + Assert.Multiple(() => + { + Assert.That(transferred, Is.SameAs(value)); + Assert.That(value.DisposeCount, Is.Zero); + Assert.That(resource.OwnershipState, Is.EqualTo(RenderResourceOwnershipState.Discharged)); + Assert.That(() => _ = resource.SlotIdentity, Throws.TypeOf()); + Assert.That(() => registry.TransferOwned(resource), Throws.TypeOf()); + }); + + transferred.Dispose(); + Assert.That(value.DisposeCount, Is.EqualTo(1)); + } + + + private sealed class TrackedDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs new file mode 100644 index 0000000000..55a6611d84 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs @@ -0,0 +1,550 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +[TestFixture] +public sealed class ShaderAndAllocationFailureTests +{ + [Test] + public void DescriptionValidationFailure_HappensDuringRecordingBeforeAnyTargetAllocation() + { + using var node = new InvalidDescriptionNode(); + var factory = new TrackingTargetFactory(); + using var renderer = CreateRenderer(node, factory); + + ArgumentException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("CurrentPixel")); + Assert.That(factory.CreateCalls, Is.Zero); + Assert.That(factory.Targets, Is.Empty); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.Zero); + }); + } + + [Test] + public void SnippetMergeFailure_DoesNotPoisonAValidDeterministicRetry() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + var stage = new SkslSnippetStage(description); + SkslMergedProgram before = SkslSnippetMerger.Merge([stage]); + + ArgumentException? failure = Assert.Throws( + () => SkslSnippetMerger.Merge([])); + SkslMergedProgram after = SkslSnippetMerger.Merge([stage]); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("At least one shader stage is required")); + Assert.That(after.Source, Is.EqualTo(before.Source)); + Assert.That(after.Bindings, Is.EqualTo(before.Bindings)); + Assert.That(after.Identity, Is.EqualTo(before.Identity)); + }); + } + + [Test] + public void InvalidProgram_PreservesValidationFailureAndReturnsEveryTargetToThePool() + { + using var node = new ShaderNode(ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 p) { this is not valid SkSL; }", + RenderBoundsContract.Identity)); + var factory = new TrackingTargetFactory(); + var renderer = CreateRenderer(node, factory); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.StartWith("SkSL program validation failed:")); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.OwnedTargets, Is.GreaterThan(0)); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.Zero, + "A failed backend compile must not install a program-cache entry."); + Assert.That(factory.Targets, Has.All.Matches(target => !target.IsDisposed)); + }); + + renderer.Dispose(); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + } + + [TestCase(RuntimeBindingFailure.MissingUniform)] + [TestCase(RuntimeBindingFailure.DuplicateUniform)] + [TestCase(RuntimeBindingFailure.MissingResource)] + [TestCase(RuntimeBindingFailure.DuplicateResource)] + public void RuntimeBindingFailure_SealsTheWriterAndDischargesEveryTarget( + RuntimeBindingFailure failurePoint) + { + using var node = new RuntimeBindingFailureNode(failurePoint); + var factory = new TrackingTargetFactory(); + var renderer = CreateRenderer(node, factory); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("must set its writer exactly once")); + Assert.That(node.VerifyRetainedWriter is not null, Is.True); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.EqualTo(1), + "A runtime binding failure must not corrupt the immutable compiled program."); + }); + Action verifyRetainedWriter = node.VerifyRetainedWriter + ?? throw new AssertionException("The runtime binder did not expose its retained-writer probe."); + Assert.That(verifyRetainedWriter, Throws.TypeOf()); + + renderer.Dispose(); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + } + + [Test] + public void MaterializationCallbackFailure_DischargesTheCreatedOutputWithoutPartialPublication() + { + var primary = new InvalidOperationException("materialization-callback-primary"); + using var node = new MaterializationFailureNode(primary); + var factory = new TrackingTargetFactory(); + var renderer = CreateRenderer(node, factory); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(node.CallbackEntries, Is.EqualTo(1)); + Assert.That(factory.CreateCalls, Is.EqualTo(2), + "The root and callback output were both acquired before the injected callback fault."); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That(factory.Targets, Has.All.Matches(target => !target.IsDisposed)); + }); + + renderer.Dispose(); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + } + + [Test] + public void UniformProviderFailure_RemainsPrimaryAndDoesNotPublishARasterization() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 0.5f, + static (_, _, _) => throw new InvalidOperationException("uniform-provider-failure"))); + using var node = new ShaderNode(description); + var factory = new TrackingTargetFactory(); + var renderer = CreateRenderer(node, factory); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("uniform-provider-failure")); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.EqualTo(1), + "A provider failure must not corrupt the immutable compiled program."); + }); + + renderer.Dispose(); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + } + + [Test] + public void DeliveryTargetAcquisitionFailure_DischargesTheAlreadyAcceptedRootWithoutPartialOutput() + { + using var node = new ShaderNode(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + var factory = new TrackingTargetFactory(failAt: 1); + var renderer = CreateRenderer(node, factory, RenderIntent.Delivery); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("could not allocate")); + Assert.That(factory.CreateCalls, Is.EqualTo(2)); + Assert.That(factory.Targets, Has.Count.EqualTo(1)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(factory.Targets.Single().IsDisposed, Is.False, + "An accepted target remains renderer-owned until renderer disposal."); + }); + + renderer.Dispose(); + Assert.That(factory.Targets.Single().DisposeCalls, Is.EqualTo(1)); + } + + [Test] + public void PreviewTargetAcquisitionFailure_SilentlyChangesThePixelsDeliveryRefusesToProduce() + { + static byte[] RasterizePreview(TrackingTargetFactory factory) + { + using var node = new ShaderNode(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + using RenderNodeRenderer renderer = CreateRenderer(node, factory, RenderIntent.Preview); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + return rasterization.Bitmap!.GetPixelSpan().ToArray(); + } + + byte[] intact = RasterizePreview(new TrackingTargetFactory()); + byte[] degraded = RasterizePreview(new TrackingTargetFactory(failAt: 1)); + + Assert.That(degraded, Is.Not.EqualTo(intact), + "Preview absorbs the allocation failure and returns different pixels, which is why a " + + "delivery-grade render must use RenderIntent.Delivery and fail instead."); + } + + [Test] + public void ProgramCacheDisposal_ContinuesAfterEachProgramFaultAndSurfacesTheFirstFailure() + { + var cache = new ProgramCache( + static _ => { }, + static _ => 1, + maxRetainedBytes: 16); + var programs = new List(); + ProgramCacheContextKey context = new( + "device", + "context", + "capability", + "linear-premul-rgba16f", + "options"); + Acquire("half4 main(float2 p) { return half4(1); }"); + Acquire("half4 main(float2 p) { return half4(0); }"); + + InvalidOperationException? failure = Assert.Throws(cache.Dispose); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("program-dispose-1"), + "The most-recently-used program is the first disposal attempt."); + Assert.That(programs.Select(static program => program.DisposeCalls), Is.All.EqualTo(1)); + Assert.That(cache.Statistics.RetainedPrograms, Is.Zero); + Assert.DoesNotThrow(cache.Dispose); + }); + + void Acquire(string source) + { + ShaderProgramIdentity identity = ShaderProgramIdentity.CreateSksl( + source, + [], + SkslBackendBudget.Unlimited); + using ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => + { + var program = new ThrowingProgram(programs.Count); + programs.Add(program); + return program; + }); + } + } + + [Test] + public void ProgramCreationFailure_LeavesNoEntryAndAValidRetryCanBeRetained() + { + using var cache = new ProgramCache( + static _ => { }, + static _ => 1, + maxRetainedBytes: 16); + ShaderProgramIdentity identity = ShaderProgramIdentity.CreateSksl( + "half4 main(float2 p) { return half4(1); }", + [], + SkslBackendBudget.Unlimited); + var context = new ProgramCacheContextKey( + "device", + "context", + "capability", + "linear-premul-rgba16f", + "options"); + var primary = new InvalidOperationException("program-creation-primary"); + + InvalidOperationException? failure = Assert.Throws( + () => cache.GetOrCreate(identity, context, () => throw primary)); + var recovered = new TrackingProgram(); + using (ProgramCacheLease lease = cache.GetOrCreate( + identity, + context, + () => recovered)) + { + Assert.That(lease.IsCacheHit, Is.False); + } + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(cache.Statistics.Misses, Is.EqualTo(2)); + Assert.That(cache.Statistics.Creations, Is.EqualTo(1)); + Assert.That(cache.Statistics.RetainedPrograms, Is.EqualTo(1)); + }); + + cache.Dispose(); + Assert.That(recovered.DisposeCalls, Is.EqualTo(1)); + } + + [Test] + public void RendererDisposal_ContinuesAfterPoolFaultAndStillReleasesProgramsAndPlans() + { + var poolFailure = new InvalidOperationException("renderer-pool-dispose-primary"); + using var node = new ShaderNode(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + var factory = new TrackingTargetFactory( + disposeFailureAt: index => index == 0 ? poolFailure : null); + var renderer = CreateRenderer(node, factory); + using (RenderNodeRasterization rasterization = renderer.Rasterize()) + { + Assert.That(rasterization.IsEmpty, Is.False); + } + Assert.Multiple(() => + { + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.EqualTo(1)); + Assert.That(renderer.StructuralPlanCacheStatistics.RetainedPlans, Is.EqualTo(1)); + Assert.That(factory.Targets, Has.Count.GreaterThanOrEqualTo(2)); + }); + + InvalidOperationException? failure = Assert.Throws(renderer.Dispose); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(poolFailure)); + Assert.That(renderer.TargetPoolStatistics.OwnedTargets, Is.Zero); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(renderer.ProgramCacheStatistics.RetainedPrograms, Is.Zero); + Assert.That(renderer.StructuralPlanCacheStatistics.RetainedPlans, Is.Zero); + Assert.That(factory.Targets, Has.All.Matches(target => target.DisposeCalls == 1)); + Assert.DoesNotThrow(renderer.Dispose); + }); + } + + public enum RuntimeBindingFailure + { + MissingUniform, + DuplicateUniform, + MissingResource, + DuplicateResource, + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode root, + IRenderTargetFactory factory, + RenderIntent intent = RenderIntent.Preview) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + TargetDomain = new Rect(0, 0, 8, 8), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + private sealed class ShaderNode(ShaderDescription description) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription source = OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.Shader(context.OpaqueSource(source), description)); + } + } + + private sealed class InvalidDescriptionNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + _ = ShaderDescription.CurrentPixel( + "half4 main(float2 position) { return half4(position, 0, 1); }"); + } + } + + private sealed class RuntimeBindingFailureNode(RuntimeBindingFailure failurePoint) : RenderNode + { + private readonly object _resource = new(); + + public Action? VerifyRetainedWriter { get; private set; } + + public override void Process(RenderNodeContext context) + { + ShaderDescription description = failurePoint is RuntimeBindingFailure.MissingUniform + or RuntimeBindingFailure.DuplicateUniform + ? CreateUniformDescription() + : CreateResourceDescription(context); + RenderFragmentHandle source = context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale)); + context.Publish(context.Shader(source, description)); + } + + private ShaderDescription CreateUniformDescription() + { + return ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 0.5f, + (writer, value, _) => + { + VerifyRetainedWriter = () => writer.Set(value); + if (failurePoint == RuntimeBindingFailure.MissingUniform) + return; + + writer.Set(value); + writer.Set(value); + })); + } + + private ShaderDescription CreateResourceDescription(RenderNodeContext context) + { + RenderResource resource = context.Borrow(_resource); + return ShaderDescription.CurrentPixel( + "uniform shader lookup; half4 apply(half4 color) { return lookup.eval(color.rg); }", + bindings => bindings.Resource( + "lookup", + resource, + ShaderResourceCoordinateSpace.Value, + (writer, _, _) => + { + VerifyRetainedWriter = () => + { + using SKShader retained = SKShader.CreateColor(SKColors.White); + writer.Set(retained); + }; + if (failurePoint == RuntimeBindingFailure.MissingResource) + return; + + writer.Set(SKShader.CreateColor(SKColors.White)); + using SKShader duplicate = SKShader.CreateColor(SKColors.Black); + writer.Set(duplicate); + })); + } + } + + private sealed class MaterializationFailureNode(InvalidOperationException failure) : RenderNode + { + public int CallbackEntries { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + CallbackEntries++; + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + throw failure; + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale))); + } + } + + private sealed class TrackingTargetFactory( + int? failAt = null, + Func? disposeFailureAt = null) : IRenderTargetFactory + { + public List Targets { get; } = []; + + public int CreateCalls { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + int index = CreateCalls++; + if (index == failAt) + return null; + + var target = new TrackingRenderTarget(deviceSize, disposeFailureAt?.Invoke(index)); + Targets.Add(target); + return target; + } + } + + private sealed class TrackingRenderTarget : RenderTarget + { + private readonly Exception? _disposeFailure; + + public TrackingRenderTarget(PixelSize size, Exception? disposeFailure = null) + : base( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + size.Width, + size.Height) + { + _disposeFailure = disposeFailure; + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + bool fail = disposing && !IsDisposed && _disposeFailure is not null; + if (disposing && !IsDisposed) + DisposeCalls++; + base.Dispose(disposing); + if (fail) + throw _disposeFailure!; + } + } + + private sealed class TrackingProgram : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + } + } + + private sealed class ThrowingProgram(int id) : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + throw new InvalidOperationException($"program-dispose-{id}"); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectCrashSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectCrashSafetyTests.cs index c5eac779b5..7a6a19a036 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectCrashSafetyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectCrashSafetyTests.cs @@ -6,6 +6,7 @@ using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; using Beutl.UnitTests.Engine.Graphics.Rendering.Golden; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -14,6 +15,59 @@ public sealed class FilterEffectCrashSafetyTests { private static readonly PixelSize Frame = new(320, 180); + // The positive-horizontal case is the one that originally exposed the decal fringe; the other three + // directions each sample a different edge of the source and cost about a third of a second between them, + // so they run with it rather than behind a gate nothing invokes. + [TestCase(100, 0)] + [TestCase(-100, 0)] + [TestCase(0, 100)] + [TestCase(0, -100)] + public void ColorShift_offsets_beyond_source_have_no_decal_fringe_at_quarter_scale( + int offsetX, + int offsetY) + { + AssertColorShiftHasNoDecalFringe(offsetX, offsetY); + } + + private static void AssertColorShiftHasNoDecalFringe(int offsetX, int offsetY) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var effect = new ColorShift + { + RedOffset = { CurrentValue = new PixelPoint(offsetX, offsetY) }, + }; + var shape = new RectShape + { + Width = { CurrentValue = 100 }, + Height = { CurrentValue = 100 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Fill = { CurrentValue = Brushes.Red }, + FilterEffect = { CurrentValue = effect }, + }; + using Drawable.Resource drawable = shape.ToResource(CompositionContext.Default); + using Bitmap bitmap = GoldenImageHarness.RenderAtScale( + drawable, + new PixelSize(200, 100), + 0.25f, + clearColor: Colors.Transparent); + + for (int y = 0; y < 25; y++) + { + for (int x = 0; x < 25; x++) + { + SKColor pixel = bitmap.SKBitmap.GetPixel(x, y); + Assert.That( + pixel.Red, + Is.Zero, + $"The red sample is outside the source domain at device pixel ({x}, {y})."); + } + } + }); + } + [Test] public void ColorShift_split_character_text_with_empty_targets_does_not_throw() { @@ -43,7 +97,11 @@ public void ShakeEffect_extreme_values_keep_target_bounds_finite() effect.ApplyTo(feCtx, effect.ToResource(new CompositionContext(TimeSpan.Zero))); using var builder = new SKImageFilterBuilder(); - using var activator = new FilterEffectActivator(targets, builder); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); Assert.DoesNotThrow(() => activator.Apply(feCtx)); foreach (EffectTarget target in activator.CurrentTargets) @@ -68,7 +126,9 @@ public void PixelSort_half_initialized_gpu_path_degrades_to_noop() using var sourceRenderTarget = RenderTarget.Create(0, 0); if (sourceRenderTarget is null) { - Assert.Pass("Zero-sized RenderTarget is unavailable in this backend."); + const string reason = "Zero-sized RenderTarget is unavailable in this backend."; + TestContext.WriteLine(reason); + Assert.Ignore(reason); } using var targets = new EffectTargets @@ -80,7 +140,11 @@ public void PixelSort_half_initialized_gpu_path_degrades_to_noop() effect.ApplyTo(feCtx, effect.ToResource(new CompositionContext(TimeSpan.Zero))); using var builder = new SKImageFilterBuilder(); - using var activator = new FilterEffectActivator(targets, builder); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary); Assert.DoesNotThrow(() => activator.Apply(feCtx)); }); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectRenderNodeTest.cs index d3580e85ad..d191b737fd 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffectRenderNodeTest.cs @@ -3,48 +3,72 @@ using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; using Beutl.Media; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; public class FilterEffectRenderNodeTest { - private static RenderNodeContext CreateRenderNodeContext() + private static FilterEffectRenderNode CreateNode(FilterEffect.Resource resource) { - return new RenderNodeContext([ - RenderNodeOperation.CreateLambda( - new Rect(0, 0, 100, 100), - canvas => canvas.DrawEllipse(new Rect(0, 0, 100, 100), Brushes.Resource.White, null), - point => false - ) - ]); + var node = new FilterEffectRenderNode(resource); + node.AddChild(new EllipseRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + return node; } [Test] - public void Process_ShouldReturnRenderNodeOperations() + public void Measure_ShouldReportRecordedFilterOutput() { var effect = new Blur(); var resource = effect.ToResource(CompositionContext.Default); - var node = new FilterEffectRenderNode(resource); - var context = CreateRenderNodeContext(); - var operations = node.Process(context); + using var node = CreateNode(resource); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); - Assert.That(operations, Is.Not.Empty); + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + }); } [Test] - public void Process_ShouldApplyFilterEffect() + public void Measure_ShouldApplyFilterEffectBounds() { var effect = new Blur() { Sigma = { CurrentValue = new(10, 10) } }; var resource = effect.ToResource(CompositionContext.Default); - var node = new FilterEffectRenderNode(resource); - var context = CreateRenderNodeContext(); - var operations = node.Process(context); - - Assert.That(operations, Is.Not.Empty); - Assert.That(operations[0].Bounds.X, Is.LessThan(0)); - Assert.That(operations[0].Bounds.Y, Is.LessThan(0)); - Assert.That(operations[0].Bounds.Width, Is.GreaterThan(100)); - Assert.That(operations[0].Bounds.Height, Is.GreaterThan(100)); + using var node = CreateNode(resource); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.OutputBounds.X, Is.LessThan(0)); + Assert.That(measurement.OutputBounds.Y, Is.LessThan(0)); + Assert.That(measurement.OutputBounds.Width, Is.GreaterThan(100)); + Assert.That(measurement.OutputBounds.Height, Is.GreaterThan(100)); + } + + [Test] + public void CurrentPixelBuiltIns_ExecuteAsOneFusedShaderRun() + { + using Bitmap disabled = RenderCurrentPixelBuiltIns( + FusionMode.Disabled, + out _); + using Bitmap enabled = RenderCurrentPixelBuiltIns( + FusionMode.Enabled, + out RenderExecutionStatistics statistics); + + Assert.Multiple(() => + { + Assert.That(enabled.GetPixelSpan().SequenceEqual(disabled.GetPixelSpan()), Is.True); + Assert.That(enabled.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That(statistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(statistics.ShaderStageExecutions, Is.EqualTo(2)); + Assert.That(statistics.FusedShaderRunExecutions, Is.EqualTo(1)); + }); } [Test] @@ -59,7 +83,7 @@ public void Update_ShouldReturnFalseForSameFilterEffect() Assert.That(result, Is.False); } - // Effectのプロパティを変更するとUpdateがTrueを返す + // Updating an effect property changes its captured resource version. [Test] public void Update_ShouldReturnTrueForDifferentFilterEffectProperty() { @@ -88,4 +112,63 @@ public void Update_ShouldReturnTrueForDifferentFilterEffect() Assert.That(result, Is.True); } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private static Bitmap RenderCurrentPixelBuiltIns( + FusionMode fusionMode, + out RenderExecutionStatistics statistics) + { + var group = new FilterEffectGroup + { + Children = + { + new Gamma(), + new Invert(), + }, + }; + using var node = CreateNode(group.ToResource(CompositionContext.Default)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + statistics = renderer.LastExecutionStatistics; + return rasterization.Bitmap?.Clone() + ?? throw new InvalidOperationException("The filter-effect render produced no bitmap."); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + SKSurface surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU filter-effect test surface."); + return new CpuRenderTarget(surface, deviceSize); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffects/FilterEffectAlphaReadbackTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffects/FilterEffectAlphaReadbackTests.cs new file mode 100644 index 0000000000..bd268fd1f6 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/FilterEffects/FilterEffectAlphaReadbackTests.cs @@ -0,0 +1,372 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.Pixel; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.FilterEffects; + +[TestFixture] +[NonParallelizable] +public sealed class FilterEffectAlphaReadbackTests +{ + private static readonly PixelSize s_sourceSize = new(72, 56); + + [Test] + [Category("GpuPassFusionGpu")] + public void SnapshotAlpha_ReadsAlpha8AndMatchesLegacyConversionExactly() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget target = CreateAlphaRampTarget(); + using Bitmap fullColor = target.Snapshot(); + using Bitmap expected = fullColor.Convert(BitmapColorType.Alpha8); + using Bitmap actual = target.SnapshotAlpha(); + + Assert.Multiple(() => + { + Assert.That(actual.ColorType, Is.EqualTo(BitmapColorType.Alpha8)); + Assert.That(actual.AlphaType, Is.EqualTo(BitmapAlphaType.Premul)); + Assert.That(actual.ColorSpace.Equals(BitmapColorSpace.LinearSrgb), Is.True); + Assert.That(actual.BytesPerPixel, Is.EqualTo(1)); + Assert.That(actual.RowBytes, Is.EqualTo(actual.Width)); + Assert.That(actual.ByteCount, Is.EqualTo(actual.Width * actual.Height)); + }); + AssertRowsIdentical(expected, actual, "legacy RgbaF16-to-Alpha8 conversion", "direct Alpha8 readback"); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void SnapshotAlpha_RetainsCompletionWaitForCpuReadback() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + GpuResourceReclaimQueue.FlushAndDrain(); + using RenderTarget target = CreatePatternTarget(); + var flushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(flushes.Add)) + using (target.SnapshotAlpha()) + { + } + + Assert.That( + flushes, + Is.EqualTo(new[] { ImmediateCanvasFlushKind.PrepareForSampling }), + "Alpha readback must wait for completion rather than using submit-only sampling."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ClippingAutoClip_DirectAlphaReadbackMatchesLegacyBoundsExactly() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreatePatternTarget(); + using Bitmap expectedAlpha = SnapshotClippingAlphaLegacy(source); + using Bitmap actualAlpha = source.SnapshotAlpha(); + + Thickness expected = FindAutoClipThickness(expectedAlpha); + Thickness actual = FindAutoClipThickness(actualAlpha); + + Assert.That(actual, Is.EqualTo(expected)); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void FlatShadow_DirectAlphaReadbackMatchesLegacyContourRenderingExactly() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreatePatternTarget(); + using Bitmap legacySource = source.Snapshot(); + using Bitmap directAlpha = source.SnapshotAlpha(); + using Bitmap expected = RenderFlatShadowContours(legacySource); + using Bitmap actual = RenderFlatShadowContours(directAlpha); + + AssertRowsIdentical(expected, actual, "legacy FlatShadow contours", "direct-alpha FlatShadow contours"); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void StrokeEffect_DirectAlphaReadbackMatchesLegacyContourRenderingExactly() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreatePatternTarget(); + using Bitmap legacySource = source.Snapshot(); + using Bitmap directAlpha = source.SnapshotAlpha(); + using Bitmap expected = RenderStrokeContours(legacySource); + using Bitmap actual = RenderStrokeContours(directAlpha); + + AssertRowsIdentical(expected, actual, "legacy StrokeEffect contours", "direct-alpha StrokeEffect contours"); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void PartsSplitEffect_DirectAlphaReadbackMatchesLegacyContourRenderingExactly() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreatePatternTarget(); + using Bitmap legacySource = source.Snapshot(); + using Bitmap directAlpha = source.SnapshotAlpha(); + using Bitmap expected = RenderSplitContours(legacySource); + using Bitmap actual = RenderSplitContours(directAlpha); + + AssertRowsIdentical(expected, actual, "legacy PartsSplitEffect contours", "direct-alpha PartsSplitEffect contours"); + }); + } + + private static Bitmap SnapshotClippingAlphaLegacy(RenderTarget target) + { + target.Value.Flush(true, true); + using SKImage image = target.Value.Snapshot(); + return image.ToBitmap(BitmapColorType.Alpha8); + } + + private static Thickness FindAutoClipThickness(Bitmap bitmap) + { + int x0 = bitmap.Width; + int y0 = bitmap.Height; + int x1 = 0; + int y1 = 0; + for (int y = 0; y < bitmap.Height; y++) + { + Span row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if (row[x] == 0) + continue; + + if (x0 > x) x0 = x; + if (y0 > y) y0 = y; + if (x1 < x) x1 = x; + if (y1 < y) y1 = y; + } + } + + return new Thickness(x0, y0, bitmap.Width - x1, bitmap.Height - y1); + } + + private static Bitmap RenderFlatShadowContours(Bitmap source) + { + using SKPath path = CreateContourPath(source); + Bitmap result = CreateReferenceBitmap(); + using var canvas = new SKCanvas(result.SKBitmap); + canvas.Clear(SKColors.Transparent); + using var paint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + + float x = MathF.Cos(MathF.PI * 31 / 180); + float y = MathF.Sin(MathF.PI * 31 / 180); + for (int i = 0; i < 13; i++) + { + canvas.Translate(x, y); + canvas.DrawPath(path, paint); + } + + return result; + } + + private static Bitmap RenderStrokeContours(Bitmap source) + { + using SKPath path = CreateContourPath(source); + Bitmap result = CreateReferenceBitmap(); + using var canvas = new SKCanvas(result.SKBitmap); + canvas.Clear(SKColors.Transparent); + canvas.Translate(4, 3); + using var paint = new SKPaint + { + Color = SKColors.Blue, + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = 7, + }; + canvas.DrawPath(path, paint); + return result; + } + + private static Bitmap RenderSplitContours(Bitmap source) + { + List paths = CreateSplitPaths(source); + try + { + Bitmap result = CreateReferenceBitmap(); + using var canvas = new SKCanvas(result.SKBitmap); + canvas.Clear(SKColors.Transparent); + using var paint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + SKColor[] colors = [SKColors.Red, SKColors.Green, SKColors.Blue, SKColors.Yellow]; + for (int i = 0; i < paths.Count; i++) + { + paint.Color = colors[i % colors.Length]; + canvas.DrawPath(paths[i], paint); + } + + return result; + } + finally + { + foreach (SKPath path in paths) + path.Dispose(); + } + } + + private static SKPath CreateContourPath(Bitmap source) + { + using Contours contours = ContourTracer.FindContours(source); + var path = new SKPath(); + for (int contourIndex = 0; contourIndex < contours.Count; contourIndex++) + { + ReadOnlySpan contour = contours[contourIndex]; + for (int i = 0; i < contour.Length; i++) + { + if (i == 0) + path.MoveTo(contour[i].X, contour[i].Y); + else + path.LineTo(contour[i].X, contour[i].Y); + } + path.Close(); + } + + return path; + } + + private static List CreateSplitPaths(Bitmap source) + { + ContourTracer.FindContoursWithHierarchy(source, out Contours contours, out var parentIndices); + using (contours) + using (parentIndices) + { + var paths = new List<(SKPath Path, int Parent, int Index)>(contours.Count); + for (int i = 0; i < contours.Count; i++) + { + ReadOnlySpan contour = contours[i]; + var path = new SKPath(); + for (int j = 0; j < contour.Length; j++) + { + if (j == 0) + path.MoveTo(contour[j].X, contour[j].Y); + else + path.LineTo(contour[j].X, contour[j].Y); + } + path.Close(); + paths.Add((path, parentIndices[i], i)); + } + + for (int i = 0; i < paths.Count; i++) + { + (SKPath path, int parent, int _) = paths[i]; + if (parent < 0) + continue; + + int parentIndex = paths.FindIndex(item => item.Index == parent); + if (parentIndex < 0) + continue; + + (SKPath parentPath, int grandParent, int originalIndex) = paths[parentIndex]; + SKPath? merged = parentPath.Op(path, SKPathOp.Xor); + if (merged is null) + continue; + + path.Dispose(); + parentPath.Dispose(); + paths[parentIndex] = (merged, grandParent, originalIndex); + paths.RemoveAt(i); + if (parentIndex < i) + i--; + } + + return paths.Select(static item => item.Path).ToList(); + } + } + + private static Bitmap CreateReferenceBitmap() + => new( + 96, + 80, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + + private static RenderTarget CreatePatternTarget() + { + RenderTarget target = RenderTarget.Create(s_sourceSize.Width, s_sourceSize.Height) + ?? throw new InvalidOperationException("Could not create the GPU filter-effect source target."); + SKCanvas canvas = target.Value.Canvas; + canvas.Clear(SKColors.Transparent); + + using var paint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true, + BlendMode = SKBlendMode.Src, + }; + canvas.DrawRoundRect(new SKRect(5.25f, 4.5f, 41.75f, 46.25f), 5, 5, paint); + canvas.DrawOval(new SKRect(48.5f, 12.25f, 67.25f, 35.75f), paint); + + paint.BlendMode = SKBlendMode.Clear; + canvas.DrawOval(new SKRect(16.5f, 16.25f, 30.75f, 33.5f), paint); + return target; + } + + private static RenderTarget CreateAlphaRampTarget() + { + const int width = 256; + const int height = 8; + RenderTarget target = RenderTarget.Create(width, height) + ?? throw new InvalidOperationException("Could not create the GPU alpha-ramp target."); + SKCanvas canvas = target.Value.Canvas; + canvas.Clear(SKColors.Transparent); + + using var paint = new SKPaint + { + IsAntialias = false, + BlendMode = SKBlendMode.Src, + }; + for (int x = 0; x < width; x++) + { + paint.Color = new SKColor(255, 255, 255, (byte)x); + canvas.DrawRect(x, 0, 1, 4, paint); + } + + paint.Color = SKColors.White; + paint.IsAntialias = true; + canvas.DrawLine(0.25f, 7.25f, 255.75f, 4.25f, paint); + return target; + } + + private static void AssertRowsIdentical(Bitmap expected, Bitmap actual, string expectedPath, string actualPath) + { + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + for (int y = 0; y < expected.Height; y++) + { + Assert.That( + actual.GetRow(y).SequenceEqual(expected.GetRow(y)), + Is.True, + $"row {y} differs between {expectedPath} and {actualPath}"); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ColorFilterIslandSplitTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ColorFilterIslandSplitTests.cs new file mode 100644 index 0000000000..8558907ee3 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ColorFilterIslandSplitTests.cs @@ -0,0 +1,424 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +/// +/// Records how migrated color adjustments land in the compiled plan as CurrentPixel shader stages. +/// +/// +/// This is a characterization test: a Skia blur remains a compatibility segment, while a following migrated +/// adjustment becomes a shader run after coverage resolution. Adjacent CurrentPixel adjustments fuse together, +/// including the resource-heavy Curves case under the Vulkan profile. +/// +[TestFixture] +public sealed class ColorFilterIslandSplitTests +{ + private static readonly Rect s_bounds = new(0, 0, 24, 16); + + /// + /// Saturate now follows the same CurrentPixel path as Brightness: the blur stays in its compatibility segment, + /// and Saturate starts a shader run once that segment has resolved coverage. + /// + [Test] + public void BlurThenSaturate_CompilesAfterTheSingleTargetSegment() + { + using CompiledRenderRequest compiled = Compile( + new Blur { Sigma = { CurrentValue = new(2, 2) } }, + new Saturate { Amount = { CurrentValue = 50f } }); + + Report("Blur -> Saturate (shader stage)", compiled); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compiled.ExecutionPlan.Islands, Has.Length.EqualTo(3)); + Assert.That( + compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] + { + ExecutionIslandKind.Compatibility, + ExecutionIslandKind.Compatibility, + ExecutionIslandKind.ShaderRun, + })); + Assert.That(Reasons(compiled), Is.EqualTo(new[] + { + ExecutionIslandBoundaryReason.Opaque, + ExecutionIslandBoundaryReason.FilterEffectSegment, + ExecutionIslandBoundaryReason.CoverageResolution, + })); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(1)); + } + } + + /// + /// Brightness is a shader stage, so it cannot be folded into the blur's segment. The blur stays in a + /// compatibility island, while Brightness becomes a one-stage shader run after the segment has resolved + /// its coverage. + /// + /// + /// A pure Skia segment preserves its one input target and therefore publishes + /// RenderValueCardinality.Single. That permits the downstream CurrentPixel stage to compile. The + /// boundary after the segment is CoverageResolution, because the Skia materialization establishes + /// the coverage that Brightness consumes; it is not a topology rejection. + /// + [Test] + public void BlurThenBrightness_CompilesAfterTheSingleTargetSegment() + { + using CompiledRenderRequest compiled = Compile( + new Blur { Sigma = { CurrentValue = new(2, 2) } }, + new Brightness { Amount = { CurrentValue = 50f } }); + + Report("Blur -> Brightness (shader stage)", compiled); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compiled.ExecutionPlan.Islands, Has.Length.EqualTo(3)); + Assert.That( + compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] + { + ExecutionIslandKind.Compatibility, + ExecutionIslandKind.Compatibility, + ExecutionIslandKind.ShaderRun, + })); + Assert.That(Reasons(compiled), Is.EqualTo(new[] + { + ExecutionIslandBoundaryReason.Opaque, + ExecutionIslandBoundaryReason.FilterEffectSegment, + ExecutionIslandBoundaryReason.CoverageResolution, + })); + + // The blur segment is what forces the split, so no custom effect is blamed for it. + Assert.That(Reasons(compiled), Does.Not.Contain(ExecutionIslandBoundaryReason.LegacyCustomEffect)); + + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(1)); + } + } + + [Test] + public void BlurThenBrightness_ExecutesTheShaderRunFromOneSegmentTarget() + { + using Bitmap disabled = RenderBlurThenBrightness(FusionMode.Disabled, out _); + using Bitmap enabled = RenderBlurThenBrightness( + FusionMode.Enabled, + out RenderExecutionStatistics statistics); + + Assert.Multiple(() => + { + Assert.That(enabled.GetPixelSpan().SequenceEqual(disabled.GetPixelSpan()), Is.True); + Assert.That(enabled.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + Assert.That(statistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(statistics.ShaderStageExecutions, Is.EqualTo(1)); + }); + } + + /// + /// Two adjacent CurrentPixel stages do fuse: Brightness followed by Gamma is a single shader run holding + /// both stages, with no boundary between them. + /// + [Test] + public void BrightnessThenGamma_FusesIntoASingleTwoStageShaderRun() + { + using CompiledRenderRequest compiled = Compile( + new Brightness { Amount = { CurrentValue = 50f } }, + new Gamma { Amount = { CurrentValue = 220f } }); + + Report("Brightness -> Gamma", compiled); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compiled.ExecutionPlan.Islands, Has.Length.EqualTo(2)); + Assert.That( + compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] { ExecutionIslandKind.Compatibility, ExecutionIslandKind.ShaderRun })); + + // Only the source-materialization boundaries; nothing separates the two color stages. + Assert.That(Reasons(compiled), Is.EqualTo(new[] + { + ExecutionIslandBoundaryReason.Opaque, + ExecutionIslandBoundaryReason.CoverageResolution, + })); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(2)); + } + } + + /// + /// Curves binds nine curve resources in addition to the implicit source. The Portable policy admits those ten + /// resources under its 12/12 budget, so Curves can remain in the same shader run as the two color stages that + /// follow it while four slots below the backend guarantee remain reserved for Skia's surrounding program. + /// + [Test] + public void CurvesThenBrightnessThenGamma_FusesWithinThePortableBudget() + { + using CompiledRenderRequest compiled = Compile( + SkslBackendBudgetResolver.Portable, + new Curves(), + new Brightness { Amount = { CurrentValue = 50f } }, + new Gamma { Amount = { CurrentValue = 220f } }); + + Report("Curves -> Brightness -> Gamma", compiled); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compiled.ExecutionPlan.Islands, Has.Length.EqualTo(2)); + Assert.That( + compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] { ExecutionIslandKind.Compatibility, ExecutionIslandKind.ShaderRun })); + + // Only source materialization remains; no backend limit separates the three shader stages. + Assert.That(Reasons(compiled), Is.EqualTo(new[] + { + ExecutionIslandBoundaryReason.Opaque, + ExecutionIslandBoundaryReason.CoverageResolution, + })); + Assert.That(Reasons(compiled), Does.Not.Contain(ExecutionIslandBoundaryReason.FilterEffectSegment)); + Assert.That(Reasons(compiled), Does.Not.Contain(ExecutionIslandBoundaryReason.BackendLimit)); + + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(3)); + } + } + + [Test] + public void MosaicGammaOpacityInvert_CompileAsOneWholeSourceHeadedRun() + { + using CompiledRenderRequest compiled = CompileMosaicGammaOpacityInvert(FusionMode.Enabled); + + Report("Mosaic -> Gamma -> Opacity -> Invert", compiled); + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + TestContext.WriteLine("Generated SkSL:\n" + run.Program.Source); + using (Assert.EnterMultipleScope()) + { + Assert.That(compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] { ExecutionIslandKind.Compatibility, ExecutionIslandKind.ShaderRun })); + Assert.That(Reasons(compiled), Is.EqualTo(new[] + { + ExecutionIslandBoundaryReason.Opaque, + ExecutionIslandBoundaryReason.CoverageResolution, + })); + Assert.That(Reasons(compiled), Does.Not.Contain(ExecutionIslandBoundaryReason.WholeSourceShader)); + Assert.That(run.Stages.Select(static stage => stage.Kind), + Is.EqualTo(new[] + { + RenderFragmentKind.Shader, + RenderFragmentKind.Shader, + RenderFragmentKind.Opacity, + RenderFragmentKind.Shader, + })); + Assert.That(run.Stages.Select(static stage => stage.Description.Kind), + Is.EqualTo(new[] + { + ShaderDescriptionKind.WholeSource, + ShaderDescriptionKind.CurrentPixel, + ShaderDescriptionKind.CurrentPixel, + ShaderDescriptionKind.CurrentPixel, + })); + Assert.That(run.WholeSourceHead, Is.SameAs(run.Stages[0].Description)); + Assert.That(run.Output.Bounds, Is.EqualTo(run.Stages[0].Fragment.Bounds)); + Assert.That(run.Output.EffectiveScale, Is.EqualTo(run.Stages[0].Fragment.EffectiveScale)); + } + } + + [Test] + public void ColorShiftHead_MapsRequestedRegionBackToItsInput() + { + Rect requestedRegion = new(8, 5, 6, 4); + var colorShift = new ColorShift(); + colorShift.RedOffset.CurrentValue = new PixelPoint(3, 0); + colorShift.GreenOffset.CurrentValue = new PixelPoint(-2, 0); + colorShift.BlueOffset.CurrentValue = new PixelPoint(0, 2); + colorShift.AlphaOffset.CurrentValue = new PixelPoint(0, -1); + + using CompiledRenderRequest compiled = Compile( + FusionMode.Enabled, + requestedRegion, + colorShift, + new Gamma { Amount = { CurrentValue = 180f } }); + + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + CompiledShaderStage head = run.Stages[0]; + Rect headRequirement = compiled.Regions.GetFragmentRequirement(head.Fragment).Resolve(head.Fragment.Bounds); + Rect expectedInput = head.Description.Bounds + .GetRequiredInputBounds(headRequirement) + .Intersect(run.Input.Bounds); + + Assert.Multiple(() => + { + Assert.That(run.WholeSourceHead, Is.SameAs(head.Description)); + Assert.That(headRequirement, Is.EqualTo(requestedRegion)); + Assert.That(expectedInput, Is.EqualTo(new Rect(5, 3, 11, 7))); + Assert.That(compiled.Regions.GetFragmentRequirement(run.Input), + Is.EqualTo(RequiredRegion.Region(expectedInput))); + }); + } + + private static void Report(string label, CompiledRenderRequest compiled) + { + TestContext.WriteLine( + $"{label}: {compiled.ExecutionPlan.Islands.Length} islands " + + $"[{string.Join(", ", compiled.ExecutionPlan.Islands.Select(static island => island.Kind))}], " + + $"boundary reasons [{string.Join(", ", Reasons(compiled))}], " + + $"shader runs {compiled.ExecutionPlan.ShaderRuns.Count()} " + + $"[{string.Join(", ", compiled.ExecutionPlan.ShaderRuns.Select(static run => run.Stages.Length))}]"); + } + + private static ExecutionIslandBoundaryReason[] Reasons(CompiledRenderRequest compiled) + => [.. compiled.ExecutionPlan.Boundaries.Select(static boundary => boundary.Reason)]; + + private static Bitmap RenderBlurThenBrightness( + FusionMode fusionMode, + out RenderExecutionStatistics statistics) + { + var group = new FilterEffectGroup + { + Children = + { + new Blur { Sigma = { CurrentValue = new(2, 2) } }, + new Brightness { Amount = { CurrentValue = 50 } }, + }, + }; + using FilterEffect.Resource resource = group.ToResource(CompositionContext.Default); + using var node = new FilterEffectRenderNode(resource); + node.AddChild(new EllipseRenderNode(s_bounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + statistics = renderer.LastExecutionStatistics; + return rasterization.Bitmap?.Clone() + ?? throw new InvalidOperationException("The filter-effect render produced no bitmap."); + } + + private static CompiledRenderRequest Compile(params FilterEffect[] effects) + => Compile(SkslBackendBudgetResolver.Portable, effects); + + private static CompiledRenderRequest Compile( + FusionMode fusionMode, + Rect? requestedRegion, + params FilterEffect[] effects) + => Compile(SkslBackendBudgetResolver.Portable, fusionMode, requestedRegion, effects); + + private static CompiledRenderRequest Compile( + SkslBackendBudget budget, + params FilterEffect[] effects) + => Compile(budget, FusionMode.Enabled, requestedRegion: null, effects); + + private static CompiledRenderRequest Compile( + SkslBackendBudget budget, + FusionMode fusionMode, + Rect? requestedRegion, + params FilterEffect[] effects) + { + var group = new FilterEffectGroup(); + foreach (FilterEffect effect in effects) + group.Children.Add(effect); + + using FilterEffect.Resource resource = group.ToResource(CompositionContext.Default); + using var node = new FilterEffectRenderNode(resource); + node.AddChild(new EllipseRenderNode(s_bounds, Brushes.Resource.White, null)); + + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: requestedRegion, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: fusionMode)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler().Compile(request, graph, budget); + } + catch + { + request.Dispose(); + throw; + } + } + + private static CompiledRenderRequest CompileMosaicGammaOpacityInvert(FusionMode fusionMode) + { + var mosaic = new MosaicEffect(); + mosaic.TileSize.CurrentValue = new Size(10, 10); + mosaic.Origin.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); + var headEffects = new FilterEffectGroup + { + Children = + { + mosaic, + new Gamma { Amount = { CurrentValue = 180f } }, + }, + }; + var tailEffects = new FilterEffectGroup + { + Children = + { + new Invert + { + Amount = { CurrentValue = 65f }, + ExcludeAlphaChannel = { CurrentValue = true }, + }, + }, + }; + + using FilterEffect.Resource headResource = headEffects.ToResource(CompositionContext.Default); + using FilterEffect.Resource tailResource = tailEffects.ToResource(CompositionContext.Default); + var head = new FilterEffectRenderNode(headResource); + head.AddChild(new RectangleRenderNode(s_bounds, Brushes.Resource.White, null)); + var opacity = new OpacityRenderNode(0.625f); + opacity.AddChild(head); + using var root = new FilterEffectRenderNode(tailResource); + root.AddChild(opacity); + + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: fusionMode)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return new RenderRequestCompiler().Compile(request, graph, SkslBackendBudgetResolver.Portable); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize size = allocation.DeviceSize; + SKSurface surface = SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU filter-effect test surface."); + return new CpuRenderTarget(surface, size); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs new file mode 100644 index 0000000000..8a13fa9405 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs @@ -0,0 +1,925 @@ +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering.Golden; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +[NonParallelizable] +public sealed class CrossNodeShaderFusionTests +{ + private static readonly Rect s_bounds = new(3, 5, 12, 8); + + [Test] + public void Enabled_CompilesDistinctShaderOpacityShaderNodesAsOneRun() + { + using CompiledRenderRequest compiled = CompilePrimaryChain(FusionMode.Enabled); + + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.Islands, Has.Length.EqualTo(1)); + Assert.That(run.Stages.Select(static stage => stage.Kind), Is.EqualTo(new[] + { + RenderFragmentKind.Shader, + RenderFragmentKind.Opacity, + RenderFragmentKind.Shader, + })); + Assert.That(run.Stages.Select(static stage => stage.CoverageBehavior), Is.EqualTo(new[] + { + SkslCoverageBehavior.RequiresResolvedCoverage, + SkslCoverageBehavior.PremultipliedCoverageHomogeneous, + SkslCoverageBehavior.RequiresResolvedCoverage, + })); + Assert.That(run.Program.StageCount, Is.EqualTo(3)); + Assert.That(run.IsFused, Is.True); + Assert.That(run.CoverageSource, Is.EqualTo(ShaderRunCoverageSource.MaterializedInput)); + Assert.That(run.Output.CanBeUsedAsValueInput, Is.True, + "The typed opacity descriptor must preserve value-input eligibility."); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.MaterializedInput)); + }); + + string source = run.Program.Source; + int gamma = source.IndexOf("__beutl_s0_apply", StringComparison.Ordinal); + int opacity = source.IndexOf("__beutl_s1_apply", StringComparison.Ordinal); + int invert = source.IndexOf("__beutl_s2_apply", StringComparison.Ordinal); + Assert.That(gamma, Is.GreaterThanOrEqualTo(0)); + Assert.That(opacity, Is.GreaterThan(gamma)); + Assert.That(invert, Is.GreaterThan(opacity)); + } + + [Test] + public void Disabled_KeepsIdenticalSemanticStagesButPreventsComposition() + { + using CompiledRenderRequest enabled = CompilePrimaryChain(FusionMode.Enabled); + using CompiledRenderRequest disabled = CompilePrimaryChain(FusionMode.Disabled); + + CompiledShaderStage[] enabledStages = enabled.ExecutionPlan.ShaderRuns + .SelectMany(static run => run.Stages) + .ToArray(); + CompiledShaderStage[] disabledStages = disabled.ExecutionPlan.ShaderRuns + .SelectMany(static run => run.Stages) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(enabled.Request.Options.PlanIdentity, + Is.Not.EqualTo(disabled.Request.Options.PlanIdentity)); + Assert.That(enabled.ExecutionPlan.ShaderRuns.Count(), Is.EqualTo(1)); + Assert.That(disabled.ExecutionPlan.ShaderRuns.Count(), Is.EqualTo(3)); + Assert.That(disabled.ExecutionPlan.ShaderRuns.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { 1, 1, 1 })); + Assert.That(disabledStages.Select(static stage => stage.Kind), + Is.EqualTo(enabledStages.Select(static stage => stage.Kind))); + Assert.That(disabledStages.Select(static stage => stage.Description.Source.Text), + Is.EqualTo(enabledStages.Select(static stage => stage.Description.Source.Text))); + Assert.That(disabled.ExecutionPlan.Boundaries.Count(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.FusionDisabled), + Is.EqualTo(2)); + }); + } + + [TestCase(-1f, 0f)] + [TestCase(2f, 1f)] + public void FiniteOutOfRangeOpacity_NormalizesBeforePlanningAndMatchesUnfusedExecution( + float authoredOpacity, + float expectedOpacity) + { + var targetFactory = new CpuTargetFactory(); + using RenderTarget source = targetFactory.CreateCpuTarget( + new PixelSize((int)s_bounds.Width, (int)s_bounds.Height)); + source.Value.Canvas.Clear(new SKColor(48, 112, 216, 176)); + using var enabledNode = new PrimaryChainNode(source, s_bounds, authoredOpacity); + using var disabledNode = new PrimaryChainNode(source, s_bounds, authoredOpacity); + using var expectedNode = new PrimaryChainNode(source, s_bounds, expectedOpacity); + using var enabled = CreateCpuRenderer(enabledNode, FusionMode.Enabled, targetFactory); + using var disabled = CreateCpuRenderer(disabledNode, FusionMode.Disabled, targetFactory); + using var expected = CreateCpuRenderer(expectedNode, FusionMode.Enabled, targetFactory); + + using RenderNodeRasterization enabledRaster = enabled.Rasterize(); + using RenderNodeRasterization disabledRaster = disabled.Rasterize(); + using RenderNodeRasterization expectedRaster = expected.Rasterize(); + + Assert.That(enabledRaster.Bitmap, Is.Not.Null); + Assert.That(disabledRaster.Bitmap, Is.Not.Null); + Assert.That(expectedRaster.Bitmap, Is.Not.Null); + RgbaMaximumError fusionParity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + disabledRaster.Bitmap!, + enabledRaster.Bitmap!); + RgbaMaximumError normalizedParity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + expectedRaster.Bitmap!, + enabledRaster.Bitmap!); + + Assert.Multiple(() => + { + Assert.That(enabled.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(3)); + Assert.That(enabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1), + "The normalized opacity must remain eligible for its adjacent shader run."); + Assert.That(disabled.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(3)); + Assert.That(disabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.Zero); + Assert.That(normalizedParity.Maximum, Is.Zero, + "Recording must make an out-of-range opacity identical to its clamped value."); + Assert.That(fusionParity.Maximum, Is.LessThanOrEqualTo(0.003), + "FusionMode must not change finite out-of-range opacity semantics."); + }); + } + + [Test] + public void CpuDestination_UsesPortableBudgetAndSplitsLongShaderChain() + { + int stageCount = SkslBackendBudgetResolver.Portable.MaxStages + 1; + var targetFactory = new CpuTargetFactory(); + using var node = new LongShaderChainNode(stageCount); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = targetFactory, + }); + using RenderTarget destination = targetFactory.CreateCpuTarget(new PixelSize(24, 16)); + using var canvas = new ImmediateCanvas(destination, logicalSize: new Size(24, 16)); + + Assert.That(destination.Value.Context, Is.Null); + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(2)); + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(stageCount)); + Assert.That(renderer.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ChangingFromCpuToGpuDestination_SelectsTheActualSurfaceCapabilityClass() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + int stageCount = SkslBackendBudgetResolver.Portable.MaxStages + 1; + var cpuFactory = new CpuTargetFactory(); + using var node = new LongShaderChainNode(stageCount); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + }); + using RenderTarget cpuDestination = cpuFactory.CreateCpuTarget(new PixelSize(24, 16)); + using var cpuCanvas = new ImmediateCanvas(cpuDestination, logicalSize: new Size(24, 16)); + using RenderTarget gpuDestination = RenderTarget.Create(24, 16) + ?? throw new InvalidOperationException("Could not create the GPU fusion-test surface."); + using var gpuCanvas = new ImmediateCanvas(gpuDestination, logicalSize: new Size(24, 16)); + + Assert.Multiple(() => + { + Assert.That(cpuDestination.Value.Context, Is.Null); + Assert.That( + gpuDestination.Value.Context?.Backend, + Is.AnyOf(GRBackend.Vulkan, GRBackend.Metal)); + }); + + renderer.Render(cpuCanvas); + StructuralPlanCacheStatistics cpuStatistics = renderer.StructuralPlanCacheStatistics; + renderer.Render(gpuCanvas); + StructuralPlanCacheStatistics firstGpuStatistics = renderer.StructuralPlanCacheStatistics; + renderer.Render(gpuCanvas); + StructuralPlanCacheStatistics warmedGpuStatistics = renderer.StructuralPlanCacheStatistics; + + Assert.Multiple(() => + { + Assert.That(cpuStatistics.Compilations, Is.EqualTo(1)); + Assert.That(firstGpuStatistics.Compilations, Is.EqualTo(2)); + Assert.That(firstGpuStatistics.Replacements, Is.EqualTo(1)); + Assert.That(warmedGpuStatistics.Compilations, Is.EqualTo(2)); + Assert.That(warmedGpuStatistics.Hits, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(2)); + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(stageCount)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ProgramCache_ContextChangeMissesAndThenWarmsTheNewContext() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using IGraphicsContext firstContext = GraphicsContextFactory.CreateContext(); + using IGraphicsContext secondContext = GraphicsContextFactory.CreateContext(); + using RenderTarget firstDestination = CreateContextTarget(firstContext, 24, 16); + using RenderTarget secondDestination = CreateContextTarget(secondContext, 24, 16); + using var firstCanvas = new ImmediateCanvas( + firstDestination, + logicalSize: new Size(24, 16)); + using var secondCanvas = new ImmediateCanvas( + secondDestination, + logicalSize: new Size(24, 16)); + using RenderTarget source = new CpuTargetFactory().CreateCpuTarget( + new PixelSize((int)s_bounds.Width, (int)s_bounds.Height)); + source.Value.Canvas.Clear(new SKColor(48, 112, 216, 176)); + using var node = new PrimaryChainNode(source, s_bounds); + using var renderer = CreateRenderer(node, FusionMode.Enabled); + + renderer.Render(firstCanvas); + ProgramCacheStatistics firstCold = renderer.ProgramCacheStatistics; + renderer.Render(firstCanvas); + ProgramCacheStatistics firstWarm = renderer.ProgramCacheStatistics; + renderer.Render(secondCanvas); + ProgramCacheStatistics secondCold = renderer.ProgramCacheStatistics; + renderer.Render(secondCanvas); + ProgramCacheStatistics secondWarm = renderer.ProgramCacheStatistics; + + Assert.Multiple(() => + { + Assert.That(firstDestination.Value.Context?.Backend, + Is.EqualTo(secondDestination.Value.Context?.Backend)); + Assert.That(firstDestination.Value.Context?.Handle, + Is.Not.EqualTo(secondDestination.Value.Context?.Handle)); + Assert.That(firstCold.Creations, Is.EqualTo(1)); + Assert.That(firstCold.Hits, Is.Zero); + Assert.That(firstCold.RetainedPrograms, Is.EqualTo(1)); + Assert.That(firstWarm.Creations, Is.EqualTo(1)); + Assert.That(firstWarm.Hits, Is.EqualTo(1)); + Assert.That(firstWarm.RetainedPrograms, Is.EqualTo(1)); + Assert.That(secondCold.Creations, Is.EqualTo(2)); + Assert.That(secondCold.Hits, Is.EqualTo(1)); + Assert.That(secondCold.Evictions, Is.EqualTo(1)); + Assert.That(secondCold.RetainedPrograms, Is.EqualTo(1), + "switching contexts must eagerly discharge the old context's program"); + Assert.That(secondWarm.Creations, Is.EqualTo(2)); + Assert.That(secondWarm.Hits, Is.EqualTo(2)); + Assert.That(secondWarm.Evictions, Is.EqualTo(1)); + Assert.That(secondWarm.RetainedPrograms, Is.EqualTo(1)); + }); + }); + } + + [Test] + public void PublishedIntermediateFanOut_IsAnExplicitDeterministicBoundary() + { + using CompiledRenderRequest compiled = CompilePrimaryChain( + FusionMode.Enabled, + publishFirstShader: true); + + CompiledShaderRun[] runs = compiled.ExecutionPlan.ShaderRuns.ToArray(); + Assert.Multiple(() => + { + Assert.That(runs, Has.Length.EqualTo(2)); + Assert.That(runs[0].Stages.Select(static stage => stage.Kind), + Is.EqualTo(new[] { RenderFragmentKind.Shader })); + Assert.That(runs[1].Stages.Select(static stage => stage.Kind), + Is.EqualTo(new[] { RenderFragmentKind.Opacity, RenderFragmentKind.Shader })); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.Branching)); + }); + } + + [Test] + public void Planner_OmitsCommittedFragmentsThatAreNotReachableFromAPublication() + { + var requestId = new RenderRequestId(1); + RenderFragmentReference source = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + RenderFragmentReference unpublished = Fragment( + RenderFragmentKind.Shader, + EffectiveScale.At(1), + new ShaderRenderFragmentPayload(description), + source); + RecordedRenderGraph graph = BuildGraph(requestId, [source, unpublished], [source]); + + ExecutionIslandPlan plan = new ExecutionIslandPlanner().Plan( + graph, + RenderRequestCompiler.ResolveRoots(graph), + FusionMode.Enabled, + SkslBackendBudget.Unlimited); + + Assert.Multiple(() => + { + Assert.That(graph.Fragments, Has.Length.EqualTo(2)); + Assert.That(plan.Islands, Is.Empty); + Assert.That(plan.Boundaries, Is.Empty); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void Enabled_ExecutesDistinctNodePrimaryChainOnce_WithParityAndAWarmedProgram() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreateSourceTarget(); + using var node = new PrimaryChainNode(source, s_bounds); + using var enabled = CreateRenderer(node, FusionMode.Enabled); + using var disabled = CreateRenderer(node, FusionMode.Disabled); + + using RenderNodeRasterization disabledRaster = disabled.Rasterize(); + using RenderNodeRasterization enabledRaster = enabled.Rasterize(); + using RenderNodeRasterization warmedRaster = enabled.Rasterize(); + + Assert.That(disabledRaster.Bitmap, Is.Not.Null); + Assert.That(enabledRaster.Bitmap, Is.Not.Null); + Assert.That(warmedRaster.Bitmap, Is.Not.Null); + RgbaMaximumError parity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + disabledRaster.Bitmap!, + enabledRaster.Bitmap!); + RgbaMaximumError warmedParity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + enabledRaster.Bitmap!, + warmedRaster.Bitmap!); + double ssim = ImageMetrics.Ssim(disabledRaster.Bitmap!, enabledRaster.Bitmap!); + double energy = SumAbsoluteChannels(enabledRaster.Bitmap!); + + Assert.Multiple(() => + { + Assert.That(energy, Is.GreaterThan(1), "the execution oracle must not be transparent or vacuous"); + Assert.That(ssim, Is.GreaterThanOrEqualTo(0.99)); + Assert.That(parity.Maximum, Is.LessThanOrEqualTo(0.0025)); + Assert.That(warmedParity.Maximum, Is.Zero); + Assert.That(enabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabled.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(3)); + Assert.That(enabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabled.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.Zero); + Assert.That(enabled.LastExecutionStatistics.Synchronizations, Is.Zero); + Assert.That(enabled.LastExecutionStatistics.ProgramCacheHits, Is.EqualTo(1)); + Assert.That(enabled.ProgramCacheStatistics.Creations, Is.EqualTo(1)); + Assert.That(enabled.ProgramCacheStatistics.Hits, Is.EqualTo(1)); + Assert.That(enabled.TargetPoolStatistics.Creates, Is.EqualTo(1)); + Assert.That(enabled.TargetPoolStatistics.Misses, Is.EqualTo(1)); + Assert.That(enabled.TargetPoolStatistics.Reuses, Is.EqualTo(1)); + Assert.That(enabled.TargetPoolStatistics.LeasedTargets, Is.Zero); + Assert.That(disabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(3)); + Assert.That(disabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.Zero); + Assert.That(enabled.Options.DefaultRequest.FusionMode, Is.EqualTo(FusionMode.Enabled)); + Assert.That(disabled.Options.DefaultRequest.FusionMode, Is.EqualTo(FusionMode.Disabled)); + Assert.That(enabled.StructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(enabled.StructuralPlanCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(enabled.StructuralPlanCacheStatistics.Hits, Is.EqualTo(1)); + Assert.That(disabled.StructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(disabled.StructuralPlanCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(disabled.StructuralPlanCacheStatistics.Hits, Is.Zero); + Assert.That(node.ProcessCounts, Is.EqualTo(new[] { 3, 3, 3 }), + "Each render must traverse the countable source, Gamma, and Invert node transactions."); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void UnboundedVectorShaderRoot_DrawsTerminalRunDirectlyAndCacheForcesMaterialization() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var directNode = new VectorTerminalShaderNode(publishTwice: false); + using var direct = CreateRenderer( + directNode, + FusionMode.Enabled); + using RenderNodeRasterization directRaster = direct.Rasterize(); + + using var cachedNode = new VectorTerminalShaderNode(publishTwice: false); + cachedNode.Cache.RecordStableRequests(); + using var cached = CreateRenderer( + cachedNode, + FusionMode.Enabled, + useRenderCache: true); + using RenderNodeRasterization missRaster = cached.Rasterize(); + RenderExecutionStatistics missStatistics = cached.LastExecutionStatistics; + using RenderNodeRasterization hitRaster = cached.Rasterize(); + + Assert.That(directRaster.Bitmap, Is.Not.Null); + Assert.That(missRaster.Bitmap, Is.Not.Null); + Assert.That(hitRaster.Bitmap, Is.Not.Null); + RgbaMaximumError missParity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + directRaster.Bitmap!, + missRaster.Bitmap!); + RgbaMaximumError hitParity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + directRaster.Bitmap!, + hitRaster.Bitmap!); + + Assert.Multiple(() => + { + Assert.That(directNode.OutputScale.IsUnbounded, Is.True); + Assert.That(direct.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(direct.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.EqualTo(1), + "Only the vector coverage input should materialize; the terminal Shader writes the root target."); + Assert.That(cachedNode.Cache.IsCached, Is.True); + Assert.That(missStatistics.IntermediateTargetAcquisitions, Is.GreaterThan(1), + "A selected cache capture must force a materialized terminal Shader value."); + Assert.That(missParity.Maximum, Is.LessThanOrEqualTo(0.02)); + Assert.That(hitParity.Maximum, Is.LessThanOrEqualTo(0.02)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void PublishedShaderFanOut_DisablesTerminalDirectDraw() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var node = new VectorTerminalShaderNode(publishTwice: true); + using var renderer = CreateRenderer(node, FusionMode.Enabled); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.Bitmap, Is.Not.Null); + Assert.That(node.OutputScale.IsUnbounded, Is.True); + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.EqualTo(2), + "Fan-out must materialize both the vector input and terminal Shader output exactly once."); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void TerminalDirectDraw_PreservesActiveDestinationState_WithCacheMissAsReference() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreateSourceTarget(); + using var directNode = new PrimaryChainNode(source, s_bounds); + using var cachedNode = new PrimaryChainNode(source, s_bounds); + cachedNode.Cache.RecordStableRequests(); + using var direct = CreateRenderer(directNode, FusionMode.Enabled); + using var cached = CreateRenderer(cachedNode, FusionMode.Enabled, useRenderCache: true); + + using Bitmap directBitmap = RenderWithActiveDestinationState(direct); + using Bitmap cachedBitmap = RenderWithActiveDestinationState(cached); + using Bitmap background = CreateActiveDestinationBackground(); + RgbaMaximumError parity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + directBitmap, + cachedBitmap); + RgbaMaximumError directChange = ImageMetrics.MaximumAbsoluteErrorPerChannel( + directBitmap, + background); + RgbaMaximumError cachedChange = ImageMetrics.MaximumAbsoluteErrorPerChannel( + cachedBitmap, + background); + + Assert.Multiple(() => + { + Assert.That(direct.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.Zero); + Assert.That(cached.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.GreaterThan(0)); + Assert.That(cachedNode.Cache.IsCached, Is.True); + Assert.That(directChange.Maximum, Is.GreaterThan(0.02), + "The terminal direct draw must modify the cleared destination."); + Assert.That(cachedChange.Maximum, Is.GreaterThan(0.02), + "The cache-materialized terminal draw must modify the cleared destination."); + Assert.That(parity.Maximum, Is.LessThanOrEqualTo(0.02)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void TerminalDirectDraw_FractionalDestinationFallsBackToMaterializedComposite() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = CreateSourceTarget(); + using var directNode = new PrimaryChainNode(source, s_bounds); + using var cachedNode = new PrimaryChainNode(source, s_bounds); + cachedNode.Cache.RecordStableRequests(); + using var direct = CreateRenderer(directNode, FusionMode.Enabled); + using var cached = CreateRenderer(cachedNode, FusionMode.Enabled, useRenderCache: true); + + using Bitmap directBitmap = RenderWithDestinationTranslation(direct, 2.25f, 1.5f); + using Bitmap cachedBitmap = RenderWithDestinationTranslation(cached, 2.25f, 1.5f); + RgbaMaximumError parity = ImageMetrics.MaximumAbsoluteErrorPerChannel( + directBitmap, + cachedBitmap); + + Assert.Multiple(() => + { + Assert.That(direct.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.GreaterThan(0), + "A fractional device origin must disable the terminal direct draw."); + Assert.That(cached.LastExecutionStatistics.IntermediateTargetAcquisitions, Is.GreaterThan(0)); + Assert.That(parity.Maximum, Is.LessThanOrEqualTo(0.02)); + }); + }); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + FusionMode fusionMode, + bool useRenderCache = false) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + private static RenderNodeRenderer CreateCpuRenderer( + RenderNode node, + FusionMode fusionMode, + IRenderTargetFactory targetFactory) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = targetFactory, + }); + + private static Bitmap RenderWithActiveDestinationState(RenderNodeRenderer renderer) + => RenderWithDestinationTranslation(renderer, 2, 1); + + private static Bitmap CreateActiveDestinationBackground() + { + using RenderTarget target = RenderTarget.Create(32, 24) + ?? throw new InvalidOperationException("Could not allocate the active-state background control."); + target.Value.Canvas.Clear(new SKColor(26, 48, 72, 255)); + return target.Snapshot(); + } + + private static Bitmap RenderWithDestinationTranslation( + RenderNodeRenderer renderer, + float x, + float y) + { + using RenderTarget target = RenderTarget.Create(32, 24) + ?? throw new InvalidOperationException("Could not allocate the active-state destination."); + using var canvas = new ImmediateCanvas(target, logicalSize: new Size(32, 24)); + canvas.Clear(new Color(255, 26, 48, 72)); + using (canvas.PushTransform(Matrix.CreateTranslation(x, y))) + using (canvas.PushClip(new Rect(4, 5, 10, 7))) + using (canvas.PushOpacity(0.625f)) + using (canvas.PushBlendMode(BlendMode.Screen)) + { + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + private static RenderTarget CreateSourceTarget() + { + RenderTarget target = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not allocate the deterministic fusion source."); + using var paint = new SKPaint + { + Color = new SKColor(48, 112, 216, 176), + IsAntialias = false, + }; + target.Value.Canvas.Clear(new SKColor(12, 24, 40, 96)); + target.Value.Canvas.DrawRect(SKRect.Create(2, 1, 8, 6), paint); + return target; + } + + private static RenderTarget CreateContextTarget( + IGraphicsContext context, + int width, + int height) + { + ITexture2D texture = context.CreateTexture2D( + width, + height, + TextureFormat.RGBA16Float); + try + { + return new TextureRenderTarget(texture); + } + catch + { + texture.Dispose(); + throw; + } + } + + private static double SumAbsoluteChannels(Bitmap bitmap) + { + double result = 0; + foreach (ushort bits in bitmap.GetPixelSpan()) + result += Math.Abs((float)BitConverter.UInt16BitsToHalf(bits)); + return result; + } + + private sealed class PrimaryChainNode : RenderNode + { + private static readonly ShaderDescription s_gamma = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(sqrt(max(color.rgb, half3(0))), color.a); }"); + + private static readonly ShaderDescription s_invert = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.a - color.rgb, color.a); }"); + + private readonly MaterializedSourceNode _source; + private readonly ShaderStageNode _gamma = new(s_gamma); + private readonly OpacityRenderNode _opacity; + private readonly ShaderStageNode _invert = new(s_invert); + + public PrimaryChainNode(RenderTarget source, Rect bounds, float opacity = 0.625f) + { + _source = new MaterializedSourceNode(source, bounds); + _opacity = new OpacityRenderNode(opacity); + } + + public int[] ProcessCounts => + [ + _source.ProcessCalls, + _gamma.ProcessCalls, + _invert.ProcessCalls, + ]; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle current = context.RecordNode(_source, []).Single(); + current = context.RecordNode(_gamma, [current]).Single(); + current = context.RecordNode(_opacity, [current]).Single(); + current = context.RecordNode(_invert, [current]).Single(); + context.Publish(current); + } + + protected override void OnDispose(bool disposing) + { + _invert.Dispose(); + _opacity.Dispose(); + _gamma.Dispose(); + _source.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class MaterializedSourceNode(RenderTarget source, Rect bounds) : RenderNode + { + public int ProcessCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + ProcessCalls++; + RenderResource target = context.Borrow(source); + context.Publish(context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + target, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds))); + } + } + + private sealed class ShaderStageNode(ShaderDescription description) : RenderNode + { + public int ProcessCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + ProcessCalls++; + Assert.That(context.Inputs, Has.Exactly(1).Items); + context.Publish(context.Shader(context.Inputs[0], description)); + } + } + + private sealed class LongShaderChainNode : RenderNode + { + private static readonly ShaderDescription s_shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + + private readonly RectangleRenderNode _source = new( + new Rect(3, 5, 12, 8), + Brushes.Resource.White, + pen: null); + private readonly ShaderStageNode[] _stages; + + public LongShaderChainNode(int stageCount) + { + _stages = Enumerable.Range(0, stageCount) + .Select(static _ => new ShaderStageNode(s_shader)) + .ToArray(); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle current = context.RecordNode(_source, []).Single(); + foreach (ShaderStageNode stage in _stages) + current = context.RecordNode(stage, [current]).Single(); + context.Publish(current); + } + + protected override void OnDispose(bool disposing) + { + foreach (ShaderStageNode stage in _stages) + stage.Dispose(); + _source.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget CreateCpuTarget(PixelSize deviceSize) + => CreateCore(deviceSize); + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => CreateCore(allocation.DeviceSize); + + private static RenderTarget CreateCore(PixelSize deviceSize) + { + SKSurface surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU fusion-test surface."); + return new CpuRenderTarget(surface, deviceSize); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height) + { + } + + private sealed class TextureRenderTarget : RenderTarget + { + private ITexture2D? _texture; + + public TextureRenderTarget(ITexture2D texture) + : base(texture.CreateSkiaSurface(), texture.Width, texture.Height) + { + _texture = texture; + } + + protected override void Dispose(bool disposing) + { + try + { + base.Dispose(disposing); + } + finally + { + if (disposing) + Interlocked.Exchange(ref _texture, null)?.Dispose(); + } + } + } + + private sealed class VectorTerminalShaderNode(bool publishTwice) : RenderNode + { + private static readonly ShaderDescription s_shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }"); + + private readonly EllipseRenderNode _source = new( + new Rect(4.25f, 6.5f, 9.5f, 5.75f), + Brushes.Resource.White, + pen: null); + private readonly ShaderStageNode _shader = new(s_shader); + + public EffectiveScale OutputScale { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle current = context.RecordNode(_source, []).Single(); + current = context.RecordNode(_shader, [current]).Single(); + if (!current.TryGetMetadata(out RenderFragmentMetadata metadata)) + throw new InvalidOperationException("The finite shader output must expose concrete metadata."); + OutputScale = metadata.EffectiveScale; + context.Publish(current); + if (publishTwice) + context.Publish(current); + } + + protected override void OnDispose(bool disposing) + { + _shader.Dispose(); + _source.Dispose(); + base.OnDispose(disposing); + } + } + + private static CompiledRenderRequest CompilePrimaryChain( + FusionMode fusionMode, + bool publishFirstShader = false) + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + fusionMode: fusionMode); + var request = new RenderRequest(options); + + RenderFragmentReference source = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + ShaderDescription gamma = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(sqrt(color.rgb), color.a); }"); + RenderFragmentReference firstShader = Fragment( + RenderFragmentKind.Shader, + EffectiveScale.At(1), + new ShaderRenderFragmentPayload(gamma), + source); + RenderFragmentReference opacity = Fragment( + RenderFragmentKind.Opacity, + EffectiveScale.At(1), + new OpacityRenderFragmentPayload( + 0.625f, + OpacityRenderNode.CreateFusionDescription(0.625f)), + firstShader); + ShaderDescription invert = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.a - color.rgb, color.a); }"); + RenderFragmentReference secondShader = Fragment( + RenderFragmentKind.Shader, + EffectiveScale.At(1), + new ShaderRenderFragmentPayload(invert), + opacity); + + RecordedRenderGraph graph = BuildGraph( + request.Id, + [source, firstShader, opacity, secondShader], + publishFirstShader ? [firstShader, secondShader] : [secondShader]); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + EffectiveScale scale, + object? payload, + params RenderFragmentReference[] inputs) + { + return new RenderFragmentReference( + kind, + s_bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs, + payload, + static _ => true); + } + + private static RecordedRenderGraph BuildGraph( + RenderRequestId requestId, + IReadOnlyList references, + IReadOnlyList roots) + { + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance(typeof(CrossNodeShaderFusionTests), "test"); + foreach (RenderFragmentReference reference in references) + { + RenderValueId[] inputs = reference.Inputs.SelectMany(static input => input.ValueIds).ToArray(); + reference.ValueIds = [builder.AddValue(inputs, provenance, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenance, reference); + } + foreach (RenderFragmentReference root in roots) + builder.PublishRoot(root.Id!.Value); + return builder.Build(); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CurvesAndLutEffectShaderTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CurvesAndLutEffectShaderTests.cs new file mode 100644 index 0000000000..54a11c697f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CurvesAndLutEffectShaderTests.cs @@ -0,0 +1,456 @@ +using System.Numerics; +using System.Text; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.Media.Source; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +[NonParallelizable] +public sealed class CurvesAndLutEffectShaderTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + public void Curves_RecordsTypedResourcesAndFusesUnderEveryCapabilityProfile() + { + var effect = new Curves(); + CurveMap masterCurve = effect.MasterCurve.CurrentValue; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + var context = new FilterEffectContext(s_bounds); + RenderResource[] tokens = []; + + try + { + effect.ApplyTo(context, resource); + + FEItem_Shader item = AssertTypedShader(context); + ShaderDescription description = item.Description; + tokens = description.Resources.Select(static binding => binding.Resource).ToArray(); + SkslMergedProgram portableProgram = SkslSnippetMerger.MergeAndSplit( + [new SkslSnippetStage(description)], + SkslBackendBudgetResolver.Portable).Single(); + SkslMergedProgram vulkanProgram = SkslSnippetMerger.MergeAndSplit( + [new SkslSnippetStage(description)], + SkslBackendBudgetResolver.Resolve(GRBackend.Vulkan)).Single(); + SkslMergedProgram metalProgram = SkslSnippetMerger.MergeAndSplit( + [new SkslSnippetStage(description)], + SkslBackendBudgetResolver.Resolve(GRBackend.Metal)).Single(); + + Assert.Multiple(() => + { + Assert.That(description.Resources, Has.Count.EqualTo(9)); + Assert.That( + description.Resources.Select(static binding => binding.CoordinateSpace), + Is.All.EqualTo(ShaderResourceCoordinateSpace.Value)); + Assert.That(portableProgram.StageCount, Is.EqualTo(1)); + Assert.That(portableProgram.SamplerCount, Is.EqualTo(10)); + Assert.That(portableProgram.ChildCount, Is.EqualTo(10)); + Assert.That(portableProgram.RequiresStandaloneExecution, Is.False); + Assert.That(portableProgram.OverflowReasons, Is.Empty); + Assert.That(vulkanProgram.RequiresStandaloneExecution, Is.False); + Assert.That(metalProgram.RequiresStandaloneExecution, Is.False); + }); + } + finally + { + context.Dispose(); + } + + Assert.That( + tokens.Select(static token => token.RegistrationState), + Is.All.EqualTo(RenderResourceRegistrationState.Released)); + using SKShader rebound = masterCurve.ToShader(); + Assert.That(rebound.Handle, Is.Not.EqualTo(IntPtr.Zero)); + } + + [TestCase(CubeFileDimension.OneDimension)] + [TestCase(CubeFileDimension.ThreeDimension)] + public void LutEffect_RecordsTypedResourceAndPreservesBorrowedLifetime( + CubeFileDimension dimension) + { + CubeSource source = CreateRedToCyanLutSource(dimension); + var effect = new LutEffect + { + Source = { CurrentValue = source }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + var lutResource = (LutEffect.Resource)resource; + CubeSource.Resource capturedSource = lutResource.Source!; + CubeFile cube = capturedSource.Cube!; + var context = new FilterEffectContext(s_bounds); + RenderResource? token = null; + + try + { + effect.ApplyTo(context, resource); + + FEItem_Shader item = AssertTypedShader(context); + ShaderDescription description = item.Description; + ShaderResourceBinding binding = description.Resources.Single(); + token = binding.Resource; + + Assert.Multiple(() => + { + Assert.That(description.Resources, Has.Count.EqualTo(1)); + Assert.That(description.Uniforms.Select(static uniform => uniform.Name), + Is.EqualTo(new[] { "lutSize", "strength" })); + Assert.That(binding.Name, Is.EqualTo("lut")); + Assert.That(binding.CoordinateSpace, Is.EqualTo(ShaderResourceCoordinateSpace.Value)); + Assert.That(lutResource.Strength, Is.EqualTo(100f)); + Assert.That(lutResource.IsEnabled, Is.True); + Assert.That(cube.Dimention, Is.EqualTo(dimension)); + if (dimension == CubeFileDimension.OneDimension) + Assert.That(cube.Data[0], Is.Not.EqualTo(cube.Data[1])); + }); + } + finally + { + context.Dispose(); + } + + Assert.Multiple(() => + { + Assert.That(token, Is.Not.Null); + Assert.That(token!.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + Assert.That(capturedSource.Cube, Is.SameAs(cube)); + Assert.That(cube.Data, Is.Not.Empty); + }); + } + + [TestCase(CubeFileDimension.OneDimension)] + [TestCase(CubeFileDimension.ThreeDimension)] + public void LutEffect_ReusesParsedSource(CubeFileDimension dimension) + { + SkslSource first = RecordLutSource(dimension); + SkslSource second = RecordLutSource(dimension); + + Assert.That(second, Is.SameAs(first)); + } + + [Test] + public void Curves_PortableShaderExecutionPreservesOutput() + { + var effect = new Curves + { + MasterCurve = + { + CurrentValue = new CurveMap( + [new CurveControlPoint(0, 1), new CurveControlPoint(1, 0)]), + }, + }; + + SKColor color = Render(effect, expectedShaderStages: 1); + + AssertCyan(color); + } + + [TestCase(CubeFileDimension.OneDimension)] + [TestCase(CubeFileDimension.ThreeDimension)] + public void LutEffect_CurrentPixelExecutionPreservesOutput(CubeFileDimension dimension) + { + var effect = new LutEffect + { + Source = { CurrentValue = CreateRedToCyanLutSource(dimension) }, + }; + + SKColor color = Render(effect, expectedShaderStages: 1); + + AssertCyan(color); + } + + [TestCase(CubeFileDimension.OneDimension)] + [TestCase(CubeFileDimension.ThreeDimension)] + public void LutEffect_DeferredBindingUsesTheRecordedCubeSnapshot(CubeFileDimension dimension) + { + var effect = new LutEffect + { + Source = { CurrentValue = CreateRedToCyanLutSource(dimension) }, + }; + using var effectResource = (LutEffect.Resource)effect.ToResource(CompositionContext.Default); + CubeFile cube = effectResource.Source!.Cube!; + using var firstContext = new FilterEffectContext(s_bounds); + effect.ApplyTo(firstContext, effectResource); + + for (int i = 0; i < cube.Data.Length; i++) + { + cube.Data[i] = Vector3.One - cube.Data[i]; + } + + using var secondContext = new FilterEffectContext(s_bounds); + effect.ApplyTo(secondContext, effectResource); + + SKColor first = ExecuteRecordedLut(firstContext); + SKColor second = ExecuteRecordedLut(secondContext); + + AssertCyan(first); + AssertRed(second); + } + + [TestCaseSource(nameof(ResourceBackedEffects))] + public void ResourceBackedCurrentPixelEffects_DirectCompatibilityExecution_CommitsAndReleasesResources( + Func factory) + { + FilterEffect effect = factory(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using RenderTarget backing = new CpuTargetFactory().CreateCpuTarget(new PixelSize(1, 1)); + backing.Value.Canvas.Clear(SKColors.Red); + backing.Value.Canvas.Flush(); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + new Rect(0, 0, 1, 1), + EffectiveScale.At(1), + new PixelRect(0, 0, 1, 1)), + }; + var context = new FilterEffectContext(new Rect(0, 0, 1, 1)); + RenderResource[] tokens = []; + try + { + context.ApplyTransactional(effect, resource); + tokens = ((FEItem_Shader)context.GetOrderedItems().Single()) + .Description.Resources.Select(static binding => binding.Resource).ToArray(); + Assert.That( + tokens.Select(static token => token.RegistrationState), + Is.All.EqualTo(RenderResourceRegistrationState.Pending)); + + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + activator.Apply(context); + Assert.That( + tokens.Select(static token => token.RegistrationState), + Is.All.EqualTo(RenderResourceRegistrationState.Committed)); + activator.Flush(false); + + using Bitmap bitmap = targets.Single().RenderTarget!.Snapshot(); + AssertCyan(bitmap.SKBitmap.GetPixel(0, 0)); + } + finally + { + context.Dispose(); + } + + Assert.That( + tokens.Select(static token => token.RegistrationState), + Is.All.EqualTo(RenderResourceRegistrationState.Released)); + } + + private static IEnumerable ResourceBackedEffects() + { + yield return new TestCaseData( + (Func)(() => new Curves + { + MasterCurve = + { + CurrentValue = new CurveMap( + [new CurveControlPoint(0, 1), new CurveControlPoint(1, 0)]), + }, + })) + .SetName("Curves_DirectCompatibilityResourceLifecycle"); + yield return new TestCaseData( + (Func)(() => new LutEffect + { + Source = + { + CurrentValue = CreateRedToCyanLutSource(CubeFileDimension.OneDimension), + }, + })) + .SetName("LutEffect1D_DirectCompatibilityResourceLifecycle"); + yield return new TestCaseData( + (Func)(() => new LutEffect + { + Source = + { + CurrentValue = CreateRedToCyanLutSource(CubeFileDimension.ThreeDimension), + }, + })) + .SetName("LutEffect3D_DirectCompatibilityResourceLifecycle"); + } + + private static FEItem_Shader AssertTypedShader(FilterEffectContext context) + { + IFEItem item = context.GetOrderedItems().Single(); + Assert.That(item, Is.TypeOf()); + var shader = (FEItem_Shader)item; + Assert.That(shader.Description.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + return shader; + } + + private static SkslSource RecordLutSource(CubeFileDimension dimension) + { + var effect = new LutEffect + { + Source = { CurrentValue = CreateRedToCyanLutSource(dimension) }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + effect.ApplyTo(context, resource); + + return AssertTypedShader(context).Description.Source; + } + + private static SKColor ExecuteRecordedLut(FilterEffectContext context) + { + using RenderTarget backing = new CpuTargetFactory().CreateCpuTarget( + new PixelSize((int)s_bounds.Width, (int)s_bounds.Height)); + backing.Value.Canvas.Clear(SKColors.Red); + backing.Value.Canvas.Flush(); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + activator.Apply(context); + activator.Flush(false); + + using Bitmap bitmap = targets.Single().RenderTarget!.Snapshot(); + return bitmap.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + } + + private static SKColor Render(FilterEffect effect, int? expectedShaderStages = null) + { + using var root = new FilterEffectRenderNode( + effect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode( + s_bounds, + Brushes.Resource.Red, + null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + if (expectedShaderStages is int expected) + { + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(expected)); + } + Bitmap? bitmap = rasterization.Bitmap; + Assert.That(bitmap, Is.Not.Null); + return bitmap!.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + } + + private static SKColor ReadCenterPixel(RenderNodeRasterization rasterization) + { + Bitmap? bitmap = rasterization.Bitmap; + Assert.That(bitmap, Is.Not.Null); + return bitmap!.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + } + + private static void AssertCyan(SKColor color) + { + Assert.Multiple(() => + { + Assert.That(color.Red, Is.LessThan(16)); + Assert.That(color.Green, Is.GreaterThan(239)); + Assert.That(color.Blue, Is.GreaterThan(239)); + Assert.That(color.Alpha, Is.GreaterThan(239)); + }); + } + + private static void AssertRed(SKColor color) + { + Assert.Multiple(() => + { + Assert.That(color.Red, Is.GreaterThan(239)); + Assert.That(color.Green, Is.LessThan(16)); + Assert.That(color.Blue, Is.LessThan(16)); + Assert.That(color.Alpha, Is.GreaterThan(239)); + }); + } + + private static CubeSource CreateRedToCyanLutSource(CubeFileDimension dimension) + { + string cubeText = dimension switch + { + CubeFileDimension.OneDimension => + """ + TITLE "invert-1d" + LUT_1D_SIZE 2 + DOMAIN_MIN 0 0 0 + DOMAIN_MAX 1 1 1 + 1 1 1 + 0 0 0 + """, + CubeFileDimension.ThreeDimension => + """ + TITLE "invert-3d" + LUT_3D_SIZE 2 + DOMAIN_MIN 0 0 0 + DOMAIN_MAX 1 1 1 + 1 1 1 + 0 1 1 + 1 0 1 + 0 0 1 + 1 1 0 + 0 1 0 + 1 0 0 + 0 0 0 + """, + _ => throw new ArgumentOutOfRangeException(nameof(dimension)), + }; + var source = new CubeSource(); + source.ReadFrom(new Uri( + "data:text/plain;base64," + + Convert.ToBase64String(Encoding.ASCII.GetBytes(cubeText + "\n")))); + return source; + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget CreateCpuTarget(PixelSize deviceSize) + => CreateCore(deviceSize); + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => CreateCore(allocation.DeviceSize); + + private static RenderTarget CreateCore(PixelSize deviceSize) + { + SKSurface surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU filter-effect test surface."); + return new CpuRenderTarget(surface, deviceSize); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs new file mode 100644 index 0000000000..3ae5684766 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs @@ -0,0 +1,599 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +[NonParallelizable] +public sealed class ExecutionIslandAuthorityTests +{ + private static readonly Rect s_bounds = new(0, 0, 24, 16); + + [Test] + [Category("GpuPassFusionGpu")] + public void TerminalOpacity_DispatchesTheCompiledRunBeforeSemanticReplay() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = FusionBoundaryExecutionTestSupport.CreatePatternSource(s_bounds); + using var node = new TerminalOpacityNode(source); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.Bitmap, Is.Not.Null); + Assert.That(FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(result.Bitmap!), + Is.GreaterThan(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.EqualTo(2)); + Assert.That(renderer.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void SameBackendCompiledRun_HasNoExecutorManagedFlush() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = FusionBoundaryExecutionTestSupport.CreatePatternSource(s_bounds); + using RenderTarget destination = RenderTarget.Create((int)s_bounds.Width, (int)s_bounds.Height) + ?? throw new InvalidOperationException("Could not allocate the flush-test destination."); + using var canvas = new ImmediateCanvas(destination, logicalSize: s_bounds.Size); + using var node = new TerminalOpacityNode(source); + using var renderer = CreateRenderer(node); + var observed = new ConcurrentQueue(); + + using (ImmediateCanvas.ObserveFlushes(observed.Enqueue)) + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(renderer.LastExecutionStatistics.Synchronizations, Is.Zero); + Assert.That(observed, Is.Empty, + "A same-backend compiled run must not hide synchronization behind canvas disposal or blits."); + }); + }); + } + + [Test] + public void OpacityOnly_IsPlannedAsASemanticGpuPassIsland() + { + using CompiledRenderRequest compiled = CompileOpacityOnly(); + RenderFragmentReference opacity = compiled.Roots.Single(); + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Is.Empty); + Assert.That(compiled.ExecutionPlan.Islands, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.TryGetMembership(opacity, out ExecutionIslandMembership membership), + Is.True); + Assert.That(membership.Island.Kind, Is.EqualTo(ExecutionIslandKind.Compatibility)); + Assert.That(membership.Island.PlansGpuPass, Is.True); + Assert.That(compiled.ExecutionPlan.Boundaries, + Has.Some.Matches(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.SemanticComposite)); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void OpacityOnly_RuntimeUsesSemanticReplayWithOneGpuPass() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = FusionBoundaryExecutionTestSupport.CreatePatternSource(s_bounds); + using Bitmap sourceBitmap = source.Snapshot(); + using var node = new OpacityOnlyNode(source); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization result = renderer.Rasterize(); + double maximumDifference = MaximumOpacityDifference(sourceBitmap, result.Bitmap!, 0.625f); + + Assert.Multiple(() => + { + Assert.That(FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(result.Bitmap!), + Is.GreaterThan(1)); + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.Zero); + Assert.That(renderer.LastExecutionStatistics.ShaderStageExecutions, Is.Zero); + Assert.That(maximumDifference, Is.LessThan(0.002), + "Semantic opacity replay must scale every premultiplied channel and alpha by 0.625."); + }); + }); + } + + private static double MaximumOpacityDifference(Bitmap source, Bitmap actual, float opacity) + { + ReadOnlySpan sourcePixels = source.GetPixelSpan(); + ReadOnlySpan actualPixels = actual.GetPixelSpan(); + Assert.That(actualPixels.Length, Is.EqualTo(sourcePixels.Length)); + double maximum = 0; + for (int index = 0; index < sourcePixels.Length; index++) + { + float sourceValue = (float)BitConverter.UInt16BitsToHalf(sourcePixels[index]); + float actualValue = (float)BitConverter.UInt16BitsToHalf(actualPixels[index]); + maximum = Math.Max(maximum, Math.Abs(actualValue - (sourceValue * opacity))); + } + + return maximum; + } + + [TestCase(false)] + [TestCase(true)] + [Category("GpuPassFusionGpu")] + [Category(TestCategories.KnownVulkanSkiaLayoutInterop)] + public void DeclaredInputReadback_IsPlannedAndCountedOnlyAtActualUse(bool opaque) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = FusionBoundaryExecutionTestSupport.CreatePatternSource(s_bounds); + using var node = new DeclaredInputReadbackNode(source, opaque); + using FusionBoundaryExecutionResult result = FusionBoundaryExecutionTestSupport.Execute( + node, + s_bounds, + FusionMode.Enabled); + + ExecutionIslandBoundaryReason semanticReason = opaque + ? ExecutionIslandBoundaryReason.Opaque + : ExecutionIslandBoundaryReason.Geometry; + Assert.Multiple(() => + { + Assert.That(FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(result.Bitmap), + Is.GreaterThan(1)); + Assert.That(result.Statistics.Synchronizations, Is.EqualTo(1)); + }); + }); + } + + [Test] + public void PlanLedger_RejectsDirectExecutionOfANonTerminalShaderStage() + { + using CompiledRenderRequest compiled = CompileTerminalOpacity(); + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + RenderFragmentReference interior = Find(compiled.Graph, run.Stages[0].FragmentId); + ExecutionIslandExecutionLedger ledger = compiled.ExecutionPlan.CreateExecutionLedger( + compiled.Graph, + compiled.Roots, + compiled.CacheResolution); + + Assert.That( + () => ledger.Begin(interior), + Throws.InvalidOperationException.With.Message.Contains("non-terminal")); + } + + [Test] + public void PlanLedger_RejectsDuplicateIslandExecution() + { + using CompiledRenderRequest compiled = CompileTerminalOpacity(); + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + ExecutionIslandExecutionLedger ledger = compiled.ExecutionPlan.CreateExecutionLedger( + compiled.Graph, + compiled.Roots, + compiled.CacheResolution); + + ExecutionIsland island = ledger.Begin(run.Output); + ledger.Complete(island); + + Assert.That( + () => ledger.Begin(run.Output), + Throws.InvalidOperationException.With.Message.Contains("more than once")); + } + + [Test] + public void PlanLedger_RejectsAReachableExecutableFragmentMissingFromThePlan() + { + using CompiledRenderRequest compiled = CompileTerminalOpacity(); + var invalid = new ExecutionIslandPlan([], compiled.ExecutionPlan.Boundaries); + + Assert.That( + () => invalid.CreateExecutionLedger( + compiled.Graph, + compiled.Roots, + compiled.CacheResolution), + Throws.InvalidOperationException.With.Message.Contains("not assigned")); + } + + [Test] + public void Plan_RejectsOneFragmentAssignedToMultipleIslands() + { + var requestId = new RenderRequestId(1); + var fragmentId = new RenderFragmentId(requestId, 1); + + Assert.That( + () => new ExecutionIslandPlan( + [ + new ExecutionIsland( + new ExecutionIslandId(1), + ExecutionIslandKind.Compatibility, + [fragmentId], + plansGpuPass: false), + new ExecutionIsland( + new ExecutionIslandId(2), + ExecutionIslandKind.Compatibility, + [fragmentId], + plansGpuPass: false), + ], + []), + Throws.ArgumentException.With.Message.Contains("more than one execution island")); + } + + [Test] + public void PlanLedger_UsesPublicationOrderInsteadOfAuthoredIslandOrder() + { + var fixture = CreateReversePublicationFixture(); + ExecutionIslandExecutionLedger ledger = fixture.Plan.CreateExecutionLedger( + fixture.Graph, + fixture.Roots, + new RenderCacheResolution([])); + + ExecutionIsland second = ledger.Begin(fixture.Second); + ledger.Complete(second); + ExecutionIsland first = ledger.Begin(fixture.First); + ledger.Complete(first); + + Assert.That(() => ledger.ValidateCompleted(), Throws.Nothing); + + ExecutionIslandExecutionLedger reversed = fixture.Plan.CreateExecutionLedger( + fixture.Graph, + fixture.Roots, + new RenderCacheResolution([])); + ExecutionIsland authoredFirst = reversed.Begin(fixture.First); + reversed.Complete(authoredFirst); + ExecutionIsland authoredSecond = reversed.Begin(fixture.Second); + Assert.That( + () => reversed.Complete(authoredSecond), + Throws.InvalidOperationException.With.Message.Contains("painter order")); + } + + [Test] + public void PlanLedger_VisitsOpacityMaskDependenciesBeforePrimaryReplay() + { + var requestId = new RenderRequestId(1); + RenderFragmentReference primarySource = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + RenderFragmentReference primary = Fragment( + RenderFragmentKind.Geometry, + EffectiveScale.At(1), + payload: null, + primarySource); + RenderFragmentReference maskSource = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + RenderFragmentReference maskDependency = Fragment( + RenderFragmentKind.Geometry, + EffectiveScale.At(1), + payload: null, + maskSource); + RenderFragmentReference opacityMask = Fragment( + RenderFragmentKind.OpacityMask, + EffectiveScale.At(1), + payload: null, + primary, + maskDependency); + ImmutableArray roots = [opacityMask]; + RecordedRenderGraph graph = BuildGraph( + requestId, + [primarySource, primary, maskSource, maskDependency, opacityMask], + roots); + var plan = new ExecutionIslandPlan( + [ + new ExecutionIsland( + new ExecutionIslandId(1), + ExecutionIslandKind.Compatibility, + [primary.Id!.Value], + plansGpuPass: true), + new ExecutionIsland( + new ExecutionIslandId(2), + ExecutionIslandKind.Compatibility, + [maskDependency.Id!.Value], + plansGpuPass: true), + new ExecutionIsland( + new ExecutionIslandId(3), + ExecutionIslandKind.Compatibility, + [opacityMask.Id!.Value], + plansGpuPass: true), + ], + []); + ExecutionIslandExecutionLedger ledger = plan.CreateExecutionLedger( + graph, + roots, + new RenderCacheResolution([])); + + ExecutionIsland dependencyIsland = ledger.Begin(maskDependency); + ledger.Complete(dependencyIsland); + ExecutionIsland primaryIsland = ledger.Begin(primary); + ledger.Complete(primaryIsland); + ExecutionIsland maskIsland = ledger.Begin(opacityMask); + ledger.Complete(maskIsland); + + Assert.That(() => ledger.ValidateCompleted(), Throws.Nothing); + } + + [Test] + public void PlanLedger_RejectsIncompleteSuccessfulExecution() + { + var fixture = CreateReversePublicationFixture(); + ExecutionIslandExecutionLedger ledger = fixture.Plan.CreateExecutionLedger( + fixture.Graph, + fixture.Roots, + new RenderCacheResolution([])); + + ExecutionIsland second = ledger.Begin(fixture.Second); + ledger.Complete(second); + + Assert.That( + () => ledger.ValidateCompleted(), + Throws.InvalidOperationException.With.Message.Contains("must complete")); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Enabled, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + private static CompiledRenderRequest CompileTerminalOpacity() + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled); + var request = new RenderRequest(options); + RenderFragmentReference source = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + ShaderDescription shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }"); + RenderFragmentReference stage = Fragment( + RenderFragmentKind.Shader, + EffectiveScale.Unbounded, + new ShaderRenderFragmentPayload(shader), + source); + RenderFragmentReference opacity = Fragment( + RenderFragmentKind.Opacity, + EffectiveScale.Unbounded, + new OpacityRenderFragmentPayload( + 0.625f, + OpacityRenderNode.CreateFusionDescription(0.625f)), + stage); + RecordedRenderGraph graph = BuildGraph(request.Id, [source, stage, opacity], [opacity]); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static CompiledRenderRequest CompileOpacityOnly() + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled); + var request = new RenderRequest(options); + RenderFragmentReference source = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + RenderFragmentReference opacity = Fragment( + RenderFragmentKind.Opacity, + EffectiveScale.At(1), + new OpacityRenderFragmentPayload( + 0.625f, + OpacityRenderNode.CreateFusionDescription(0.625f)), + source); + RecordedRenderGraph graph = BuildGraph(request.Id, [source, opacity], [opacity]); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static ( + RecordedRenderGraph Graph, + ImmutableArray Roots, + ExecutionIslandPlan Plan, + RenderFragmentReference First, + RenderFragmentReference Second) CreateReversePublicationFixture() + { + var requestId = new RenderRequestId(1); + RenderFragmentReference firstSource = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + RenderFragmentReference first = Fragment( + RenderFragmentKind.Geometry, + EffectiveScale.At(1), + payload: null, + firstSource); + RenderFragmentReference secondSource = Fragment( + RenderFragmentKind.MaterializedInput, + EffectiveScale.At(1), + payload: null); + RenderFragmentReference second = Fragment( + RenderFragmentKind.Geometry, + EffectiveScale.At(1), + payload: null, + secondSource); + ImmutableArray roots = [second, first]; + RecordedRenderGraph graph = BuildGraph( + requestId, + [firstSource, first, secondSource, second], + roots); + var plan = new ExecutionIslandPlan( + [ + new ExecutionIsland( + new ExecutionIslandId(1), + ExecutionIslandKind.Compatibility, + [first.Id!.Value], + plansGpuPass: true), + new ExecutionIsland( + new ExecutionIslandId(2), + ExecutionIslandKind.Compatibility, + [second.Id!.Value], + plansGpuPass: true), + ], + []); + return (graph, roots, plan, first, second); + } + + private static RenderFragmentReference Find(RecordedRenderGraph graph, RenderFragmentId id) + => (RenderFragmentReference)graph.Fragments.Single(fragment => fragment.Id == id).Payload!; + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + EffectiveScale scale, + object? payload, + params RenderFragmentReference[] inputs) + => new( + kind, + s_bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs, + payload, + static _ => true); + + private static RecordedRenderGraph BuildGraph( + RenderRequestId requestId, + IReadOnlyList references, + IReadOnlyList roots) + { + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance( + typeof(ExecutionIslandAuthorityTests), + "execution-island-authority-test"); + foreach (RenderFragmentReference reference in references) + { + RenderValueId[] inputs = reference.Inputs.SelectMany(static input => input.ValueIds).ToArray(); + reference.ValueIds = [builder.AddValue(inputs, provenance, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenance, reference); + } + + foreach (RenderFragmentReference root in roots) + builder.PublishRoot(root.Id!.Value); + return builder.Build(); + } + + private sealed class TerminalOpacityNode(RenderTarget source) : RenderNode + { + private static readonly ShaderDescription s_shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }"); + + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + current = context.Shader(current, s_shader); + current = context.Opacity(current, 0.625f); + context.Publish(current); + } + } + + private sealed class OpacityOnlyNode(RenderTarget source) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + current = context.Opacity(current, 0.625f); + context.Publish(current); + } + } + + private sealed class DeclaredInputReadbackNode(RenderTarget source, bool opaque) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + current = opaque + ? context.OpaqueMap( + current, + OpaqueRenderDescription.Create( + "opaque-readback", + static (session, _) => + { + RenderExecutionInput input = session.Inputs.Single(); + input.UseSnapshot(static _ => { }); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(input.Draw); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply, + inputReadbacks: [RenderInputReadback.All])) + : context.Geometry( + current, + GeometryDescription.Create( + "geometry-readback", + static (session, _) => + { + session.Input.UseSnapshot(static _ => { }); + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: true)); + context.Publish(current); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FilterEffectSegmentBoundaryReasonTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FilterEffectSegmentBoundaryReasonTests.cs new file mode 100644 index 0000000000..0954902c23 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FilterEffectSegmentBoundaryReasonTests.cs @@ -0,0 +1,137 @@ +using System.Reactive; + +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +// A filter-effect segment collects whatever could not lower to a typed fragment — Skia items and typed +// suffixes as well as custom effects — so the boundary reason has to name what the segment really holds. +[TestFixture] +public sealed class FilterEffectSegmentBoundaryReasonTests +{ + private static readonly Rect s_bounds = new(0, 0, 24, 16); + + [Test] + public void A_segment_without_a_custom_effect_does_not_blame_one() + { + using CompiledRenderRequest compiled = Compile(new Blur { Sigma = { CurrentValue = new(2, 2) } }); + + Assert.Multiple(() => + { + Assert.That(Reasons(compiled), Does.Contain(ExecutionIslandBoundaryReason.FilterEffectSegment)); + Assert.That(Reasons(compiled), Does.Not.Contain(ExecutionIslandBoundaryReason.LegacyCustomEffect)); + }); + } + + [Test] + public void A_segment_holding_a_custom_effect_still_names_it() + { + using CompiledRenderRequest compiled = Compile(new StrokeEffect()); + + Assert.That(Reasons(compiled), Does.Contain(ExecutionIslandBoundaryReason.LegacyCustomEffect)); + } + + [Test] + public void PureSkiaSegments_DeclareSingleForOneInput() + { + using CompiledRenderRequest imageFilter = Compile( + new Blur { Sigma = { CurrentValue = new(2, 2) } }); + using CompiledRenderRequest colorFilter = Compile(new PureSkiaColorFilterEffect()); + + Assert.Multiple(() => + { + Assert.That(Segment(imageFilter).ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(Segment(colorFilter).ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + }); + } + + [Test] + public void CustomSegment_RemainsDynamic() + { + using CompiledRenderRequest compiled = Compile(new SplitEffect()); + + Assert.That(Segment(compiled).ValueCardinality, Is.EqualTo(RenderValueCardinality.Dynamic)); + } + + [Test] + public void PureSkiaSegment_WithMultipleInputs_RemainsDynamic() + { + using CompiledRenderRequest compiled = Compile( + new Blur { Sigma = { CurrentValue = new(2, 2) } }, + childCount: 2); + + Assert.That(Segment(compiled).ValueCardinality, Is.EqualTo(RenderValueCardinality.Dynamic)); + } + + [Test] + public void PureSkiaSegment_WithEmptyOutput_IsZeroOrOne() + { + using CompiledRenderRequest compiled = Compile(new EmptySkiaFilterEffect()); + + Assert.That(Segment(compiled).ValueCardinality, Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + } + + private static RenderFragmentReference Segment(CompiledRenderRequest compiled) + => compiled.Graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(static fragment => fragment.Kind == RenderFragmentKind.FilterEffectSegment); + + private static IEnumerable Reasons(CompiledRenderRequest compiled) + => compiled.ExecutionPlan.Boundaries.Select(static boundary => boundary.Reason); + + private static CompiledRenderRequest Compile(FilterEffect effect, int childCount = 1) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var node = new FilterEffectRenderNode(resource); + for (int index = 0; index < childCount; index++) + { + node.AddChild(new EllipseRenderNode( + s_bounds.Translate(new Vector(index, 0)), + Brushes.Resource.White, + null)); + } + + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + cachePolicy: RenderCacheOptions.Disabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler().Compile(request, graph); + } + catch + { + request.Dispose(); + throw; + } + } +} + +internal sealed partial class EmptySkiaFilterEffect : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.AppendSkiaFilter( + data: 0, + factory: static (_, input, _) => input, + transformBounds: static (_, _) => Rect.Empty); + } +} + +internal sealed partial class PureSkiaColorFilterEffect : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.AppendSKColorFilter( + Unit.Default, + static (_, _) => SKColorFilter.CreateLumaColor()); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryExecutionTestSupport.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryExecutionTestSupport.cs new file mode 100644 index 0000000000..7a84424036 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryExecutionTestSupport.cs @@ -0,0 +1,392 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +internal enum FusionBoundaryRuntimeScenario +{ + MaterializedInput, + WholeSource, + Geometry, + OpaqueCallback, + TargetReadback, + DestinationBlend, + DynamicExpansion, + Graphics3D, +} + +internal sealed class FusionBoundaryRuntimeNode( + RenderTarget source, + Rect bounds, + FusionBoundaryRuntimeScenario scenario) : RenderNode +{ + private static readonly ShaderDescription s_firstShader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.rg * 0.875, color.ba); }"); + + private static readonly ShaderDescription s_secondShader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.b, color.g, color.r, color.a); }"); + + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + current = context.Shader(current, s_firstShader); + + switch (scenario) + { + case FusionBoundaryRuntimeScenario.MaterializedInput: + context.Publish(current); + return; + + case FusionBoundaryRuntimeScenario.WholeSource: + current = context.Shader(current, ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Identity)); + break; + + case FusionBoundaryRuntimeScenario.Geometry: + current = context.Geometry(current, GeometryDescription.CreateRequestLocal( + session => session.Canvas.Use(session.Input.Draw), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + break; + + case FusionBoundaryRuntimeScenario.OpaqueCallback: + current = context.OpaqueMap(current, CreateOpaqueMap( + RenderBackendBoundary.None, + RenderValueCardinality.Single, + "opaque-identity")); + break; + + case FusionBoundaryRuntimeScenario.TargetReadback: + context.Publish(current); + context.Publish(context.TargetCommand( + [current], + TargetCommandDescription.CreateRequestLocal( + session => session.UseSnapshot(static _ => { }), + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + return; + + case FusionBoundaryRuntimeScenario.DestinationBlend: + context.Publish(context.Blend(current, BlendMode.DstOver)); + return; + + case FusionBoundaryRuntimeScenario.DynamicExpansion: + current = context.OpaqueExpand( + [current], + OpaqueRenderDescription.CreateRequestLocal( + CopySingleInput, + OpaqueRenderBoundsContract.FullInputs( + static inputs => inputs.Single()), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Dynamic, + RenderScaleContract.MaterializeAtWorkingScale)); + break; + + case FusionBoundaryRuntimeScenario.Graphics3D: + current = context.OpaqueMap(current, CreateOpaqueMap( + RenderBackendBoundary.Graphics3D, + RenderValueCardinality.Single, + "graphics-3d")); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + context.Publish(context.Shader(current, s_secondShader)); + } + + private static OpaqueRenderDescription CreateOpaqueMap( + RenderBackendBoundary backendBoundary, + RenderValueCardinality cardinality, + string identity) + { + if (backendBoundary == RenderBackendBoundary.None) + { + return OpaqueRenderDescription.CreateRequestLocal( + CopySingleInput, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + cardinality, + RenderScaleContract.PreserveInputSupply); + } + + return OpaqueRenderDescription.CreateBackendBoundary( + backendBoundary, + CopySingleInput, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + cardinality, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive); + } + + private static void CopySingleInput(OpaqueRenderSession session) + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs.Single().Draw); + session.Publish(output); + } +} + +internal sealed class AntialiasedCoverageBoundaryNode(Rect bounds) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription source = OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => + { + using var paint = new SKPaint + { + Color = new SKColor(196, 96, 224, 208), + IsAntialias = true, + StrokeWidth = 1, + Style = SKPaintStyle.Stroke, + }; + canvas.Canvas.DrawLine(2.25f, 2.75f, 21.25f, 13.25f, paint); + }); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + RenderFragmentHandle current = context.OpaqueSource(source); + current = context.Shader(current, ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color * color.a; }")); + context.Publish(current); + } +} + +internal sealed class CachedBoundaryRoot(RenderTarget source, Rect bounds) : RenderNode +{ + private readonly CachedBoundaryShaderNode _cached = new(); + private readonly BoundaryShaderNode _after = new( + ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }")); + + public CachedBoundaryShaderNode Cached => _cached; + + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + current = context.RecordNode(_cached, [current]).Single(); + current = context.RecordNode(_after, [current]).Single(); + context.Publish(current); + } + + protected override void OnDispose(bool disposing) + { + _after.Dispose(); + _cached.Dispose(); + base.OnDispose(disposing); + } +} + +internal sealed class CachedBoundaryShaderNode : RenderNode +{ + private static readonly ShaderDescription s_description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color * 0.75; }"); + + public override void Process(RenderNodeContext context) + { + context.Publish(context.Shader(context.Inputs.Single(), s_description)); + } +} + +internal sealed class BoundaryShaderNode(ShaderDescription description) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + context.Publish(context.Shader(context.Inputs.Single(), description)); + } +} + +internal sealed class BackendOverflowBoundaryNode(RenderTarget source, Rect bounds) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow( + source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.625f)); + context.Publish(context.Shader(current, description)); + } +} + + +internal sealed record FusionBoundaryExecutionResult( + Bitmap Bitmap, + RenderExecutionStatistics Statistics) : IDisposable +{ + public void Dispose() => Bitmap.Dispose(); +} + +internal static class FusionBoundaryExecutionTestSupport +{ + public static RenderTarget CreatePatternSource(Rect bounds) + { + RenderTarget target = RenderTarget.Create((int)bounds.Width, (int)bounds.Height) + ?? throw new InvalidOperationException("Could not allocate the fusion-boundary source."); + target.Value.Canvas.Clear(new SKColor(20, 32, 56, 112)); + using var paint = new SKPaint + { + Color = new SKColor(176, 92, 212, 192), + IsAntialias = true, + }; + target.Value.Canvas.DrawOval(SKRect.Create(3, 2, 16, 11), paint); + return target; + } + + public static FusionBoundaryExecutionResult Execute( + RenderNode node, + Rect bounds, + FusionMode fusionMode, + bool useRenderCache = false) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap?.Clone() + ?? throw new InvalidOperationException("The fusion-boundary render unexpectedly produced no bitmap."); + return new FusionBoundaryExecutionResult(bitmap, renderer.LastExecutionStatistics); + } + + public static FusionBoundaryExecutionResult ExecuteWithBudget( + RenderNode node, + Rect bounds, + FusionMode fusionMode, + SkslBackendBudget budget) + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: fusionMode); + var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph, budget); + using var targetRegistry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = targetRegistry.BeginSession( + RenderIntent.Preview); + PixelRect deviceBounds = PixelRect.FromRect(compiled.ExecutionTargetBounds, 1); + using RenderTargetLease root = targets.Acquire(deviceBounds.Size); + using var canvas = new ImmediateCanvas(root.Target, 1, 1, compiled.ExecutionTargetBounds.Size); + canvas.Clear(); + using (canvas.PushTransform(Matrix.CreateTranslation( + -compiled.ExecutionTargetBounds.X, + -compiled.ExecutionTargetBounds.Y))) + { + var executor = new RenderRequestExecutor(targets); + executor.Execute(compiled, canvas); + using Bitmap complete = root.Target.Snapshot(); + Bitmap bitmap = complete.Clone(); + return new FusionBoundaryExecutionResult(bitmap, executor.Statistics); + } + } + + public static double SumAbsoluteChannels(Bitmap bitmap) + { + double result = 0; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int index = 0; index < pixels.Length; index++) + { + float value = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + if (!float.IsFinite(value)) + { + throw new AssertionException( + $"Fusion-boundary bitmap channel {index} is non-finite: {value}."); + } + + result += Math.Abs(value); + } + + return result; + } + + public static int CountFractionalAlphaPixels(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int result = 0; + for (int index = 3; index < pixels.Length; index += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + if (alpha > 0.001f && alpha < 0.999f) + result++; + } + return result; + } +} + +[TestFixture] +public sealed class FusionBoundaryExecutionTestSupportTests +{ + [TestCase(float.PositiveInfinity)] + [TestCase(float.NegativeInfinity)] + public void SumAbsoluteChannels_RejectsInfiniteChannel(float value) + { + using var bitmap = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + bitmap.GetPixelSpan()[0] = BitConverter.HalfToUInt16Bits((Half)value); + + AssertionException? exception = Assert.Throws( + () => FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(bitmap)); + Assert.That(exception!.Message, Does.Contain("channel 0").And.Contain("non-finite")); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs new file mode 100644 index 0000000000..f1e0e69608 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryTests.cs @@ -0,0 +1,725 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +[NonParallelizable] +public sealed class FusionBoundaryTests +{ + private static readonly Rect s_bounds = new(0, 0, 24, 16); + + [Test] + [Category("GpuPassFusionGpu")] + public void AntialiasedThinStroke_NonlinearShaderPreservesCoverageAtTheExactBoundary() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var node = new AntialiasedCoverageBoundaryNode(s_bounds); + using FusionBoundaryExecutionResult disabled = FusionBoundaryExecutionTestSupport.Execute( + node, + s_bounds, + FusionMode.Disabled); + using FusionBoundaryExecutionResult enabled = FusionBoundaryExecutionTestSupport.Execute( + node, + s_bounds, + FusionMode.Enabled); + + RgbaMaximumError maximum = ImageMetrics.EdgeBandMaximumAbsoluteErrorPerChannel( + disabled.Bitmap, + enabled.Bitmap); + Assert.Multiple(() => + { + Assert.That(FusionBoundaryExecutionTestSupport.CountFractionalAlphaPixels(enabled.Bitmap), + Is.GreaterThan(0), "The control must contain antialiased fractional-coverage edge pixels."); + Assert.That(FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(enabled.Bitmap), Is.GreaterThan(1)); + Assert.That(maximum.Maximum, Is.LessThanOrEqualTo(0.02)); + Assert.That(enabled.Statistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabled.Statistics.FusedShaderRunExecutions, Is.Zero); + Assert.That(enabled.Statistics.IntermediateTargetAcquisitions, Is.EqualTo(1)); + }); + }); + } + + + [Test] + [Category("GpuPassFusionGpu")] + public void StandaloneBackendOverflow_ExecutesCompatibilityPathWithParityAndExactReason() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = FusionBoundaryExecutionTestSupport.CreatePatternSource(s_bounds); + using var node = new BackendOverflowBoundaryNode(source, s_bounds); + SkslBackendBudget budget = new( + capabilityClass: (typeof(FusionBoundaryTests), "runtime-standalone-overflow"), + maxStages: int.MaxValue, + maxUniformVectors: 0, + maxSamplers: int.MaxValue, + maxChildren: int.MaxValue, + maxSourceBytes: int.MaxValue, + maxProgramTokens: int.MaxValue); + using FusionBoundaryExecutionResult disabled = FusionBoundaryExecutionTestSupport.ExecuteWithBudget( + node, + s_bounds, + FusionMode.Disabled, + budget); + using FusionBoundaryExecutionResult enabled = FusionBoundaryExecutionTestSupport.ExecuteWithBudget( + node, + s_bounds, + FusionMode.Enabled, + budget); + + RgbaMaximumError maximum = ImageMetrics.MaximumAbsoluteErrorPerChannel( + disabled.Bitmap, + enabled.Bitmap); + Assert.Multiple(() => + { + Assert.That(FusionBoundaryExecutionTestSupport.SumAbsoluteChannels(enabled.Bitmap), Is.GreaterThan(1)); + Assert.That(maximum.Maximum, Is.LessThanOrEqualTo(0.02)); + Assert.That(enabled.Statistics.ShaderRunExecutions, Is.Zero); + Assert.That(disabled.Statistics.ShaderRunExecutions, Is.Zero); + }); + }); + } + + [Test] + public void NonlinearCurrentPixel_AfterCoverageProducerRequiresMaterializationBoundary() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment( + RenderFragmentKind.OpaqueSource, + OpaquePayload( + OpaqueRenderTopology.Source, + RenderValueCardinality.Single)); + ShaderDescription nonlinear = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color * color.a; }"); + RenderFragmentReference shader = Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(nonlinear), + source); + return BuildGraph(requestId, [source, shader], [shader], cache); + }); + + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + Assert.Multiple(() => + { + Assert.That(run.CoverageSource, Is.EqualTo(ShaderRunCoverageSource.CompatibilityMaterialization)); + Assert.That(run.Stages.Single().CoverageBehavior, + Is.EqualTo(SkslCoverageBehavior.RequiresResolvedCoverage)); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.Opaque)); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.CoverageResolution)); + }); + } + + [Test] + public void ScopeMetadataMismatch_RemainsAnExplicitCompatibilityBoundary() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference first = CurrentPixel(source, "return color * 0.75;"); + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color.bgra; }"); + var mismatched = new RenderFragmentReference( + RenderFragmentKind.Shader, + s_bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + [first], + new ShaderRenderFragmentPayload(description), + static _ => true); + return BuildGraph(requestId, [source, first, mismatched], [mismatched], cache); + }); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Output, + Is.SameAs(compiled.Graph.Fragments[1].Payload)); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.ScopeMismatch)); + }); + } + + [Test] + public void GeometryAndTargetCaptureRemainExplicitBarriers() + { + AssertBarrier( + RenderFragmentKind.Geometry, + GeometryPayload(), + expected: ExecutionIslandBoundaryReason.Geometry); + AssertBarrier( + RenderFragmentKind.TargetCapture, + TargetCapturePayload(), + expected: ExecutionIslandBoundaryReason.TargetCapture); + } + + [Test] + public void WholeSourceStagesStartRunsAndNeverBecomeSuccessors() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference first = Fragment(RenderFragmentKind.Shader, WholeSourcePayload(), source); + RenderFragmentReference second = Fragment(RenderFragmentKind.Shader, WholeSourcePayload(), first); + RenderFragmentReference currentPixel = CurrentPixel(second, "return color * 0.5;"); + return BuildGraph(requestId, [source, first, second, currentPixel], [currentPixel], cache); + }); + + CompiledShaderRun[] runs = compiled.ExecutionPlan.ShaderRuns.ToArray(); + Assert.Multiple(() => + { + Assert.That(runs.Select(static run => run.Stages.Length), Is.EqualTo(new[] { 1, 2 })); + Assert.That(runs, Has.All.Matches(static run => run.WholeSourceHead is not null)); + Assert.That(runs, Has.All.Matches(static run => + run.Stages[0].Description.Kind == ShaderDescriptionKind.WholeSource + && run.Stages.Skip(1).All(stage => stage.Description.Kind == ShaderDescriptionKind.CurrentPixel))); + Assert.That(compiled.ExecutionPlan.Boundaries.Count(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.WholeSourceShader), + Is.EqualTo(1)); + }); + } + + [Test] + public void FusionDisabled_KeepsWholeSourceInACompatibilityIsland() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference wholeSource = Fragment( + RenderFragmentKind.Shader, + WholeSourcePayload(), + source); + RenderFragmentReference currentPixel = CurrentPixel(wholeSource, "return color * 0.5;"); + return BuildGraph(requestId, [source, wholeSource, currentPixel], [currentPixel], cache); + }, fusionMode: FusionMode.Disabled); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] { ExecutionIslandKind.Compatibility, ExecutionIslandKind.ShaderRun })); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(1)); + Assert.That(compiled.ExecutionPlan.ShaderRuns.Single().WholeSourceHead, Is.Null); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.WholeSourceShader)); + }); + } + + + [Test] + public void BypassedCacheCandidate_DoesNotSplitOtherwiseCompatibleRun() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference first = CurrentPixel(source, "return color * 0.75;"); + RenderFragmentReference second = CurrentPixel(first, "return half4(color.bgr, color.a);"); + cache.Add(first); + return BuildGraph(requestId, [source, first, second], [second], cache); + }); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { 2 })); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.None.Matches( + static boundary => boundary.Reason is ExecutionIslandBoundaryReason.CacheInput + or ExecutionIslandBoundaryReason.CacheCapture)); + }); + } + + [Test] + public void BackendStageBudget_SplitsBeforeOverflowAndPreservesOrderDeterministically() + { + SkslBackendBudget budget = Budget(maxStages: 2); + using CompiledRenderRequest first = Compile(FiveStageGraph, budget: budget); + using CompiledRenderRequest second = Compile(FiveStageGraph, budget: budget); + + CompiledShaderRun[] firstRuns = first.ExecutionPlan.ShaderRuns.ToArray(); + CompiledShaderRun[] secondRuns = second.ExecutionPlan.ShaderRuns.ToArray(); + Assert.Multiple(() => + { + Assert.That(firstRuns.Select(static run => run.Stages.Length), Is.EqualTo(new[] { 2, 2, 1 })); + Assert.That(firstRuns.SelectMany(static run => run.Stages) + .Select(static stage => stage.Description.Source.Text), + Is.EqualTo(secondRuns.SelectMany(static run => run.Stages) + .Select(static stage => stage.Description.Source.Text))); + Assert.That(first.ExecutionPlan.Boundaries.Count(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.BackendLimit), + Is.EqualTo(2)); + Assert.That(first.ExecutionPlan.Boundaries + .Where(static boundary => boundary.Reason == ExecutionIslandBoundaryReason.BackendLimit), + Has.All.Matches(static boundary => + boundary.BackendLimits.Contains(SkslBackendLimit.StageCount))); + }); + } + + [Test] + public void DefaultCompiler_UsesFinitePortableBudgetAndSplitsBeforeOverflow() + { + SkslBackendBudget budget = SkslBackendBudgetResolver.Portable; + using CompiledRenderRequest compiled = CompileWithProductionDefaults( + (requestId, cache) => StageGraph(requestId, cache, budget.MaxStages + 1)); + + CompiledShaderRun[] runs = compiled.ExecutionPlan.ShaderRuns.ToArray(); + ExecutionIslandBoundary[] backendBoundaries = compiled.ExecutionPlan.Boundaries + .Where(static boundary => boundary.Reason == ExecutionIslandBoundaryReason.BackendLimit) + .ToArray(); + Assert.Multiple(() => + { + Assert.That(runs.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { budget.MaxStages, 1 })); + Assert.That(runs, Has.All.Matches( + run => run.Program.Budget.Equals(budget))); + Assert.That(backendBoundaries, Has.Exactly(1).Items); + Assert.That(backendBoundaries[0].BackendLimits, + Does.Contain(SkslBackendLimit.StageCount)); + }); + } + + [Test] + public void CompileAfterMetadata_UsesFinitePortableBudgetAndSplitsBeforeOverflow() + { + SkslBackendBudget budget = SkslBackendBudgetResolver.Portable; + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + fusionMode: FusionMode.Enabled); + var request = new RenderRequest(options); + var cache = new HashSet(ReferenceEqualityComparer.Instance); + RecordedRenderGraph graph = StageGraph(request.Id, cache, budget.MaxStages + 1); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + var compiler = new RenderRequestCompiler(); + RenderNodeMeasurement measurement = compiler.ResolveMetadata(request, graph); + + using CompiledRenderRequest compiled = compiler.CompileAfterMetadata( + request, + graph, + measurement); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { budget.MaxStages, 1 })); + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.All.Matches( + run => run.Program.Budget.Equals(budget))); + }); + } + + [Test] + public void StandaloneBackendOverflow_ReportsOnlyTheExactBackendLimitBoundary() + { + SkslBackendBudget budget = new( + capabilityClass: (typeof(FusionBoundaryTests), "standalone-uniform-overflow"), + maxStages: int.MaxValue, + maxUniformVectors: 0, + maxSamplers: int.MaxValue, + maxChildren: int.MaxValue, + maxSourceBytes: int.MaxValue, + maxProgramTokens: int.MaxValue); + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.5f)); + RenderFragmentReference shader = Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(description), + source); + return BuildGraph(requestId, [source, shader], [shader], cache); + }, budget: budget); + + ExecutionIslandBoundary[] backendBoundaries = compiled.ExecutionPlan.Boundaries + .Where(static boundary => boundary.Reason == ExecutionIslandBoundaryReason.BackendLimit) + .ToArray(); + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Is.Empty); + Assert.That(compiled.ExecutionPlan.Islands, Has.Exactly(1).Items); + Assert.That(backendBoundaries, Has.Exactly(1).Items); + Assert.That(backendBoundaries[0].BackendLimits, + Is.EqualTo(new[] { SkslBackendLimit.UniformVectors })); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.None.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.WholeSourceShader)); + }); + } + + [Test] + public void PortableResourceOverflow_UsesSingleCompatibilityFallbackAtCurrentBudget() + { + using var registry = new RenderRequestResourceRegistry(); + SkslBackendBudget budget = SkslBackendBudgetResolver.Portable; + ShaderDescription description = ResourceHeavyDescription(budget.MaxSamplers, registry); + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference shader = Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(description), + source); + return BuildGraph(requestId, [source, shader], [shader], cache); + }, budget: budget); + + ExecutionIslandBoundary[] backendBoundaries = compiled.ExecutionPlan.Boundaries + .Where(static boundary => boundary.Reason == ExecutionIslandBoundaryReason.BackendLimit) + .ToArray(); + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Is.Empty); + Assert.That(compiled.ExecutionPlan.Islands, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.Islands[0].Kind, Is.EqualTo(ExecutionIslandKind.Compatibility)); + Assert.That(backendBoundaries, Has.Exactly(1).Items); + Assert.That( + backendBoundaries[0].BackendLimits, + Is.EqualTo(new[] { SkslBackendLimit.Samplers, SkslBackendLimit.Children })); + }); + } + + [Test] + public void DynamicCardinalityAndGroupOpacityDoNotClaimShaderEligibility() + { + using CompiledRenderRequest dynamic = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment( + RenderFragmentKind.OpaqueExpand, + OpaquePayload( + OpaqueRenderTopology.Expand, + RenderValueCardinality.Dynamic), + cardinality: RenderValueCardinality.Dynamic); + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + RenderFragmentReference shader = Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(description), + RenderValueCardinality.Dynamic, + source); + return BuildGraph(requestId, [source, shader], [shader], cache); + }); + using CompiledRenderRequest groupOpacity = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment( + RenderFragmentKind.OpaqueExpand, + OpaquePayload( + OpaqueRenderTopology.Expand, + RenderValueCardinality.Exactly(2)), + cardinality: RenderValueCardinality.Exactly(2)); + RenderFragmentReference opacity = Fragment( + RenderFragmentKind.Opacity, + new OpacityRenderFragmentPayload(0.5f, OpacityRenderNode.CreateFusionDescription(0.5f)), + RenderValueCardinality.Exactly(2), + source); + return BuildGraph(requestId, [source, opacity], [opacity], cache); + }); + + Assert.Multiple(() => + { + Assert.That(dynamic.ExecutionPlan.ShaderRuns, Is.Empty); + Assert.That(dynamic.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.DynamicTopology)); + Assert.That(groupOpacity.ExecutionPlan.ShaderRuns, Is.Empty, + "Group opacity over multiple values is not equivalent to per-value color multiplication."); + Assert.That(groupOpacity.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.DynamicTopology)); + }); + } + + [Test] + public void ZeroOrOneInput_CanStartAShaderRunWithoutABackendBoundary() + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment( + RenderFragmentKind.OpaqueSource, + OpaquePayload( + OpaqueRenderTopology.Source, + RenderValueCardinality.ZeroOrOne), + cardinality: RenderValueCardinality.ZeroOrOne); + RenderFragmentReference shader = Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")), + RenderValueCardinality.ZeroOrOne, + source); + return BuildGraph(requestId, [source, shader], [shader], cache); + }); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.None.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.DynamicTopology)); + }); + } + + private static void AssertBarrier( + RenderFragmentKind barrierKind, + object? payload, + ExecutionIslandBoundaryReason expected) + { + using CompiledRenderRequest compiled = Compile((requestId, cache) => + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + RenderFragmentReference barrier = Fragment(barrierKind, payload, source); + RenderFragmentReference shader = CurrentPixel(barrier, "return color * color.a;"); + return BuildGraph(requestId, [source, barrier, shader], [shader], cache); + }); + + Assert.Multiple(() => + { + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + boundary => boundary.Reason == expected)); + Assert.That(compiled.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.CoverageResolution)); + }); + } + + private static RecordedRenderGraph FiveStageGraph( + RenderRequestId requestId, + HashSet cache) + => StageGraph(requestId, cache, 5); + + private static RecordedRenderGraph StageGraph( + RenderRequestId requestId, + HashSet cache, + int stageCount) + { + RenderFragmentReference source = Fragment(RenderFragmentKind.MaterializedInput, payload: null); + var references = new List { source }; + RenderFragmentReference current = source; + for (int index = 0; index < stageCount; index++) + { + current = CurrentPixel(current, $"return color * {index + 1}.0;"); + references.Add(current); + } + return BuildGraph(requestId, references, [current], cache); + } + + private static object WholeSourcePayload() + { + ShaderDescription description = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Identity); + return new ShaderRenderFragmentPayload(description); + } + + private static object TargetCapturePayload() + { + TargetCaptureDescription description = TargetCaptureDescription.Create( + TargetRegion.Full, + s_bounds, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale); + return new TargetCaptureRenderFragmentPayload(description); + } + + private static RenderFragmentReference CurrentPixel( + RenderFragmentReference input, + string body) + { + ShaderDescription description = ShaderDescription.CurrentPixel( + $"half4 apply(half4 color) {{ {body} }}"); + return Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(description), + input); + } + + private static GeometryRenderFragmentPayload GeometryPayload() + { + GeometryDescription description = GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.OutputBounds); + return new GeometryRenderFragmentPayload(description); + } + + private static OpaqueRenderFragmentPayload OpaquePayload( + OpaqueRenderTopology topology, + RenderValueCardinality cardinality) + { + OpaqueRenderBoundsContract bounds = topology switch + { + OpaqueRenderTopology.Source => OpaqueRenderBoundsContract.Source(s_bounds), + OpaqueRenderTopology.Map => OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + OpaqueRenderTopology.Combine or OpaqueRenderTopology.Expand + => OpaqueRenderBoundsContract.FullInputs(static _ => s_bounds), + _ => throw new ArgumentOutOfRangeException(nameof(topology)), + }; + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + static _ => { }, + bounds, + RenderHitTestContract.OutputBounds, + cardinality, + RenderScaleContract.MaterializeAtWorkingScale); + IReadOnlyList inputReadbacks = topology == OpaqueRenderTopology.Map + ? [RenderInputReadback.None] + : Array.Empty(); + return new OpaqueRenderFragmentPayload(topology, description, inputReadbacks); + } + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + object? payload, + params RenderFragmentReference[] inputs) + => Fragment(kind, payload, RenderValueCardinality.Single, inputs); + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + object? payload, + RenderValueCardinality cardinality, + params RenderFragmentReference[] inputs) + { + return new RenderFragmentReference( + kind, + s_bounds, + kind == RenderFragmentKind.MaterializedInput ? EffectiveScale.At(1) : EffectiveScale.Unbounded, + cardinality, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: kind == RenderFragmentKind.TargetCapture + || inputs.Any(static input => input.HasTargetEffects), + hasOpaqueExternalWork: kind is RenderFragmentKind.OpaqueSource + or RenderFragmentKind.OpaqueMap + or RenderFragmentKind.OpaqueCombine + or RenderFragmentKind.OpaqueExpand + || inputs.Any(static input => input.HasOpaqueExternalWork), + inputs, + payload, + static _ => true); + } + + private static CompiledRenderRequest Compile( + Func, RecordedRenderGraph> createGraph, + FusionMode fusionMode = FusionMode.Enabled, + SkslBackendBudget? budget = null) + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + fusionMode: fusionMode); + var request = new RenderRequest(options); + var cache = new HashSet(ReferenceEqualityComparer.Instance); + RecordedRenderGraph graph = createGraph(request.Id, cache); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + return new RenderRequestCompiler().Compile( + request, + graph, + budget ?? SkslBackendBudget.Unlimited); + } + + private static CompiledRenderRequest CompileWithProductionDefaults( + Func, RecordedRenderGraph> createGraph) + { + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + fusionMode: FusionMode.Enabled); + var request = new RenderRequest(options); + var cache = new HashSet(ReferenceEqualityComparer.Instance); + RecordedRenderGraph graph = createGraph(request.Id, cache); + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static RecordedRenderGraph BuildGraph( + RenderRequestId requestId, + IReadOnlyList references, + IReadOnlyList roots, + IReadOnlySet cache) + { + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance(typeof(FusionBoundaryTests), "test"); + foreach (RenderFragmentReference reference in references) + { + RenderValueId[] inputs = reference.Inputs.SelectMany(static input => input.ValueIds).ToArray(); + reference.ValueIds = reference.ValueCardinality.Maximum == 0 + ? [] + : [builder.AddValue(inputs, provenance, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenance, reference); + if (cache.Contains(reference)) + builder.AddCacheCandidate(reference.Id.Value, (typeof(FusionBoundaryTests), reference.Id.Value.Value)); + } + foreach (RenderFragmentReference root in roots) + builder.PublishRoot(root.Id!.Value); + return builder.Build(); + } + + private static SkslBackendBudget Budget(int maxStages) + => new( + capabilityClass: (typeof(FusionBoundaryTests), maxStages), + maxStages, + maxUniformVectors: int.MaxValue, + maxSamplers: int.MaxValue, + maxChildren: int.MaxValue, + maxSourceBytes: int.MaxValue, + maxProgramTokens: int.MaxValue); + + private static ShaderDescription ResourceHeavyDescription( + int resourceCount, + RenderRequestResourceRegistry registry) + { + string[] names = Enumerable.Range(0, resourceCount) + .Select(static index => $"lookup{index}") + .ToArray(); + RenderResource[] resources = names + .Select(_ => registry.RegisterBorrowed(new object())) + .ToArray(); + string declarations = string.Join(' ', names.Select(static name => $"uniform shader {name};")); + + return ShaderDescription.CurrentPixel( + $"{declarations} half4 apply(half4 color) {{ return color; }}", + bindings => + { + for (int index = 0; index < names.Length; index++) + { + bindings.Resource( + names[index], + resources[index], + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set( + SkiaSharp.SKShader.CreateColor(SkiaSharp.SKColors.White))); + } + }); + } + + + private static RenderNodeRenderer CreateBoundaryRenderer( + RenderNode node, + FusionMode fusionMode, + bool useRenderCache) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + }); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SKSLScriptEffectShaderTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SKSLScriptEffectShaderTests.cs new file mode 100644 index 0000000000..c2e601534f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SKSLScriptEffectShaderTests.cs @@ -0,0 +1,413 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +[NonParallelizable] +public sealed class SKSLScriptEffectShaderTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + public void MainScript_RecordsWholeSourceWithoutLegacyBoundary() + { + var effect = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + uniform shader src; + uniform float progress; + uniform float customValue; + + half4 main(float2 fragCoord) { + return src.eval(fragCoord) + half4(customValue); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + using var secondContext = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + secondContext.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + var shader = (FEItem_Shader)items.Single(); + var secondShader = (FEItem_Shader)secondContext.GetOrderedItems().Single(); + Assert.Multiple(() => + { + Assert.That(items.OfType(), Is.Empty); + Assert.That(shader.Description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + Assert.That(shader.Description.SourceTileMode, Is.EqualTo(SKShaderTileMode.Clamp)); + Assert.That(shader.Description.Bounds.RequiresFullInput, Is.True); + Assert.That(shader.Description.Resources, Is.Empty); + Assert.That( + shader.Description.Uniforms.Select(static binding => binding.Name), + Is.EqualTo(new[] { "progress", "customValue" })); + Assert.That(secondShader.Description, Is.Not.SameAs(shader.Description)); + Assert.That(secondShader.Description.Source, Is.SameAs(shader.Description.Source)); + Assert.That( + secondShader.Description.StructuralIdentity, + Is.EqualTo(shader.Description.StructuralIdentity)); + }); + } + + [Test] + public void ApplyScript_FusesWithFollowingColorStage() + { + var script = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + half4 apply(half4 color) { + return half4(color.rgb * 0.5, color.a); + } + """, + }, + }; + + using CompiledRenderRequest compiled = Compile( + script, + new Invert { Amount = { CurrentValue = 25f } }); + + CompiledShaderRun run = compiled.ExecutionPlan.ShaderRuns.Single(); + TestContext.WriteLine( + $"SKSL apply -> Invert: {compiled.ExecutionPlan.Islands.Length} islands, " + + $"{compiled.ExecutionPlan.ShaderRuns.Count()} shader run, {run.Stages.Length} stages"); + Assert.Multiple(() => + { + Assert.That( + compiled.ExecutionPlan.Islands.Select(static island => island.Kind), + Is.EqualTo(new[] { ExecutionIslandKind.Compatibility, ExecutionIslandKind.ShaderRun })); + Assert.That(compiled.ExecutionPlan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(run.Stages, Has.Length.EqualTo(2)); + Assert.That( + run.Stages.Select(static stage => stage.Description.Kind), + Is.EqualTo(new[] + { + ShaderDescriptionKind.CurrentPixel, + ShaderDescriptionKind.CurrentPixel, + })); + Assert.That(run.WholeSourceHead, Is.Null); + }); + } + + [Test] + public void ReservedBindingName_FallsBackToLegacyCustomEffect() + { + var effect = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + uniform shader src; + uniform float fe0_value; + + half4 main(float2 fragCoord) { + return src.eval(fragCoord) + half4(fe0_value); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items.OfType(), Has.Exactly(1).Items); + Assert.That(items.OfType(), Is.Empty); + }); + } + + [Test] + public void IntegerArrayWithoutCanonicalZero_FallsBackToLegacyCustomEffect() + { + var effect = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + uniform shader src; + uniform int values[2]; + + half4 main(float2 fragCoord) { + return src.eval(fragCoord) + half4(float(values[0])); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(s_bounds); + + context.ApplyTransactional(effect, resource); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items.OfType(), Has.Exactly(1).Items); + Assert.That(items.OfType(), Is.Empty); + }); + } + + [Test] + public void OutputSizeUniforms_UseSemanticOutputAndClampedWorkingScale() + { + var effect = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + uniform shader src; + uniform float width; + uniform float height; + uniform float2 iResolution; + uniform float iScale; + + half4 main(float2 fragCoord) { + return src.eval(fragCoord); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var recording = new FilterEffectContext(s_bounds); + recording.ApplyTransactional(effect, resource); + ShaderDescription description = ((FEItem_Shader)recording.GetOrderedItems().Single()).Description; + var token = new RenderExecutionSessionToken(); + + Dictionary values = token.RunAndComplete(() => + { + var execution = new ShaderExecutionContext( + token, + s_bounds, + s_bounds, + new Rect(0, 0, 2, 3), + new PixelRect(0, 0, 2, 3), + EffectiveScale.At(1), + outputScale: 1, + workingScale: 2, + maxWorkingScale: 2, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary); + return description.Uniforms.ToDictionary( + static binding => binding.Name, + binding => binding.Bind(description.Source.Uniforms[binding.Name], execution)); + }); + + Assert.Multiple(() => + { + Assert.That(values["width"].Floats, Is.EqualTo(new[] { 32f })); + Assert.That(values["height"].Floats, Is.EqualTo(new[] { 24f })); + Assert.That(values["iResolution"].Floats, Is.EqualTo(new[] { 32f, 24f })); + Assert.That(values["iScale"].Floats, Is.EqualTo(new[] { 2f })); + }); + } + + [Test] + public void TimeUniforms_AreSnapshottedWhenShaderIsRecorded() + { + var effect = new SKSLScriptEffect + { + TimeRange = TimeRange.FromSeconds(8), + Script = + { + CurrentValue = + """ + uniform shader src; + uniform float progress; + uniform float duration; + uniform float time; + uniform float iTime; + + half4 main(float2 coord) { + bool recorded = progress == 0.25 && duration == 8.0 + && time == 2.0 && iTime == 2.0; + return recorded + ? half4(0.0, 1.0, 0.0, 1.0) + : half4(1.0, 0.0, 1.0, 1.0); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource( + new CompositionContext(TimeSpan.FromSeconds(2))); + using var node = new FilterEffectRenderNode(resource); + node.AddChild(new RectangleRenderNode(s_bounds, Brushes.Resource.White, null)); + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled)); + CompiledRenderRequest compiled; + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + compiled = new RenderRequestCompiler().Compile(request, graph, SkslBackendBudgetResolver.Portable); + } + catch + { + request.Dispose(); + throw; + } + + using (compiled) + { + bool updateOnly = false; + resource.Update( + effect, + new CompositionContext(TimeSpan.FromSeconds(6)), + ref updateOnly); + + using var targetRegistry = new RenderTargetLeaseRegistry(new CpuTargetFactory()); + using RenderTargetLeaseSession targets = targetRegistry.BeginSession(RenderIntent.Preview); + PixelRect deviceBounds = PixelRect.FromRect(compiled.ExecutionTargetBounds, 1); + using RenderTargetLease output = targets.Acquire(deviceBounds.Size); + using var canvas = new ImmediateCanvas( + output.Target, + density: 1, + maxWorkingScale: 1, + logicalSize: compiled.ExecutionTargetBounds.Size, + intent: RenderIntent.Preview); + canvas.Clear(); + using (canvas.PushTransform(Matrix.CreateTranslation( + -compiled.ExecutionTargetBounds.X, + -compiled.ExecutionTargetBounds.Y))) + { + new RenderRequestExecutor(targets).Execute(compiled, canvas); + } + + using Bitmap bitmap = output.Target.Snapshot(); + SKColor color = bitmap.SKBitmap.GetPixel(8, 6); + Assert.Multiple(() => + { + Assert.That(color.Green, Is.GreaterThanOrEqualTo(250), + "the recorded request must retain its T1 progress/duration/time/iTime values"); + Assert.That(color.Red, Is.LessThanOrEqualTo(5)); + Assert.That(color.Blue, Is.LessThanOrEqualTo(5)); + }); + } + } + + [Test] + public void SourceLessGenerator_InjectsImplicitSourceAndRenders() + { + var effect = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + half4 main(float2 fragCoord) { + return half4(1.0, 0.0, 0.0, 1.0); + } + """, + }, + }; + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(new Rect(0, 0, 2, 2)); + context.ApplyTransactional(effect, resource); + var shader = (FEItem_Shader)context.GetOrderedItems().Single(); + using RenderTarget backing = new CpuRenderTarget(2, 2); + backing.Value.Canvas.Clear(SKColors.Transparent); + backing.Value.Canvas.Flush(); + using var targets = new EffectTargets + { + new EffectTarget( + backing, + new Rect(0, 0, 2, 2), + EffectiveScale.At(1), + new PixelRect(0, 0, 2, 2)), + }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + activator.Apply(context); + activator.Flush(false); + + using Bitmap bitmap = targets.Single().RenderTarget!.Snapshot(); + SKColor[] pixels = Enumerable.Range(0, bitmap.Width * bitmap.Height) + .Select(index => bitmap.SKBitmap.GetPixel(index % bitmap.Width, index / bitmap.Width)) + .ToArray(); + Assert.Multiple(() => + { + Assert.That(shader.Description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + Assert.That(shader.Description.Source.Uniforms["src"].IsShader, Is.True); + Assert.That(context.GetOrderedItems().OfType(), Is.Empty); + Assert.That(pixels, Has.All.Matches(static pixel => + pixel.Red >= 250 && pixel.Green <= 5 && pixel.Blue <= 5 && pixel.Alpha >= 250)); + }); + } + + private static CompiledRenderRequest Compile(params FilterEffect[] effects) + { + var group = new FilterEffectGroup(); + foreach (FilterEffect effect in effects) + group.Children.Add(effect); + + using FilterEffect.Resource resource = group.ToResource(CompositionContext.Default); + using var node = new FilterEffectRenderNode(resource); + node.AddChild(new EllipseRenderNode(s_bounds, Brushes.Resource.White, null)); + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: s_bounds, + requestedRegion: null, + cachePolicy: Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + fusionMode: FusionMode.Enabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler().Compile(request, graph, SkslBackendBudgetResolver.Portable); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height) + { + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Failed to create the CPU test surface."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderDescriptionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderDescriptionTests.cs new file mode 100644 index 0000000000..6d72e2c56a --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderDescriptionTests.cs @@ -0,0 +1,514 @@ +using System.Numerics; +using System.Reflection; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +public sealed class ShaderDescriptionTests +{ + private const string IdentityCurrentPixel = "half4 apply(half4 color) { return color; }"; + + [Test] + public void CurrentPixel_NormalizesSourceAndRejectsUnsafeGrammar() + { + ShaderDescription first = ShaderDescription.CurrentPixel( + "\r\nhalf4 apply(half4 color) {\r\n return color;\r\n}\r\n"); + ShaderDescription second = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) {\n return color;\n}"); + + Assert.Multiple(() => + { + Assert.That(first.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + Assert.That(first.Source.Text, Is.EqualTo(second.Source.Text)); + Assert.That(first.Source.IdentityHash, Is.EqualTo(second.Source.IdentityHash)); + Assert.That(first.Bounds, Is.EqualTo(RenderBoundsContract.Identity)); + Assert.That( + typeof(ShaderDescription).GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Select(static property => property.Name), + Does.Not.Contain("IsCoverageHomogeneous")); + }); + + string[] invalidSources = + [ + "half4 main(float2 coord) { return half4(1); }", + "half4 apply(half4 pixel) { return pixel; }", + "half4 apply(half4 color) { return half4(sk_FragCoord.xy, 0, 1); }", + "uniform shader src; half4 apply(half4 color) { return src.eval(color.rg); }", + "uniform float left, right; half4 apply(half4 color) { return color; }", + "struct Payload { float value; }; half4 apply(half4 color) { return color; }", + "half4 apply(half4 color) { return color; } half4 apply(half4 color) { return color; }", + ]; + + foreach (string source in invalidSources) + { + Assert.That( + () => ShaderDescription.CurrentPixel(source), + Throws.TypeOf(), + source); + } + } + + [Test] + public void CurrentPixel_BackendStructuralIdentityIncludesSelectedLowering() + { + const string sksl = "half4 apply(half4 color) { return color; }"; + const string glsl = + "#version 450\nlayout(location=0) out vec4 color; void main() { color = vec4(1); }"; + var firstLowering = new SpirvShaderLowering( + glsl, + [], + supportsBitExactSkiaHandoff: false); + var equivalentLowering = new SpirvShaderLowering( + glsl, + [], + supportsBitExactSkiaHandoff: false); + var autoEligibleLowering = new SpirvShaderLowering( + glsl, + [], + supportsBitExactSkiaHandoff: true); + SkslSource source = new(sksl, ShaderDescriptionKind.CurrentPixel); + ShaderDescription first = ShaderDescription.CurrentPixel(source, firstLowering, bindings: null); + ShaderDescription equivalent = ShaderDescription.CurrentPixel(source, equivalentLowering, bindings: null); + ShaderDescription autoEligible = ShaderDescription.CurrentPixel(source, autoEligibleLowering, bindings: null); + + Assert.Multiple(() => + { + Assert.That( + first.GetStructuralIdentity(ShaderProgramBackend.Sksl), + Is.Not.EqualTo(first.GetStructuralIdentity(ShaderProgramBackend.Spirv))); + Assert.That( + first.GetStructuralIdentity(ShaderProgramBackend.Sksl), + Is.EqualTo(equivalent.GetStructuralIdentity(ShaderProgramBackend.Sksl))); + Assert.That( + first.GetStructuralIdentity(ShaderProgramBackend.Spirv), + Is.EqualTo(equivalent.GetStructuralIdentity(ShaderProgramBackend.Spirv))); + Assert.That( + first.GetStructuralIdentity(ShaderProgramBackend.Spirv), + Is.Not.EqualTo(autoEligible.GetStructuralIdentity(ShaderProgramBackend.Spirv))); + }); + } + + [Test] + public void CurrentPixel_AcceptsOnlyRenameSafeValueDerivedGrammar() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterBorrowed(new object()); + ShaderDescription description = ShaderDescription.CurrentPixel( + """ + uniform float gain; + uniform float2 offset; + uniform shader lut; + const float bias = 0.125; + const float weights[2] = float[2](0.25, 0.75); + + half3 adjust(half3 value, float amount) + { + half3 adjusted = clamp(value * amount + bias, 0.0, 1.0); + return adjusted; + } + + half4 apply(half4 color) + { + float2 lookup = color.rg + offset; + half3 rgb = adjust(color.rgb, gain) * weights[0] + color.rgb * weights[1]; + return half4(lut.eval(lookup).rgb * rgb, color.a); + } + """, + bindings => + { + bindings.Uniform("gain", 0.5f); + bindings.Uniform("offset", Vector2.Zero); + bindings.Resource( + "lut", + resource, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White))); + }); + + Assert.Multiple(() => + { + Assert.That(description.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + Assert.That(description.Uniforms, Has.Count.EqualTo(2)); + Assert.That(description.Resources, Has.Count.EqualTo(1)); + }); + } + + [TestCase("float leaked; half4 apply(half4 color) { return color; }")] + [TestCase("layout(color) uniform half4 tint; half4 apply(half4 color) { return color; }")] + [TestCase("#define GAIN 2\nhalf4 apply(half4 color) { return color * GAIN; }")] + [TestCase("half4 helper(half4 value); half4 apply(half4 color) { return helper(color); }")] + [TestCase("half4 helper(inout half4 value) { return value; } half4 apply(half4 color) { return helper(color); }")] + [TestCase("half4 apply(half4 color) { float left = 1, right = 2; return color * left; }")] + [TestCase("half4 apply(half4 color) { for (int x = 0, y = 0; x < 1; ++x) { } return color; }")] + [TestCase("half4 apply(half4 color) { float color = 1; return half4(color); }")] + [TestCase("half4 apply(half4 color) { return half4(dFdx(color.r)); }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { return lut.eval(sk_FragCoord.xy); }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { return lut.eval(unknownValue); }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { float2 position; return lut.eval(position); }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { return lut.eval(); }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { return lut.eval(color.rg, color.ba); }")] + [TestCase("uniform shader lut; const half4 sampled = lut.eval(); half4 apply(half4 color) { return sampled; }")] + [TestCase("uniform shader lut; half4 apply(half4 color) { return half4(lut); }")] + [TestCase("uniform float __beutl_value; half4 apply(half4 color) { return color; }")] + public void CurrentPixel_RejectsGrammarThatCannotBeProvenValueOnly(string source) + { + Assert.That( + () => new SkslSource(source, ShaderDescriptionKind.CurrentPixel), + Throws.TypeOf()); + } + + [Test] + public void WholeSource_RemainsACompleteCoordinateShader() + { + RenderBoundsContract bounds = RenderBoundsContract.Create( + static input => input, + static requested => requested); + + ShaderDescription description = ShaderDescription.WholeSource( + """ + uniform shader src; + half4 sampleSource(float2 position) { return src.eval(position); } + half4 main(float2 coord) + { + float2 first = coord, second = coord + float2(1); + return sampleSource(mix(first, second, 0.5)); + } + """, + bounds); + + Assert.That(description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + } + + [TestCase("const float __beutl_pixel = 1.0;")] + [TestCase("half4 __beutl_s7_sample(float2 coord) { return src.eval(coord); }")] + public void WholeSource_RejectsRendererReservedTopLevelDeclarations(string declaration) + { + Assert.That( + () => ShaderDescription.WholeSource( + $"uniform shader src; {declaration} half4 main(float2 coord) {{ return src.eval(coord); }}", + RenderBoundsContract.Identity), + Throws.TypeOf()); + } + + [Test] + public void WholeSource_AllowsNonGeneratedRendererPrefixOnTopLevelNames() + { + ShaderDescription description = ShaderDescription.WholeSource( + "uniform shader src; const float __beutl_custom = 1.0; " + + "half4 main(float2 coord) { return src.eval(coord) * __beutl_custom; }", + RenderBoundsContract.Identity); + + Assert.That(description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + } + + [Test] + public void WholeSource_RejectsCommaSeparatedRendererGeneratedDeclaration() + { + Assert.That( + () => ShaderDescription.WholeSource( + """ + uniform shader src; + half __beutl_head_main, keep; + half4 main(float2 coord) { return src.eval(coord); } + """, + RenderBoundsContract.Identity), + Throws.TypeOf() + .With.Message.Contains("__beutl_head_main")); + } + + [Test] + public void WholeSource_AllowsRendererPrefixOnFunctionLocalNames() + { + ShaderDescription description = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { " + + "float2 __beutl_pixel = coord; return src.eval(__beutl_pixel); }", + RenderBoundsContract.Identity); + + Assert.That(description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + } + + [Test] + public void WholeSource_RequiresImplicitSourceAndExplicitBounds() + { + RenderBoundsContract bounds = RenderBoundsContract.Create( + static input => input.Inflate(new Thickness(2)), + static requested => requested.Inflate(new Thickness(2))); + ShaderDescription description = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + bounds, + sourceTileMode: SKShaderTileMode.Clamp); + + Assert.Multiple(() => + { + Assert.That(description.Kind, Is.EqualTo(ShaderDescriptionKind.WholeSource)); + Assert.That(description.Bounds, Is.EqualTo(bounds)); + Assert.That(description.SourceTileMode, Is.EqualTo(SKShaderTileMode.Clamp)); + Assert.That( + () => ShaderDescription.WholeSource( + "half4 main(float2 coord) { return half4(1); }", + bounds), + Throws.TypeOf()); + Assert.That( + () => ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + default), + Throws.TypeOf()); + }); + } + + [Test] + public void WholeSource_RejectsAnExplicitBindingForItsImplicitSource() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource resource = registry.RegisterBorrowed(new object()); + + ArgumentException? exception = Assert.Throws( + () => ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Identity, + bindings => bindings.Resource( + "src", + resource, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White))))); + + Assert.Multiple(() => + { + Assert.That(exception!.ParamName, Is.EqualTo("resources")); + Assert.That(exception.Message, Does.Contain("implicit WholeSource input 'src'")); + }); + } + + [Test] + public void DirectUniforms_AreCanonicalAndValidatedAgainstDeclarations() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float amount; uniform float2 offset; uniform float4 tint; " + + "half4 apply(half4 color) { return color * amount + half4(offset, 0, 0) + tint; }", + bindings => + { + bindings.Uniform("amount", 0.5f); + bindings.Uniform("offset", new Vector2(1, 2)); + bindings.Uniform("tint", new float[] { 0, 0, 0, 0 }); + }); + + Assert.That(description.Uniforms, Has.Count.EqualTo(3)); + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float2 value; half4 apply(half4 color) { return color; }", + bindings => bindings.Uniform("value", 1f)), + Throws.TypeOf() + .Or.TypeOf()); + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float value; half4 apply(half4 color) { return color; }", + bindings => bindings.Uniform("value", 1L)), + Throws.TypeOf()); + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float value; half4 apply(half4 color) { return color; }", + bindings => + { + bindings.Uniform("value", 1f); + bindings.Uniform("value", 2f); + }), + Throws.TypeOf()); + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float value; half4 apply(half4 color) { return color; }", + bindings => bindings.Uniform("not-valid!", 1f)), + Throws.TypeOf()); + } + + [Test] + public void DirectUniform_UInt32AboveInt32MaxValueReportsRangeError() + { + ArgumentOutOfRangeException exception = Assert.Throws( + () => ShaderDescription.CurrentPixel( + "uniform int value; half4 apply(half4 color) { return color; }", + bindings => bindings.Uniform("value", uint.MaxValue)))!; + + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("value")); + Assert.That(exception.ActualValue, Is.EqualTo(uint.MaxValue)); + Assert.That(exception.Message, Does.Contain("Int32.MaxValue")); + }); + } + + [Test] + public void ResourceBindings_EnforceCoordinateSpaceAndDeclaredType() + { + using var registry = new RenderRequestResourceRegistry(); + var resource = new object(); + RenderResource token = registry.RegisterBorrowed(resource); + + ShaderDescription current = ShaderDescription.CurrentPixel( + "uniform shader lut; half4 apply(half4 color) { return lut.eval(color.rg); }", + bindings => bindings.Resource( + "lut", + token, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White)))); + Assert.That(current.Resources.Single().CoordinateSpace, Is.EqualTo(ShaderResourceCoordinateSpace.Value)); + + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform shader lut; half4 apply(half4 color) { return lut.eval(color.rg); }", + bindings => bindings.Resource( + "lut", + token, + ShaderResourceCoordinateSpace.OutputDevice, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White)))), + Throws.TypeOf()); + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float value; half4 apply(half4 color) { return color * value; }", + bindings => bindings.Resource( + "value", + token, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White)))), + Throws.TypeOf()); + } + + [Test] + public void ScopedCustomBinder_CannotRetainWriterAndDefaultCachePolicyIsRequestUnique() + { + ShaderUniformWriter? retained = null; + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float amount; half4 apply(half4 color) { return color * amount; }", + bindings => bindings.Uniform( + "amount", + 0.25f, + (writer, value, _) => + { + retained = writer; + writer.Set(value); + })); + var token = new RenderExecutionSessionToken(); + var execution = new ShaderExecutionContext( + token, + new Rect(0, 0, 10, 10), + new Rect(0, 0, 10, 10), + new Rect(0, 0, 10, 10), + new PixelRect(0, 0, 10, 10), + EffectiveScale.At(1), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 2, + intent: RenderIntent.Preview, + purpose: RenderRequestPurpose.Frame); + + _ = description.Uniforms.Single().Bind( + new SkslUniformDeclaration("float", null), + execution); + token.Complete(); + + Assert.Multiple(() => + { + Assert.That(() => retained!.Set(0.5f), Throws.TypeOf()); + Assert.That(() => _ = execution.OutputBounds, Throws.TypeOf()); + }); + } + + // SkSL reads matrix uniform data column-major, so a canonical matrix value must be the column-major encoding + // of the SkSL matrix that reproduces the source type's own transform convention. + [Test] + public void CanonicalMatrixValues_AreColumnMajorForTheEquivalentSkslMatrix() + { + // SKMatrix transforms column vectors (p' = M * p) and stores its rows contiguously, so the canonical + // value is its storage order transposed. A translation must therefore land in the last column. + var skMatrix = SKMatrix.CreateTranslation(50, 70); + float[] skValues = ShaderCanonicalValue.Create(skMatrix).Values!; + + // Matrix4x4 transforms row vectors (p' = p * M) and stores its rows contiguously. The equivalent + // column-vector matrix is its transpose, whose column-major encoding is that same storage order. + Matrix4x4 numericsMatrix = Matrix4x4.CreateTranslation(50, 70, 90); + float[] numericsValues = ShaderCanonicalValue.Create(numericsMatrix).Values!; + + // Matrix3x2 has no SkSL matrix type. Its six floats bind to float2[3]: x basis, y basis, translation. + var affine = Matrix3x2.CreateScale(2, 3) * Matrix3x2.CreateTranslation(50, 70); + float[] affineValues = ShaderCanonicalValue.Create(affine).Values!; + + Assert.Multiple(() => + { + Assert.That(skValues, Is.EqualTo(new float[] + { + 1, 0, 0, + 0, 1, 0, + 50, 70, 1, + })); + Assert.That(numericsValues, Is.EqualTo(new float[] + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 50, 70, 90, 1, + })); + Assert.That(affineValues, Is.EqualTo(new float[] + { + 2, 0, + 0, 3, + 50, 70, + })); + }); + } + + // The two conventions agree once both are expressed as an SkSL matrix, so a transform built either way must + // produce the same canonical value. This is what makes the differing member order above correct. + [Test] + public void CanonicalMatrixValues_AgreeBetweenSkiaAndNumericsForTheSameTransform() + { + var skMatrix = SKMatrix.CreateScaleTranslation(2, 3, 50, 70); + Matrix4x4 numericsMatrix = Matrix4x4.CreateScale(2, 3, 1) * Matrix4x4.CreateTranslation(50, 70, 0); + + float[] skValues = ShaderCanonicalValue.Create(skMatrix).Values!; + float[] numericsValues = ShaderCanonicalValue.Create(numericsMatrix).Values!; + + // The 3x3 columns are the 4x4 columns with the z row and column dropped. + float[] projected = + [ + numericsValues[0], numericsValues[1], numericsValues[3], + numericsValues[4], numericsValues[5], numericsValues[7], + numericsValues[12], numericsValues[13], numericsValues[15], + ]; + + Assert.That(skValues, Is.EqualTo(projected)); + } + + [Test] + public void CanonicalMatrixValues_BindToTheDeclaredSkslMatrixType() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float3x3 xform; uniform float2 basis[3]; " + + "half4 apply(half4 color) { return color * (xform[0][0] + basis[2].x); }", + bindings => + { + bindings.Uniform("xform", SKMatrix.CreateTranslation(50, 70)); + bindings.Uniform("basis", Matrix3x2.Identity); + }); + + Assert.Multiple(() => + { + Assert.That(description.Uniforms, Has.Count.EqualTo(2)); + + // float3x3 takes nine floats; a Matrix4x4 supplies sixteen and must be rejected. + Assert.That( + () => ShaderDescription.CurrentPixel( + "uniform float3x3 xform; half4 apply(half4 color) { return color * xform[0][0]; }", + bindings => bindings.Uniform("xform", Matrix4x4.Identity)), + Throws.TypeOf().Or.TypeOf()); + }); + } + + private sealed record CollisionKey(string Value) + { + public override int GetHashCode() => 7; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs new file mode 100644 index 0000000000..4d250ed17b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs @@ -0,0 +1,306 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +public sealed class ShaderFallbackTests +{ + private static readonly Rect s_bounds = new(0, 0, 6, 4); + + [TestCase(ShaderDescriptionKind.CurrentPixel)] + [TestCase(ShaderDescriptionKind.WholeSource)] + public void OrdinaryCpuBackend_RendersEveryPublicShaderFormWithoutSkipping( + ShaderDescriptionKind kind) + { + using var source = new CpuRenderTarget(6, 4); + source.Value.Canvas.Clear(new SKColor(64, 128, 192, 160)); + using Bitmap sourceBitmap = source.Snapshot(); + ShaderDescription description = kind == ShaderDescriptionKind.CurrentPixel + ? ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.bgr, color.a); }") + : ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 p) { return src.eval(p).bgra; }", + RenderBoundsContract.Identity); + using var node = new ShaderNode(source, description); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(rasterization.Bounds, Is.EqualTo(s_bounds)); + Assert.That(SumAbsoluteChannels(rasterization.Bitmap!), Is.GreaterThan(1)); + AssertBlueRedSwap(sourceBitmap, rasterization.Bitmap!); + }); + } + + [Test] + public void FusedCurrentPixelStages_ReceiveTheSameRoiCroppedInputBoundsAsUnfusedStages() + { + var requestedRegion = new Rect(2, 1, 2, 2); + using var source = new CpuRenderTarget(6, 4); + source.Value.Canvas.Clear(new SKColor(64, 128, 192, 160)); + var disabledInputBounds = new List(); + var enabledInputBounds = new List(); + using var disabledNode = new BoundShaderChainNode(source, disabledInputBounds); + using var enabledNode = new BoundShaderChainNode(source, enabledInputBounds); + using var disabled = CreateRenderer(disabledNode, requestedRegion, FusionMode.Disabled); + using var enabled = CreateRenderer(enabledNode, requestedRegion, FusionMode.Enabled); + + using RenderNodeRasterization disabledRaster = disabled.Rasterize(); + using RenderNodeRasterization enabledRaster = enabled.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(disabledInputBounds[1], Is.EqualTo(requestedRegion)); + Assert.That(enabledInputBounds, Is.EqualTo(disabledInputBounds)); + Assert.That(enabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + Assert.That(disabledRaster.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(enabledRaster.Bounds, Is.EqualTo(requestedRegion)); + }); + } + + [Test] + public void OrdinaryCpuBackend_PreservesExplicitProgramValidationFailure() + { + using var source = new CpuRenderTarget(6, 4); + ShaderDescription invalid = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 p) { this is not valid SkSL; }", + RenderBoundsContract.Identity); + using var node = new ShaderNode(source, invalid); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + Assert.That( + () => renderer.Rasterize(), + Throws.TypeOf() + .With.Message.StartsWith("SkSL program validation failed:")); + } + + [TestCase(ShaderDescriptionKind.WholeSource)] + [TestCase(ShaderDescriptionKind.CurrentPixel)] + public void CompatibilityShaderProgramCache_ColdMissThenWarmHit( + ShaderDescriptionKind kind) + { + using var source = new CpuRenderTarget(6, 4); + source.Value.Canvas.Clear(new SKColor(64, 128, 192, 160)); + using Bitmap sourceBitmap = source.Snapshot(); + ShaderDescription description; + if (kind == ShaderDescriptionKind.WholeSource) + { + description = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 p) { return src.eval(p).bgra; }", + RenderBoundsContract.Identity); + } + else + { + string currentPixelSource = + $"/*{new string('界', 22_000)}*/\n" + + "half4 apply(half4 color) { return color.bgra; }"; + description = ShaderDescription.CurrentPixel(currentPixelSource); + SkslMergedProgram fallback = SkslSnippetMerger.MergeAndSplit( + [new SkslSnippetStage(description)], + SkslBackendBudgetResolver.Portable)[0]; + Assert.That( + fallback.OverflowReasons, + Does.Contain(SkslBackendLimit.SourceBytes), + "the test must exercise the backend-overflow compatibility path"); + } + + using var node = new ShaderNode(source, description); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization cold = renderer.Rasterize(); + ProgramCacheStatistics coldStatistics = renderer.ProgramCacheStatistics; + using RenderNodeRasterization warm = renderer.Rasterize(); + ProgramCacheStatistics warmStatistics = renderer.ProgramCacheStatistics; + + Assert.Multiple(() => + { + Assert.That(cold.Bitmap, Is.Not.Null); + Assert.That(warm.Bitmap, Is.Not.Null); + AssertBlueRedSwap(sourceBitmap, cold.Bitmap!); + AssertBlueRedSwap(sourceBitmap, warm.Bitmap!); + Assert.That(coldStatistics.Creations, Is.EqualTo(1)); + Assert.That(coldStatistics.Misses, Is.EqualTo(1)); + Assert.That(coldStatistics.Hits, Is.Zero); + Assert.That(warmStatistics.Creations, Is.EqualTo(1)); + Assert.That(warmStatistics.Misses, Is.EqualTo(1)); + Assert.That(warmStatistics.Hits, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ProgramCacheHits, Is.EqualTo(1)); + }); + } + + private static double SumAbsoluteChannels(Bitmap bitmap) + { + double result = 0; + foreach (ushort bits in bitmap.GetPixelSpan()) + result += Math.Abs((float)BitConverter.UInt16BitsToHalf(bits)); + return result; + } + + private static void AssertBlueRedSwap(Bitmap source, Bitmap actual) + { + (float sourceRed, float sourceGreen, float sourceBlue, float sourceAlpha) = ChannelsAt(source, 0, 0); + (float actualRed, float actualGreen, float actualBlue, float actualAlpha) = ChannelsAt(actual, 0, 0); + Assert.Multiple(() => + { + Assert.That(sourceRed, Is.Not.EqualTo(sourceBlue).Within(0.001f), + "the source fixture must distinguish a skipped shader from a red/blue swap"); + Assert.That(actualRed, Is.EqualTo(sourceBlue).Within(0.002f)); + Assert.That(actualGreen, Is.EqualTo(sourceGreen).Within(0.002f)); + Assert.That(actualBlue, Is.EqualTo(sourceRed).Within(0.002f)); + Assert.That(actualAlpha, Is.EqualTo(sourceAlpha).Within(0.002f)); + }); + } + + private static (float Red, float Green, float Blue, float Alpha) ChannelsAt( + Bitmap bitmap, + int x, + int y) + { + Span row = bitmap.GetRow(y); + int offset = x * 4; + return ( + (float)BitConverter.UInt16BitsToHalf(row[offset]), + (float)BitConverter.UInt16BitsToHalf(row[offset + 1]), + (float)BitConverter.UInt16BitsToHalf(row[offset + 2]), + (float)BitConverter.UInt16BitsToHalf(row[offset + 3])); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + Rect requestedRegion, + FusionMode fusionMode) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private sealed class ShaderNode(RenderTarget source, ShaderDescription description) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow(source); + RenderFragmentHandle input = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + context.Publish(context.Shader(input, description)); + } + } + + private sealed class BoundShaderChainNode : RenderNode + { + private readonly RenderTarget _source; + private readonly IReadOnlyList _stages; + + public BoundShaderChainNode(RenderTarget source, ICollection observedInputBounds) + { + _source = source; + _stages = + [ + CreateStage(0, observedInputBounds), + CreateStage(1, observedInputBounds), + ]; + } + + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow(_source); + RenderFragmentHandle current = context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds)); + foreach (ShaderDescription stage in _stages) + current = context.Shader(current, stage); + context.Publish(current); + } + + private static ShaderDescription CreateStage( + int stage, + ICollection observedInputBounds) + => ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 1f, + (writer, value, execution) => + { + observedInputBounds.Add(execution.InputBounds); + writer.Set(value); + })); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslBackendBudgetResolverTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslBackendBudgetResolverTests.cs new file mode 100644 index 0000000000..809ee9b8c7 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslBackendBudgetResolverTests.cs @@ -0,0 +1,162 @@ +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +public sealed class SkslBackendBudgetResolverTests +{ + [Test] + public void PortableProfile_UsesFiniteConservativeFusionLimits() + { + SkslBackendBudget budget = SkslBackendBudgetResolver.Portable; + + Assert.Multiple(() => + { + Assert.That(budget.CapabilityClass, Is.EqualTo(SkslBackendCapabilityClass.Portable)); + Assert.That(budget.MaxStages, Is.EqualTo(16)); + Assert.That(budget.MaxUniformVectors, Is.EqualTo(128)); + Assert.That(budget.MaxSamplers, Is.EqualTo(12)); + Assert.That(budget.MaxChildren, Is.EqualTo(12)); + Assert.That(budget.MaxSourceBytes, Is.EqualTo(64 * 1024)); + Assert.That(budget.MaxProgramTokens, Is.EqualTo(16 * 1024)); + }); + } + + [TestCase(GRBackend.Vulkan, "Vulkan")] + [TestCase(GRBackend.Metal, "Metal")] + [TestCase(GRBackend.OpenGL, "Portable")] + [TestCase(GRBackend.Direct3D, "Portable")] + [TestCase(GRBackend.Dawn, "Portable")] + [TestCase(GRBackend.Unsupported, "Portable")] + public void Resolve_MapsBackendToStableCapabilityClass( + GRBackend backend, + string expected) + { + SkslBackendBudget first = SkslBackendBudgetResolver.Resolve(backend); + SkslBackendBudget second = SkslBackendBudgetResolver.Resolve(backend); + + Assert.Multiple(() => + { + Assert.That(first, Is.SameAs(second)); + Assert.That(first.CapabilityClass.ToString(), Is.EqualTo(expected)); + }); + } + + [Test] + public void Resolve_NullAndUnknownBackendUsePortableProfile() + { + SkslBackendBudget portable = SkslBackendBudgetResolver.Portable; + + Assert.Multiple(() => + { + Assert.That(SkslBackendBudgetResolver.Resolve(null), Is.SameAs(portable)); + Assert.That(SkslBackendBudgetResolver.Resolve((GRBackend)int.MaxValue), Is.SameAs(portable)); + Assert.That( + SkslBackendBudgetResolver.Resolve(GRBackend.Vulkan).CapabilityClass, + Is.Not.EqualTo(portable.CapabilityClass)); + Assert.That( + SkslBackendBudgetResolver.Resolve(GRBackend.Metal).CapabilityClass, + Is.Not.EqualTo(portable.CapabilityClass)); + }); + } + + [TestCase("Portable", 12, 12)] + [TestCase("Vulkan", 12, 12)] + [TestCase("Metal", 12, 12)] + public void CapabilityProfiles_UseSupportedBackendFloorWithHeadroom( + string capabilityClass, + int expectedSamplers, + int expectedChildren) + { + SkslBackendBudget budget = capabilityClass switch + { + "Portable" => SkslBackendBudgetResolver.Portable, + "Vulkan" => SkslBackendBudgetResolver.Resolve(GRBackend.Vulkan), + "Metal" => SkslBackendBudgetResolver.Resolve(GRBackend.Metal), + _ => throw new ArgumentOutOfRangeException(nameof(capabilityClass)), + }; + + Assert.Multiple(() => + { + Assert.That(budget.CapabilityClass.ToString(), Is.EqualTo(capabilityClass)); + Assert.That(budget.MaxStages, Is.EqualTo(16)); + Assert.That(budget.MaxUniformVectors, Is.EqualTo(128)); + Assert.That(budget.MaxSamplers, Is.EqualTo(expectedSamplers)); + Assert.That(budget.MaxChildren, Is.EqualTo(expectedChildren)); + Assert.That(budget.MaxSourceBytes, Is.EqualTo(64 * 1024)); + Assert.That(budget.MaxProgramTokens, Is.EqualTo(16 * 1024)); + }); + } + + [Test] + public void CapabilityProfiles_DivergeAndSeparateProgramIdentity() + { + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + var stage = new SkslSnippetStage(description); + SkslBackendBudget portable = SkslBackendBudgetResolver.Portable; + SkslBackendBudget vulkan = SkslBackendBudgetResolver.Resolve(GRBackend.Vulkan); + SkslBackendBudget metal = SkslBackendBudgetResolver.Resolve(GRBackend.Metal); + + SkslMergedProgram portableProgram = SkslSnippetMerger.MergeAndSplit([stage], portable).Single(); + SkslMergedProgram vulkanProgram = SkslSnippetMerger.MergeAndSplit([stage], vulkan).Single(); + SkslMergedProgram metalProgram = SkslSnippetMerger.MergeAndSplit([stage], metal).Single(); + var contextIdentity = new RenderCacheDeviceContextIdentity("device", "context"); + ProgramCacheContextKey portableContext = SkRuntimeEffectProgramCache.CreateContextKey( + contextIdentity, + portable); + ProgramCacheContextKey vulkanContext = SkRuntimeEffectProgramCache.CreateContextKey( + contextIdentity, + vulkan); + ProgramCacheContextKey metalContext = SkRuntimeEffectProgramCache.CreateContextKey( + contextIdentity, + metal); + + Assert.Multiple(() => + { + Assert.That(portable, Is.Not.EqualTo(vulkan)); + Assert.That(portable, Is.Not.EqualTo(metal)); + Assert.That(vulkan, Is.Not.EqualTo(metal)); + Assert.That(portableProgram.Identity, Is.Not.EqualTo(vulkanProgram.Identity)); + Assert.That(portableProgram.Identity, Is.Not.EqualTo(metalProgram.Identity)); + Assert.That(vulkanProgram.Identity, Is.Not.EqualTo(metalProgram.Identity)); + Assert.That(portableContext, Is.Not.EqualTo(vulkanContext)); + Assert.That(portableContext, Is.Not.EqualTo(metalContext)); + Assert.That(vulkanContext, Is.Not.EqualTo(metalContext)); + }); + } + + [Test] + public void CapabilityClass_RemainsPartOfBudgetAndCacheIdentityWhenLimitsMatch() + { + SkslBackendBudget vulkan = CreateIdentityBudget(SkslBackendCapabilityClass.Vulkan); + SkslBackendBudget metal = CreateIdentityBudget(SkslBackendCapabilityClass.Metal); + ShaderDescription description = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + var stage = new SkslSnippetStage(description); + SkslMergedProgram vulkanProgram = SkslSnippetMerger.MergeAndSplit([stage], vulkan).Single(); + SkslMergedProgram metalProgram = SkslSnippetMerger.MergeAndSplit([stage], metal).Single(); + var contextIdentity = new RenderCacheDeviceContextIdentity("device", "context"); + + Assert.Multiple(() => + { + Assert.That(vulkan, Is.Not.EqualTo(metal)); + Assert.That(vulkanProgram.Identity, Is.Not.EqualTo(metalProgram.Identity)); + Assert.That( + SkRuntimeEffectProgramCache.CreateContextKey(contextIdentity, vulkan), + Is.Not.EqualTo(SkRuntimeEffectProgramCache.CreateContextKey(contextIdentity, metal))); + }); + } + + private static SkslBackendBudget CreateIdentityBudget(SkslBackendCapabilityClass capabilityClass) + => new( + capabilityClass, + maxStages: 16, + maxUniformVectors: 128, + maxSamplers: 16, + maxChildren: 16, + maxSourceBytes: 64 * 1024, + maxProgramTokens: 16 * 1024); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslSnippetMergerTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslSnippetMergerTests.cs new file mode 100644 index 0000000000..d00b37a8dc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/SkslSnippetMergerTests.cs @@ -0,0 +1,598 @@ +using System.Text; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; + +[TestFixture] +public sealed class SkslSnippetMergerTests +{ + private const string Identity = "half4 apply(half4 color) { return color; }"; + + [Test] + public void Merge_IsolatesTopLevelSymbolsWithoutRenamingMembersOrComments() + { + const string source = + "uniform float gain;\n" + + "const float weights[2] = float[2](0.25, 0.75);\n" + + "half3 adjust(half3 value) { return value * gain * weights[0]; }\n" + + "half4 apply(half4 color) { /* gain weights adjust */ " + + "return half4(adjust(color.rgb) + color.rrr * weights[1], color.a); }"; + + ShaderDescription first = ShaderDescription.CurrentPixel( + source, + static bindings => bindings.Uniform("gain", 0.5f)); + ShaderDescription second = ShaderDescription.CurrentPixel( + source, + static bindings => bindings.Uniform("gain", 0.75f)); + + SkslMergedProgram program = SkslSnippetMerger.Merge( + [new(first), new(second)]); + string firstPrefix = program.Stages[0].Prefix; + string secondPrefix = program.Stages[1].Prefix; + + Assert.Multiple(() => + { + Assert.That(firstPrefix, Is.Not.EqualTo(secondPrefix)); + Assert.That(program.Source, Does.Contain($"uniform float {firstPrefix}gain;") + .And.Contain($"uniform float {secondPrefix}gain;")); + Assert.That(program.Source, Does.Contain($"{firstPrefix}weights[2]") + .And.Contain($"{secondPrefix}weights[2]")); + Assert.That(program.Source, Does.Contain($"{firstPrefix}adjust") + .And.Contain($"{secondPrefix}adjust")); + Assert.That(program.Source, Does.Contain("color.rrr") + .And.Not.Contain($"color.{firstPrefix}") + .And.Not.Contain($"color.{secondPrefix}")); + Assert.That(program.Source, Does.Contain("/* gain weights adjust */"), + "comments are copied verbatim rather than interpreted as identifiers"); + }); + } + + [Test] + public void Merge_UsesValidatedPrecisionQualifiedTopLevelSymbols() + { + const string source = + "uniform highp float gain;\n" + + "const mediump float bias = 0.25;\n" + + "highp float4 helper(highp float4 value) { return value * gain + bias; }\n" + + "half4 apply(half4 color) { return half4(helper(float4(color))); }"; + ShaderDescription description = ShaderDescription.CurrentPixel( + source, + static bindings => bindings.Uniform("gain", 0.5f)); + + SkslMergedProgram program = SkslSnippetMerger.Merge( + [new(description), new(description)]); + + Assert.Multiple(() => + { + Assert.That( + description.Source.TopLevelSymbols, + Is.EquivalentTo(new[] { "gain", "bias", "helper", "apply" })); + foreach (SkslMergedStageLayout stage in program.Stages) + { + Assert.That(program.Source, Does.Contain($"uniform highp float {stage.Prefix}gain;")); + Assert.That(program.Source, Does.Contain($"const mediump float {stage.Prefix}bias")); + Assert.That(program.Source, Does.Contain($"highp float4 {stage.Prefix}helper(")); + Assert.That(program.Source, Does.Contain($"half4 {stage.Prefix}apply(")); + } + }); + + using SKRuntimeEffect? effect = SKRuntimeEffect.CreateShader(program.Source, out string? error); + Assert.Multiple(() => + { + Assert.That(error, Is.Null); + Assert.That(effect, Is.Not.Null); + }); + } + + [Test] + public void Merge_PreservesAuthoredStageOrder() + { + ShaderDescription red = ShaderDescription.CurrentPixel( + "half4 red(half4 value) { return half4(value.r, 0, 0, value.a); } " + + "half4 apply(half4 color) { return red(color); }"); + ShaderDescription blue = ShaderDescription.CurrentPixel( + "half4 blue(half4 value) { return half4(0, 0, value.b, value.a); } " + + "half4 apply(half4 color) { return blue(color); }"); + + SkslMergedProgram program = SkslSnippetMerger.Merge([new(red), new(blue)]); + string firstPrefix = program.Stages[0].Prefix; + string secondPrefix = program.Stages[1].Prefix; + + int firstCall = program.Source.IndexOf( + $"__beutl_pixel = {firstPrefix}apply(__beutl_pixel);", + StringComparison.Ordinal); + int secondCall = program.Source.IndexOf( + $"__beutl_pixel = {secondPrefix}apply(__beutl_pixel);", + StringComparison.Ordinal); + + Assert.Multiple(() => + { + Assert.That(firstCall, Is.GreaterThanOrEqualTo(0)); + Assert.That(secondCall, Is.GreaterThan(firstCall)); + Assert.That(program.Stages.Select(static stage => stage.StageIndex), Is.EqualTo(new[] { 0, 1 })); + }); + } + + [Test] + public void Merge_ProducesDeterministicBindingLayout() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource lookup = registry.RegisterBorrowed(new object()); + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float gain; uniform float2 offset; uniform shader lookup; " + + "half4 apply(half4 color) { return lookup.eval(color.rg + offset) * gain; }", + bindings => + { + bindings.Uniform("gain", 0.5f); + bindings.Uniform("offset", new System.Numerics.Vector2(1, 2)); + bindings.Resource( + "lookup", + lookup, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White))); + }); + + SkslMergedProgram first = SkslSnippetMerger.Merge([new(description)]); + SkslMergedProgram second = SkslSnippetMerger.Merge([new(description)]); + + Assert.Multiple(() => + { + Assert.That( + first.Bindings.Select(static binding => + (binding.StageIndex, binding.Kind, binding.OriginalName, binding.MergedName, + binding.Type, binding.ArrayExtent, binding.CoordinateSpace)), + Is.EqualTo(new[] + { + (0, SkslBindingKind.Uniform, "gain", "__beutl_s0_gain", "float", (int?)null, + (ShaderResourceCoordinateSpace?)null), + (0, SkslBindingKind.Uniform, "offset", "__beutl_s0_offset", "float2", (int?)null, + (ShaderResourceCoordinateSpace?)null), + (0, SkslBindingKind.Resource, "lookup", "__beutl_s0_lookup", "shader", (int?)null, + (ShaderResourceCoordinateSpace?)ShaderResourceCoordinateSpace.Value), + })); + Assert.That(second.Bindings, Is.EqualTo(first.Bindings)); + Assert.That(second.Identity, Is.EqualTo(first.Identity)); + }); + } + + [Test] + public void MergeAndSplit_SplitsBeforeStageLimitDeterministically() + { + var stages = Enumerable.Range(0, 5) + .Select(_ => new SkslSnippetStage(ShaderDescription.CurrentPixel(Identity))) + .ToArray(); + SkslBackendBudget budget = Budget(maxStages: 2); + + IReadOnlyList first = SkslSnippetMerger.MergeAndSplit(stages, budget); + IReadOnlyList second = SkslSnippetMerger.MergeAndSplit(stages, budget); + + Assert.Multiple(() => + { + Assert.That(first.Select(static program => program.StageCount), Is.EqualTo(new[] { 2, 2, 1 })); + Assert.That( + first.Select(static program => program.Stages.Select(static stage => stage.StageIndex).ToArray()), + Is.EqualTo(new[] { new[] { 0, 1 }, new[] { 2, 3 }, new[] { 4 } })); + Assert.That(second.Select(static program => program.Source), + Is.EqualTo(first.Select(static program => program.Source))); + Assert.That(first, Has.All.Matches(static program => !program.RequiresStandaloneExecution)); + }); + } + + [Test] + public void MergeAndSplit_AccountsForUniformVectorLimitsIncludingArraysAndMatrices() + { + ShaderDescription first = ShaderDescription.CurrentPixel( + "uniform float4 values[2]; half4 apply(half4 color) { return color * values[0]; }", + static bindings => bindings.Uniform("values", (ReadOnlySpan)[1, 1, 1, 1, 1, 1, 1, 1])); + ShaderDescription second = ShaderDescription.CurrentPixel( + "uniform float2x2 matrix; half4 apply(half4 color) { return color * matrix[0][0]; }", + static bindings => bindings.Uniform("matrix", (ReadOnlySpan)[1, 0, 0, 1])); + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + [new(first), new(second)], + Budget(maxUniformVectors: 2)); + + Assert.Multiple(() => + { + Assert.That(programs, Has.Count.EqualTo(2)); + Assert.That(programs.Select(static program => program.UniformVectorCount), Is.EqualTo(new[] { 2, 2 })); + Assert.That(programs, Has.All.Matches(static program => !program.RequiresStandaloneExecution)); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void MergeAndSplit_AccountsForSamplerAndChildLimits(bool samplerLimit) + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource firstResource = registry.RegisterBorrowed(new object()); + RenderResource secondResource = registry.RegisterBorrowed(new object()); + ShaderDescription first = ResourceShader("lookup", firstResource); + ShaderDescription second = ResourceShader("lookup", secondResource); + SkslBackendBudget budget = samplerLimit + ? Budget(maxSamplers: 2) + : Budget(maxChildren: 2); + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + [new(first), new(second)], + budget); + + Assert.Multiple(() => + { + Assert.That(programs, Has.Count.EqualTo(2)); + Assert.That(programs.Select(static program => program.SamplerCount), Is.EqualTo(new[] { 2, 2 })); + Assert.That(programs.Select(static program => program.ChildCount), Is.EqualTo(new[] { 2, 2 })); + Assert.That(programs, Has.All.Matches(static program => !program.RequiresStandaloneExecution)); + }); + } + + [Test] + public void PortableBudget_ReservesOneSamplerAndChildForTheImplicitSource() + { + using var registry = new RenderRequestResourceRegistry(); + SkslBackendBudget budget = SkslBackendBudgetResolver.Portable; + SkslSnippetStage[] stages = Enumerable.Range(0, budget.MaxSamplers) + .Select(index => + { + RenderResource resource = registry.RegisterBorrowed(new object()); + return new SkslSnippetStage(ResourceShader($"lookup{index}", resource)); + }) + .ToArray(); + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + stages, + budget); + + Assert.Multiple(() => + { + Assert.That(programs.Select(static program => program.StageCount), + Is.EqualTo(new[] { budget.MaxSamplers - 1, 1 })); + Assert.That(programs.Select(static program => program.SamplerCount), + Is.EqualTo(new[] { budget.MaxSamplers, 2 })); + Assert.That(programs.Select(static program => program.ChildCount), + Is.EqualTo(new[] { budget.MaxChildren, 2 })); + Assert.That(programs, Has.All.Matches( + static program => !program.RequiresStandaloneExecution)); + }); + } + + [TestCase(GRBackend.Vulkan)] + [TestCase(GRBackend.Metal)] + public void BackendProfile_SingleStageBeyondSamplerLimitRequiresStandaloneFallback(GRBackend backend) + { + using var registry = new RenderRequestResourceRegistry(); + SkslBackendBudget budget = SkslBackendBudgetResolver.Resolve(backend); + ShaderDescription description = ResourceShader(budget.MaxSamplers, registry); + + SkslMergedProgram program = SkslSnippetMerger.MergeAndSplit( + [new SkslSnippetStage(description)], + budget).Single(); + + Assert.Multiple(() => + { + Assert.That(program.SamplerCount, Is.EqualTo(budget.MaxSamplers + 1)); + Assert.That(program.RequiresStandaloneExecution, Is.True); + Assert.That(program.OverflowReasons, Does.Contain(SkslBackendLimit.Samplers)); + Assert.That(program.OverflowReasons.Contains(SkslBackendLimit.Children), + Is.EqualTo(program.ChildCount > budget.MaxChildren)); + Assert.That(program.Stages, Has.Count.EqualTo(1), + "an individually unsupported stage remains visible to the ordinary unfused fallback"); + }); + } + + [Test] + public void MergeAndSplit_AccountsForGeneratedSourceLimit() + { + SkslSnippetStage first = new(ShaderDescription.CurrentPixel(Identity)); + SkslSnippetStage second = new(ShaderDescription.CurrentPixel( + "half4 helper(half4 value) { return value; } " + + "half4 apply(half4 color) { return helper(color); }")); + SkslMergedProgram firstOnly = SkslSnippetMerger.Merge([first]); + SkslMergedProgram secondOnly = SkslSnippetMerger.Merge([second]); + int limit = Math.Max(firstOnly.SourceByteCount, secondOnly.SourceByteCount); + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + [first, second], + Budget(maxSourceBytes: limit)); + + Assert.Multiple(() => + { + Assert.That(programs, Has.Count.EqualTo(2)); + Assert.That(programs, Has.All.Matches(program => program.SourceByteCount <= limit)); + Assert.That(programs.SelectMany(static program => program.Stages) + .Select(static stage => stage.StageIndex), Is.EqualTo(new[] { 0, 1 })); + }); + } + + [Test] + public void MergeAndSplit_AccountsForBackendProgramTokenLimit() + { + SkslSnippetStage first = new(ShaderDescription.CurrentPixel(Identity)); + SkslSnippetStage second = new(ShaderDescription.CurrentPixel( + "half4 helper(half4 value) { return value; } " + + "half4 apply(half4 color) { return helper(color); }")); + SkslMergedProgram firstOnly = SkslSnippetMerger.Merge([first]); + SkslMergedProgram secondOnly = SkslSnippetMerger.Merge([second]); + int limit = Math.Max(firstOnly.ProgramTokenCount, secondOnly.ProgramTokenCount); + + IReadOnlyList programs = SkslSnippetMerger.MergeAndSplit( + [first, second], + Budget(maxProgramTokens: limit)); + + Assert.Multiple(() => + { + Assert.That(programs, Has.Count.EqualTo(2)); + Assert.That(programs, Has.All.Matches(program => program.ProgramTokenCount <= limit)); + Assert.That(programs.SelectMany(static program => program.Stages) + .Select(static stage => stage.StageIndex), Is.EqualTo(new[] { 0, 1 })); + }); + } + + [Test] + public void MergeAndSplit_UsesExactUtf8AndTokenMetricsAcrossStageIndexDigitBoundary() + { + const string source = + "// UTF-8 境界コメント\r\n" + + "const highp float gain = 1.0;\r\n" + + "half4 apply(half4 color) { return color * gain; }\r\n"; + ShaderDescription description = ShaderDescription.CurrentPixel(source); + SkslSnippetStage[] stages = Enumerable.Range(0, 11) + .Select(_ => new SkslSnippetStage(description)) + .ToArray(); + SkslMergedProgram merged = SkslSnippetMerger.Merge(stages); + + IReadOnlyList atBoundary = SkslSnippetMerger.MergeAndSplit( + stages, + Budget( + maxSourceBytes: merged.SourceByteCount, + maxProgramTokens: merged.ProgramTokenCount)); + IReadOnlyList belowByteBoundary = SkslSnippetMerger.MergeAndSplit( + stages, + Budget( + maxSourceBytes: merged.SourceByteCount - 1, + maxProgramTokens: merged.ProgramTokenCount)); + IReadOnlyList belowTokenBoundary = SkslSnippetMerger.MergeAndSplit( + stages, + Budget( + maxSourceBytes: merged.SourceByteCount, + maxProgramTokens: merged.ProgramTokenCount - 1)); + + Assert.Multiple(() => + { + Assert.That(merged.Source, Does.Contain("__beutl_s9_apply") + .And.Contain("__beutl_s10_apply")); + Assert.That(merged.Source, Does.Not.Contain('\r')); + Assert.That(merged.SourceByteCount, Is.EqualTo(Encoding.UTF8.GetByteCount(merged.Source))); + Assert.That(merged.ProgramTokenCount, Is.EqualTo(SkslLexer.Tokenize(merged.Source).Count)); + Assert.That(atBoundary, Has.Count.EqualTo(1)); + Assert.That(belowByteBoundary, Has.Count.EqualTo(2)); + Assert.That(belowTokenBoundary, Has.Count.EqualTo(2)); + }); + + foreach (SkslMergedProgram program in atBoundary + .Concat(belowByteBoundary) + .Concat(belowTokenBoundary)) + { + Assert.Multiple(() => + { + Assert.That(program.SourceByteCount, Is.EqualTo(Encoding.UTF8.GetByteCount(program.Source))); + Assert.That(program.ProgramTokenCount, Is.EqualTo(SkslLexer.Tokenize(program.Source).Count)); + }); + } + + Assert.Multiple(() => + { + Assert.That( + belowByteBoundary.SelectMany(static program => program.Stages) + .Select(static stage => stage.StageIndex), + Is.EqualTo(Enumerable.Range(0, 11))); + Assert.That( + belowTokenBoundary.SelectMany(static program => program.Stages) + .Select(static stage => stage.StageIndex), + Is.EqualTo(Enumerable.Range(0, 11))); + }); + } + + [Test] + public void MergeAndSplit_ReportsSingleStageBackendOverflowForStandaloneFallback() + { + SkslSnippetStage stage = new(ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.5f))); + + SkslMergedProgram program = SkslSnippetMerger.MergeAndSplit( + [stage], + Budget(maxUniformVectors: 0))[0]; + + Assert.Multiple(() => + { + Assert.That(program.RequiresStandaloneExecution, Is.True); + Assert.That(program.OverflowReasons, Does.Contain(SkslBackendLimit.UniformVectors)); + Assert.That(program.Stages, Has.Count.EqualTo(1), + "an individually unsupported stage remains visible to the ordinary unfused fallback"); + }); + } + + [Test] + public void ProgramIdentity_UsesHashOnlyAsBucketAndComparesFullSourceAndLayout() + { + SkslMergedProgram first = SkslSnippetMerger.Merge( + [new(ShaderDescription.CurrentPixel(Identity))]); + SkslMergedProgram second = SkslSnippetMerger.Merge( + [new(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.a - color.rgb, color.a); }"))]); + ShaderProgramIdentity firstCollision = ShaderProgramIdentity.CreateSksl( + first.Source, + first.Bindings, + first.Budget, + bucketHashOverride: 17); + ShaderProgramIdentity secondCollision = ShaderProgramIdentity.CreateSksl( + second.Source, + second.Bindings, + second.Budget, + bucketHashOverride: 17); + + Assert.Multiple(() => + { + Assert.That(firstCollision.GetHashCode(), Is.EqualTo(secondCollision.GetHashCode())); + Assert.That(firstCollision, Is.Not.EqualTo(secondCollision)); + Assert.That(new HashSet { firstCollision, secondCollision }, Has.Count.EqualTo(2)); + }); + } + + [Test] + public void CoverageMetadata_RequiresEveryStageToHaveAnEngineProof() + { + ShaderDescription description = ShaderDescription.CurrentPixel(Identity); + SkslMergedProgram homogeneous = SkslSnippetMerger.Merge( + [ + new(description, SkslCoverageBehavior.PremultipliedCoverageHomogeneous), + new(description, SkslCoverageBehavior.PremultipliedCoverageHomogeneous), + ]); + SkslMergedProgram mixed = SkslSnippetMerger.Merge( + [ + new(description, SkslCoverageBehavior.PremultipliedCoverageHomogeneous), + new(description), + ]); + + Assert.Multiple(() => + { + Assert.That(homogeneous.IsPremultipliedCoverageHomogeneous, Is.True); + Assert.That(homogeneous.RequiresResolvedCoverage, Is.False); + Assert.That(mixed.IsPremultipliedCoverageHomogeneous, Is.False); + Assert.That(mixed.RequiresResolvedCoverage, Is.True); + Assert.That(mixed.Stages[1].CoverageBehavior, + Is.EqualTo(SkslCoverageBehavior.RequiresResolvedCoverage)); + }); + } + + [Test] + public void Merge_WholeSourceHeadFeedsFollowingCurrentPixelStage() + { + ShaderDescription wholeSource = ShaderDescription.WholeSource( + "uniform shader src; uniform float gain; " + + "half4 sampleSource(float2 coord) { return src.eval(coord) * gain; } " + + "half4 main(float2 coord) { return sampleSource(coord); }", + RenderBoundsContract.Identity, + static bindings => bindings.Uniform("gain", 0.75f)); + ShaderDescription currentPixel = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.5f)); + + SkslMergedProgram program = SkslSnippetMerger.Merge( + [new SkslSnippetStage(wholeSource), new SkslSnippetStage(currentPixel)]); + + Assert.Multiple(() => + { + Assert.That(program.Source, Does.Contain("half4 __beutl_head_main(float2 coord)") + .And.Contain("half4 sampleSource(float2 coord)") + .And.Contain("half4 __beutl_s1_apply(half4 color)") + .And.Contain("half4 __beutl_pixel = __beutl_head_main(coord);") + .And.Contain("__beutl_pixel = __beutl_s1_apply(__beutl_pixel);")); + Assert.That(program.Source.Split("uniform shader src;", StringSplitOptions.None), Has.Length.EqualTo(2), + "the WholeSource declaration is the only implicit-source declaration"); + Assert.That(program.Bindings.Select(static binding => binding.MergedName), + Is.EqualTo(new[] { "gain", "__beutl_s1_gain" })); + Assert.That(program.SourceByteCount, Is.EqualTo(Encoding.UTF8.GetByteCount(program.Source))); + Assert.That(program.ProgramTokenCount, Is.EqualTo(SkslLexer.Tokenize(program.Source).Count)); + }); + + using SKRuntimeEffect? effect = SKRuntimeEffect.CreateShader(program.Source, out string? error); + Assert.Multiple(() => + { + Assert.That(error, Is.Null); + Assert.That(effect, Is.Not.Null); + }); + } + + [Test] + public void Merge_RejectsWholeSourceAfterTheHeadPosition() + { + ShaderDescription wholeSource = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Identity); + ShaderDescription currentPixel = ShaderDescription.CurrentPixel(Identity); + + Assert.That( + () => SkslSnippetMerger.Merge( + [new SkslSnippetStage(currentPixel), new SkslSnippetStage(wholeSource)]), + Throws.TypeOf()); + } + + [Test] + public void Merge_EmitsSkiaCompilableCurrentPixelProgram() + { + ShaderDescription first = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + static bindings => bindings.Uniform("gain", 0.5f)); + ShaderDescription second = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.a - color.rgb, color.a); }"); + SkslMergedProgram program = SkslSnippetMerger.Merge([new(first), new(second)]); + + using SKRuntimeEffect? effect = SKRuntimeEffect.CreateShader(program.Source, out string? error); + + Assert.Multiple(() => + { + Assert.That(error, Is.Null); + Assert.That(effect, Is.Not.Null); + }); + } + + private static ShaderDescription ResourceShader(string name, RenderResource resource) + { + return ShaderDescription.CurrentPixel( + $"uniform shader {name}; half4 apply(half4 color) {{ return {name}.eval(color.rg); }}", + bindings => bindings.Resource( + name, + resource, + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White)))); + } + + private static ShaderDescription ResourceShader( + int resourceCount, + RenderRequestResourceRegistry registry) + { + string[] names = Enumerable.Range(0, resourceCount) + .Select(static index => $"lookup{index}") + .ToArray(); + RenderResource[] resources = names + .Select(_ => registry.RegisterBorrowed(new object())) + .ToArray(); + string declarations = string.Join(' ', names.Select(static name => $"uniform shader {name};")); + + return ShaderDescription.CurrentPixel( + $"{declarations} half4 apply(half4 color) {{ return color; }}", + bindings => + { + for (int index = 0; index < names.Length; index++) + { + bindings.Resource( + names[index], + resources[index], + ShaderResourceCoordinateSpace.Value, + static (writer, _, _) => writer.Set(SKShader.CreateColor(SKColors.White))); + } + }); + } + + private static SkslBackendBudget Budget( + int maxStages = int.MaxValue, + int maxUniformVectors = int.MaxValue, + int maxSamplers = int.MaxValue, + int maxChildren = int.MaxValue, + int maxSourceBytes = int.MaxValue, + int maxProgramTokens = int.MaxValue) + { + return new SkslBackendBudget( + "unit-test-backend", + maxStages, + maxUniformVectors, + maxSamplers, + maxChildren, + maxSourceBytes, + maxProgramTokens); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryClipRenderNodeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryClipRenderNodeTests.cs new file mode 100644 index 0000000000..54c397d68f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryClipRenderNodeTests.cs @@ -0,0 +1,85 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class GeometryClipRenderNodeTests +{ + [Test] + public void Update_ShouldNotMarkChanges_WhenAllPropertiesMatch() + { + Geometry.Resource clip = CreateClip(30, 40); + using var node = new GeometryClipRenderNode(clip, ClipOperation.Intersect); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(clip, ClipOperation.Intersect), Is.False); + Assert.That(node.HasChanges, Is.False); + }); + } + + [Test] + public void Update_ShouldMarkChanges_WhenPropertiesDoNotMatch() + { + Geometry.Resource clip = CreateClip(30, 40); + using var node = new GeometryClipRenderNode(clip, ClipOperation.Intersect); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(clip, ClipOperation.Difference), Is.True); + Assert.That(node.HasChanges, Is.True); + }); + } + + + private static Geometry.Resource CreateClip(float width, float height) + { + var geometry = new RectGeometry + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + }; + return geometry.ToResource(CompositionContext.Default); + } + + [Test] + public void Intersect_ClipsOutputBoundsAndHitTesting() + { + var geometry = new RectGeometry + { + Width = { CurrentValue = 30 }, + Height = { CurrentValue = 40 }, + }; + Geometry.Resource resource = geometry.ToResource(CompositionContext.Default); + using var node = new GeometryClipRenderNode(resource, ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 30, 40))); + Assert.That(measurement.QueryBounds, Is.EqualTo(new Rect(0, 0, 30, 40))); + Assert.That(renderer.HitTest(new Point(20, 20)), Is.True); + Assert.That(renderer.HitTest(new Point(50, 20)), Is.False); + }); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryRenderNodeTest.cs index 20ee4a9286..caf01b2957 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GeometryRenderNodeTest.cs @@ -47,7 +47,7 @@ public void Update_ShouldReturnTrue_WhenPropertiesDoNotMatch() } [Test] - public void Process_ShouldReturnCorrectRenderNodeOperation() + public void Measure_ShouldReportRecordedFragment() { var geometry = new EllipseGeometry { Width = { CurrentValue = 100 }, Height = { CurrentValue = 100 } }; Brush fill = new SolidColorBrush(Colors.Red); @@ -55,13 +55,105 @@ public void Process_ShouldReturnCorrectRenderNodeOperation() var geometryResource = geometry.ToResource(CompositionContext.Default); var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); + using var node = new GeometryRenderNode(geometryResource, fillResource, penResource); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + }); + } - var node = new GeometryRenderNode(geometryResource, fillResource, penResource); - var operations = node.Process(context); + [Test] + public void Measure_ShouldCoverTheFill_WhenANegativeOffsetErodesTheStroke() + { + var geometry = new EllipseGeometry { Width = { CurrentValue = 140 }, Height = { CurrentValue = 95 } }; + Brush fill = new SolidColorBrush(Colors.Gold); + Pen pen = new Pen + { + Brush = { CurrentValue = Brushes.Blue }, + Thickness = { CurrentValue = 5 }, + Offset = { CurrentValue = -12 }, + }; + var geometryResource = geometry.ToResource(CompositionContext.Default); + var fillResource = fill.ToResource(CompositionContext.Default); + var penResource = pen.ToResource(CompositionContext.Default); + Rect fillBounds = geometryResource.Bounds; + Rect strokeBounds = geometryResource.GetRenderBounds(penResource); + using var node = new GeometryRenderNode(geometryResource, fillResource, penResource); + using var renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(strokeBounds.Contains(fillBounds), Is.False, + "the negative offset must pull the stroke off the fill's extremes for this case to be meaningful"); + Assert.That(measurement.OutputBounds.Contains(fillBounds), Is.True, + "the declared output must cover the fill the draw callback paints, not just the eroded stroke"); + } + } - Assert.That(operations, Is.Not.Null); - Assert.That(operations.Length, Is.EqualTo(1)); + [Test] + public void Measure_ShouldCoverTheFill_WhenATrimmedPenStrokesOnlyAnArc() + { + var geometry = new EllipseGeometry { Width = { CurrentValue = 140 }, Height = { CurrentValue = 95 } }; + Brush fill = new SolidColorBrush(Colors.Gold); + Pen pen = new Pen + { + Brush = { CurrentValue = Brushes.Blue }, + Thickness = { CurrentValue = 5 }, + TrimStart = { CurrentValue = 40 }, + TrimEnd = { CurrentValue = 60 }, + }; + var geometryResource = geometry.ToResource(CompositionContext.Default); + var fillResource = fill.ToResource(CompositionContext.Default); + var penResource = pen.ToResource(CompositionContext.Default); + Rect fillBounds = geometryResource.Bounds; + Rect strokeBounds = geometryResource.GetRenderBounds(penResource); + using var node = new GeometryRenderNode(geometryResource, fillResource, penResource); + using var renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(strokeBounds.Contains(fillBounds), Is.False, + "the trim must pull the stroke off the fill's extremes for this case to be meaningful"); + Assert.That(measurement.OutputBounds.Contains(fillBounds), Is.True, + "the declared output must cover the fill the draw callback paints, not just the trimmed arc"); + } + } + + [Test] + public void Measure_ShouldStayStrokeOnly_WhenThereIsNoFillToCover() + { + var geometry = new EllipseGeometry { Width = { CurrentValue = 140 }, Height = { CurrentValue = 95 } }; + Pen pen = new Pen + { + Brush = { CurrentValue = Brushes.Blue }, + Thickness = { CurrentValue = 5 }, + Offset = { CurrentValue = -12 }, + }; + var geometryResource = geometry.ToResource(CompositionContext.Default); + var penResource = pen.ToResource(CompositionContext.Default); + Rect fillBounds = geometryResource.Bounds; + Rect strokeBounds = geometryResource.GetRenderBounds(penResource); + using var node = new GeometryRenderNode(geometryResource, null, penResource); + using var renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(measurement.OutputBounds, Is.EqualTo(strokeBounds), + "without a fill there is nothing outside the stroke to cover, so the bound must stay stroke-only"); + Assert.That(measurement.OutputBounds.Contains(fillBounds), Is.False, + "growing a fill-less stroke to the fill would waste the intermediate it allocates"); + } } [Test] @@ -73,13 +165,11 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideGeometry() var geometryResource = geometry.ToResource(CompositionContext.Default); var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new GeometryRenderNode(geometryResource, fillResource, penResource); - var operations = node.Process(context); + using var node = new GeometryRenderNode(geometryResource, fillResource, penResource); + using var renderer = CreateRenderer(node); var point = new Point(50, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } [Test] @@ -91,13 +181,11 @@ public void HitTest_ShouldReturnFalse_WhenPointIsOutsideGeometry() var geometryResource = geometry.ToResource(CompositionContext.Default); var fillResource = fill.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new GeometryRenderNode(geometryResource, fillResource, penResource); - var operations = node.Process(context); + using var node = new GeometryRenderNode(geometryResource, fillResource, penResource); + using var renderer = CreateRenderer(node); var point = new Point(150, 150); - Assert.That(operations[0].HitTest(point), Is.False); + Assert.That(renderer.HitTest(point), Is.False); } [Test] @@ -107,12 +195,19 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideGeometryStroke() Pen pen = new Pen { Brush = { CurrentValue = Brushes.Black }, Thickness = { CurrentValue = 50 } }; var geometryResource = geometry.ToResource(CompositionContext.Default); var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new GeometryRenderNode(geometryResource, null, penResource); - var operations = node.Process(context); + using var node = new GeometryRenderNode(geometryResource, null, penResource); + using var renderer = CreateRenderer(node); var point = new Point(0, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/AnisotropicHairlineCoverageTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/AnisotropicHairlineCoverageTests.cs new file mode 100644 index 0000000000..79aba16bec --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/AnisotropicHairlineCoverageTests.cs @@ -0,0 +1,220 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A drawable whose transform squeezes it below one device pixel keeps the same ink under a filter +/// effect as it does without one. +/// +/// +/// A built-in Skia filter over a vector drawable used to be materialized into a buffer rasterized in +/// the drawable's own space and then composited back through the drawable's minifying transform with +/// a two-tap sampler. At a 10:1 minification that single tap either lands on the hairline or misses +/// it, so the bar arrived at up to 1.5x or as little as 0.0005x of its ink depending only on sub-pixel +/// phase. The remaining loss came from the filter's save layer, whose bound hugged the content: the +/// Ganesh backend keeps only (1 + w) / 2 of a w-device-pixel-wide feature inside such a layer. +/// +/// The no-effect render is the reference rather than the analytic 0.6 x s_out, because antialiasing +/// phase alone moves a heavily sub-pixel feature by ~20% and only the effect's contribution is under +/// test here. +/// +[NonParallelizable] +[TestFixture] +public class AnisotropicHairlineCoverageTests +{ + /// Skia publishes coverage in 1/255 steps, which accumulates over a whole bar. + private const double InkTolerance = 0.06; + + [TestCase(0.25f)] + [TestCase(0.333f)] + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void ABlurredHairline_KeepsTheInkItHasWithoutTheBlur(float outputScale) + { + AssertEffectPreservesHairlineInk(Blur(), outputScale, "blur"); + } + + [TestCase(0.25f)] + [TestCase(0.333f)] + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void AShadowedHairline_KeepsTheInkItHasWithoutTheShadow(float outputScale) + { + AssertEffectPreservesHairlineInk(WhiteShadowOnly(), outputScale, "shadow-only"); + } + + /// + /// A morphology radius that is sub-pixel on the squeezed axis neither grows nor erases the bar: it + /// resolves against the destination's device grid, where Skia rounds it to no pixels at all. + /// + [TestCase(0.25f)] + [TestCase(0.333f)] + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void ADilatedHairline_KeepsTheInkItHasWithoutTheDilation(float outputScale) + { + AssertEffectPreservesHairlineInk(Dilate(), outputScale, "dilate"); + } + + /// + /// The default authoring shape: 's constructor installs a + /// , so two stacked effects arrive as two filter segments and the + /// outer one's input is the inner segment rather than the drawable. + /// + [TestCase(0.25f)] + [TestCase(0.333f)] + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void AHairlineUnderStackedEffects_KeepsTheInkItHasWithoutThem(float outputScale) + { + AssertEffectPreservesHairlineInk( + Group(Blur(), WhiteShadowOnly()), + outputScale, + "blur over shadow-only"); + } + + /// + /// The anisotropic rig the family was reported against: the same 10% squeeze in x with a 20x + /// stretch in y, so the working density the effect resolves cannot describe both axes at once. + /// + [TestCase(0.25f)] + [TestCase(0.5f)] + [TestCase(1f)] + [Category("GpuPassFusionGpu")] + public void AnAnisotropicallyScaledHairline_KeepsItsInkUnderABlur(float outputScale) + { + AssertEffectPreservesHairlineInk(Blur(), outputScale, "anisotropic blur", scaleY: 2000f); + } + + private static void AssertEffectPreservesHairlineInk( + FilterEffect effect, + float outputScale, + string scenario, + float scaleY = 100f) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + double unfiltered = MeasureInk(null, scaleY, outputScale); + double filtered = MeasureInk(effect, scaleY, outputScale); + + Assert.That( + unfiltered, + Is.GreaterThan(0), + "the unfiltered hairline has to render, or the comparison proves nothing."); + Assert.That( + filtered / unfiltered, + Is.EqualTo(1d).Within(InkTolerance), + $"{scenario} at output scale {outputScale} painted {filtered:F4} of ink where the same " + + $"content without the effect paints {unfiltered:F4}; an effect that conserves ink " + + "must not gain or lose it to the transform the drawable is composited through."); + }); + } + + private static FilterEffect Group(params FilterEffect[] children) + { + var group = new FilterEffectGroup(); + foreach (FilterEffect child in children) + group.Children.Add(child); + return group; + } + + private static FilterEffect Blur() + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(1, 1); + return blur; + } + + private static FilterEffect Dilate() + { + var dilate = new Dilate(); + dilate.RadiusX.CurrentValue = 1f; + dilate.RadiusY.CurrentValue = 1f; + return dilate; + } + + private static FilterEffect WhiteShadowOnly() + { + var shadow = new DropShadow(); + shadow.ShadowOnly.CurrentValue = true; + shadow.Sigma.CurrentValue = new Size(1, 1); + shadow.Color.CurrentValue = Colors.White; + return shadow; + } + + /// + /// Renders a 6 x 100 bar squeezed to 0.6 logical units wide — below one device pixel at every + /// output scale sampled here — and sums the alpha it leaves on the frame. + /// + private static double MeasureInk(FilterEffect? effect, float scaleY, float outputScale) + { + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = 6f; + // Keep the bar 100 logical units tall whatever the y stretch, so it never leaves the frame. + rectangle.Height.CurrentValue = 10000f / scaleY; + rectangle.Fill.CurrentValue = new SolidColorBrush(Colors.White); + rectangle.AlignmentX.CurrentValue = AlignmentX.Left; + rectangle.AlignmentY.CurrentValue = AlignmentY.Top; + rectangle.TransformOrigin.CurrentValue = RelativePoint.TopLeft; + if (effect is not null) + rectangle.FilterEffect.CurrentValue = effect; + + var group = new TransformGroup(); + var translate = new TranslateTransform(); + translate.X.CurrentValue = 128; + translate.Y.CurrentValue = 42; + // A TransformGroup applies its last child first, so the squeeze runs in the shape's own space. + group.Children.Add(translate); + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = 10f; + scale.ScaleY.CurrentValue = scaleY; + group.Children.Add(scale); + rectangle.Transform.CurrentValue = group; + + var scene = new Scene(640, 360, "hairline") { Uri = new Uri("file:///hairline/scene") }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(4), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///hairline/element"), + }; + element.AddObject(rectangle); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, outputScale, false, outputScale * 2f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); + using Bitmap bitmap = renderer.Snapshot(); + + double ink = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int x = 3; x < row.Length; x += 4) + ink += (float)BitConverter.UInt16BitsToHalf(row[x]); + } + + return ink; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BackdropDecoratorTransformTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BackdropDecoratorTransformTests.cs new file mode 100644 index 0000000000..f52df19e09 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BackdropDecoratorTransformTests.cs @@ -0,0 +1,72 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class BackdropDecoratorTransformTests +{ + private static readonly PixelSize s_frame = new(256, 144); + + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + public void DecoratorTransformAroundBackdrop_MatchesTransformOnBackdrop(float scale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource expectedResource = CreateScene(decorateBackdrop: false); + using Drawable.Resource actualResource = CreateScene(decorateBackdrop: true); + using Bitmap expected = GoldenImageHarness.RenderAtScale(expectedResource, s_frame, scale); + using Bitmap actual = GoldenImageHarness.RenderAtScale(actualResource, s_frame, scale); + + double ssim = ImageMetrics.Ssim(expected, actual); + double mae = ImageMetrics.MeanAbsoluteError(expected, actual); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + $"The transformed backdrop differs at scale {scale}: SSIM={ssim:F6}, MAE={mae:F6}."); + }); + } + + private static Drawable.Resource CreateScene(bool decorateBackdrop) + { + var scene = new DrawableGroup(); + scene.Children.Add(CreateRectangle(s_frame.Width, s_frame.Height, Colors.DimGray)); + scene.Children.Add(CreateRectangle(130, 95, Colors.Navy)); + + var backdrop = new SourceBackdrop(); + backdrop.FilterEffect.CurrentValue = new Invert(); + if (decorateBackdrop) + { + var decorator = new DrawableDecorator(); + decorator.Children.Add(backdrop); + decorator.Transform.CurrentValue = new RotationTransform(24); + scene.Children.Add(decorator); + } + else + { + backdrop.Transform.CurrentValue = new RotationTransform(24); + scene.Children.Add(backdrop); + } + + return scene.ToResource(CompositionContext.Default); + } + + private static RectShape CreateRectangle(float width, float height, Color color) + { + var shape = new RectShape(); + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.Fill.CurrentValue = new SolidColorBrush(color); + return shape; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BitmapSamplingQualityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BitmapSamplingQualityTests.cs new file mode 100644 index 0000000000..06d394f7f5 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BitmapSamplingQualityTests.cs @@ -0,0 +1,251 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.Pixel; +using Beutl.Media.Source; +using Beutl.Serialization; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class BitmapSamplingQualityTests +{ + [Test] + public void ExactTwoByTwoReduction_PreservesBlackAndWhiteBlocks() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource source = CreateSourceImage( + 64, + 64, + static (x, y) => (((x / 2) + (y / 2)) & 1) == 0 ? byte.MinValue : byte.MaxValue); + using Bitmap rendered = GoldenImageHarness.RenderAtScale(source, new PixelSize(64, 64), 0.5f); + + var observed = new HashSet(); + for (int y = 2; y < rendered.Height - 2; y++) + { + for (int x = 2; x < rendered.Width - 2; x++) + { + observed.Add(ReadRed(rendered, x, y)); + } + } + + TestContext.WriteLine($"Exact 2x2 reduction values: {string.Join(", ", observed.Order())}"); + Assert.That(observed, Is.EquivalentTo(new[] { 0f, 1f }), + "Each destination pixel covers one uniform source block and must retain its exact endpoint."); + }); + } + + [Test] + public void ExactTwoByTwoReduction_PreservesMidToneBlocksWithoutRinging() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource source = CreateSourceImage( + 64, + 64, + static (x, y) => (((x / 2) + (y / 2)) & 1) == 0 ? (byte)96 : (byte)176); + using Bitmap rendered = GoldenImageHarness.RenderAtScale(source, new PixelSize(64, 64), 0.5f); + + float[] observed = ReadInteriorRedValues(rendered); + float low = Color.FromRgb(96, 96, 96).ToLinear().X; + float high = Color.FromRgb(176, 176, 176).ToLinear().X; + TestContext.WriteLine($"Exact mid-tone reduction values: {string.Join(", ", observed)}"); + Assert.Multiple(() => + { + Assert.That(observed, Has.Length.EqualTo(2)); + AssertWithinStorageCodes(observed[0], low, "low"); + AssertWithinStorageCodes(observed[1], high, "high"); + }); + }); + } + + [Test] + public void MildCheckerboardMinification_RetainsBranchAntialiasing() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource source = CreateSourceImage( + 64, + 64, + static (x, y) => ((x + y) & 1) == 0 ? byte.MinValue : byte.MaxValue); + using Bitmap rendered = GoldenImageHarness.RenderAtScale(source, new PixelSize(64, 64), 0.75f); + + float[] observed = ReadInteriorRedValues(rendered); + float spread = observed[^1] - observed[0]; + TestContext.WriteLine($"0.75x checker values: {string.Join(", ", observed)}; spread={spread:R}"); + Assert.That(spread, Is.LessThanOrEqualTo(0.26f), + "Non-integer minification must retain the branch's lower-aliasing two-stage path."); + }); + } + + [Test] + public void FusedCurrentPixelMagnification_InterpolatesBetweenSourcePixels() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource source = CreateSourceImage( + 2, + 1, + static (x, _) => x == 0 ? byte.MinValue : byte.MaxValue, + new FilterEffectGroup + { + Children = + { + // Non-identity: an identity colour matrix records no stage, so the fixture + // would stop exercising the fused colour-stage path it asserts on. + CreateBrightness(75f), + CreateBrightness(80f), + }, + }); + using RenderResult result = Render(source, new PixelSize(2, 1), 4f); + + float[] values = Enumerable.Range(1, result.Bitmap.Width - 2) + .Select(x => ReadRed(result.Bitmap, x, result.Bitmap.Height / 2)) + .ToArray(); + int distinct = values.Distinct().Count(); + TestContext.WriteLine( + $"Fused 4x magnification values: {string.Join(", ", values)}; " + + $"runs={result.Statistics.ShaderRunExecutions}, " + + $"stages={result.Statistics.ShaderStageExecutions}, " + + $"fused={result.Statistics.FusedShaderRunExecutions}"); + Assert.Multiple(() => + { + Assert.That(result.Statistics.FusedShaderRunExecutions, Is.EqualTo(1), + "The fixture must exercise the fused colour-stage path."); + Assert.That(result.Statistics.ShaderStageExecutions, Is.GreaterThanOrEqualTo(2)); + Assert.That(distinct, Is.GreaterThan(2), + "Magnification must interpolate instead of repeating two nearest-neighbour plateaus."); + Assert.That(values, Has.Some.GreaterThan(0f).And.LessThan(1f)); + }); + }); + } + + private static Brightness CreateBrightness(float amount) + { + var brightness = new Brightness(); + brightness.Amount.CurrentValue = amount; + return brightness; + } + + private static Drawable.Resource CreateSourceImage( + int width, + int height, + Func red, + FilterEffect? effect = null) + { + using var bitmap = new Bitmap( + width, + height, + BitmapColorType.Bgra8888, + BitmapAlphaType.Opaque, + BitmapColorSpace.Srgb); + for (int y = 0; y < height; y++) + { + Span row = bitmap.GetRow(y); + for (int x = 0; x < width; x++) + { + byte value = red(x, y); + row[x] = new Bgra8888(value, value, value, byte.MaxValue); + } + } + + using var stream = new MemoryStream(); + Assert.That(bitmap.Save(stream, EncodedImageFormat.Png), Is.True); + var imageSource = new ImageSource(); + imageSource.ReadFrom(UriHelper.CreateBase64DataUri("image/png", stream.ToArray())); + var image = new SourceImage + { + Source = { CurrentValue = imageSource }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + FilterEffect = { CurrentValue = effect }, + }; + return image.ToResource(CompositionContext.Default); + } + + private static RenderResult Render(Drawable.Resource source, PixelSize frame, float scale) + { + using var node = new DrawableRenderNode(source); + using (var context = new GraphicsContext2D(node, frame.ToSize(1), scale)) + { + source.GetOriginal()!.Render(context, source); + } + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, frame.ToSize(1)), + OutputScale = scale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Enabled, + }, + }); + using RenderTarget target = RenderTarget.Create( + (int)MathF.Ceiling(frame.Width * scale), + (int)MathF.Ceiling(frame.Height * scale)) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, scale, logicalSize: frame.ToSize(1)); + canvas.Clear(Colors.Black); + renderer.Render(canvas); + return new RenderResult(target.Snapshot(), renderer.LastExecutionStatistics); + } + + private static float ReadRed(Bitmap bitmap, int x, int y) + { + ReadOnlySpan row = bitmap.GetRow(y); + return (float)BitConverter.UInt16BitsToHalf(row[x * 4]); + } + + /// + /// Bounds a reduced block against its source value in RgbaF16 storage codes. + /// + /// + /// One code is 2^-12 at these magnitudes, so an absolute bound tight enough to mean anything is + /// finer than the buffer can represent and no implementation can satisfy it. The block structure + /// - that exactly two values survive, with no ringing around them - is asserted separately, and + /// a kernel that rang would move a block by percent, not by a code or two. + /// + private static void AssertWithinStorageCodes(float actual, float expected, string label) + { + const int budget = 2; + int distance = Math.Abs( + BitConverter.HalfToInt16Bits((Half)actual) - BitConverter.HalfToInt16Bits((Half)expected)); + Assert.That( + distance, + Is.LessThanOrEqualTo(budget), + $"The {label} block must land within {budget} RgbaF16 codes of its source value; " + + $"measured {actual:R} against {expected:R}."); + } + + private static float[] ReadInteriorRedValues(Bitmap bitmap) + { + var observed = new HashSet(); + for (int y = 2; y < bitmap.Height - 2; y++) + { + for (int x = 2; x < bitmap.Width - 2; x++) + { + observed.Add(ReadRed(bitmap, x, y)); + } + } + + return observed.Order().ToArray(); + } + + private sealed record RenderResult(Bitmap Bitmap, RenderExecutionStatistics Statistics) : IDisposable + { + public void Dispose() => Bitmap.Dispose(); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BlurFilterLayerBoundsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BlurFilterLayerBoundsTests.cs new file mode 100644 index 0000000000..361894fdbc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/BlurFilterLayerBoundsTests.cs @@ -0,0 +1,192 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Media.Source; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A blur-backed filter must only sample the content it was given. +/// +/// +/// The filtered flush used to open its Skia layer without bounds, so Skia sized the layer from the +/// clip and the blur sampled past the drawn content into uninitialized device memory. On a real GPU +/// that memory is whatever the allocator last left there, so the undefined values reached the output +/// as NaN. Asserting on finiteness is the point: a NaN reads as "some value" through every ordinary +/// bitmap comparison, so an image-difference assertion alone does not catch it. +/// +/// These are guards, not a reproduction. The defect only becomes observable when the sampled memory +/// happens to hold non-zero data, which depends on the driver's allocator; on the SwiftShader host +/// used to find it, the reproduction needed a specific sequence of whole-scene renders that a unit +/// test cannot pin down. They were verified to pass both with and without the production fix, so +/// they document the contract and would catch a gross regression, but they are not a red-then-green +/// characterization test. Reproducing the original failure takes an out-of-process harness that +/// renders several whole scenes in sequence and then checks every channel for non-finite values. +/// +[NonParallelizable] +[TestFixture] +public sealed class BlurFilterLayerBoundsTests +{ + private static readonly PixelSize s_frame = new(400, 400); + + [Test] + public void DropShadow_DoesNotSampleOutsideItsInput() + { + AssertFiniteOutput(CreateShadow(), "drop shadow"); + } + + [Test] + public void Blur_DoesNotSampleOutsideItsInput() + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(18, 18); + AssertFiniteOutput(blur, "blur"); + } + + /// + /// The original reproduction. The undefined values only become observable once an earlier render + /// has left non-zero data in the memory the blur samples, and an image source is what makes the + /// engine take the filtered-flush path this guards. + /// + [Test] + public void ShadowedContentOverAnImage_StaysFiniteAfterAnEarlierRender() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + // Dirty the device memory the next render will draw into. + using (Drawable.Resource warmup = CreatePlate().ToResource(CompositionContext.Default)) + using (Bitmap _ = RenderScene(warmup)) + { + } + + RectShape shape = CreateRectangle(240, 120, Brushes.White); + shape.FilterEffect.CurrentValue = CreateShadow(); + using Drawable.Resource plate = CreatePlate().ToResource(CompositionContext.Default); + using Drawable.Resource shadowed = shape.ToResource(CompositionContext.Default); + using Bitmap actual = RenderScene(plate, shadowed); + + AssertAllChannelsFinite(actual, "shadowed content over an image after an earlier render"); + }); + } + + private static SourceImage CreatePlate() + { + Uri uri = TestMediaHelper.CreateTestImageUri(s_frame.Width, s_frame.Height, Colors.White); + var imageSource = new ImageSource(); + imageSource.ReadFrom(uri); + var image = new SourceImage(); + image.Source.CurrentValue = imageSource; + return image; + } + + private static void AssertFiniteOutput(FilterEffect effect, string scenario) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + RectShape shape = CreateRectangle(240, 120, Brushes.White); + shape.FilterEffect.CurrentValue = effect; + using Drawable.Resource resource = shape.ToResource(CompositionContext.Default); + using Bitmap actual = RenderScene(resource); + + AssertAllChannelsFinite(actual, scenario); + }); + } + + private static DropShadow CreateShadow() + { + var shadow = new DropShadow(); + shadow.Position.CurrentValue = new Point(0, 10); + shadow.Sigma.CurrentValue = new Size(18, 18); + shadow.Color.CurrentValue = Color.FromArgb(150, 0, 0, 0); + return shadow; + } + + private static void AssertAllChannelsFinite(Bitmap bitmap, string scenario) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int nonFinite = 0; + int firstIndex = -1; + for (int i = 0; i < pixels.Length; i++) + { + float value = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + if (float.IsFinite(value)) + continue; + + nonFinite++; + if (firstIndex < 0) + firstIndex = i; + } + + Assert.Multiple(() => + { + Assert.That( + nonFinite, + Is.Zero, + $"{scenario} produced {nonFinite} non-finite channel values " + + $"(first at pixel {(firstIndex < 0 ? -1 : firstIndex / 4)}); " + + "the filter sampled outside its input."); + Assert.That( + HasVisibleContent(bitmap), + Is.True, + $"{scenario} rendered nothing, so the finiteness check proves nothing."); + }); + } + + private static bool HasVisibleContent(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int i = 3; i < pixels.Length; i += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + if (float.IsFinite(alpha) && alpha > 0.01f) + return true; + } + + return false; + } + + private static RectShape CreateRectangle(float width, float height, Brush fill) + { + var shape = new RectShape(); + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.Fill.CurrentValue = fill; + return shape; + } + + private static Bitmap RenderScene(params Drawable.Resource[] resources) + { + using RenderTarget target = RenderTarget.Create(s_frame.Width, s_frame.Height) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, 1f, logicalSize: s_frame.ToSize(1)); + canvas.Clear(); + + using var root = new DrawableRenderNode(resources[0]); + using (var context = new GraphicsContext2D(root, s_frame.ToSize(1), 1f)) + { + foreach (Drawable.Resource resource in resources) + context.DrawDrawable(resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame.ToSize(1)), + OutputScale = 1f, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); + return target.Snapshot(); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ColorFilterShaderParityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ColorFilterShaderParityTests.cs new file mode 100644 index 0000000000..42885bce79 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ColorFilterShaderParityTests.cs @@ -0,0 +1,662 @@ +using System.Reactive; + +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// Pins the shared color-matrix CurrentPixel stage against the Skia color filter it replaces. +/// +/// +/// SKColorFilter.CreateColorMatrix unpremultiplies without clamping the straight components, multiplies by +/// the matrix, clamps the product to [0, 1], and re-premultiplies. A shader that skipped the unpremultiply or +/// output clamp, or that transposed the matrix wrongly would still look plausible on opaque mid-tone input, so +/// the sweep deliberately includes transparent, semi-transparent, near-zero, out-of-range, and saturating +/// samples. The near-zero alpha band and the non-zero alpha-offset matrix specifically pin the two cases a +/// transparency shortcut inside the stage would get wrong: an alpha offset can make a transparent pixel +/// visible, and a tiny alpha still unpremultiplies into a saturating value rather than into nothing. +/// +/// Parity is bounded, not bit-exact. Skia carries the matrix in half uniforms, so on a backend whose +/// half is real fp16 (Metal through MoltenVK) the reference itself works from coefficients quantized +/// to about 2^-11 relative, while this stage takes them at float precision; a backend that evaluates +/// half at float precision (SwiftShader) makes that quantization a no-op and the two agree bit for +/// bit. Feeding both paths fp16-rounded coefficients collapses the divergence, which is what identifies it. +/// +/// +/// Two bounds hold together because neither one alone covers the sweep. The absolute bound expresses the +/// coefficient quantization, but says nothing about the near-zero alpha band, whose outputs are subnormal +/// and orders of magnitude under it - a stage that blanked that band would pass it. The code-distance bound +/// covers the band, where quantizing a coefficient cannot move a result more than a code or so, and is left +/// off the normal range, where near-cancellation legitimately amplifies the same quantization into tens of +/// codes. +/// +/// +[NonParallelizable] +[TestFixture] +public sealed class ColorFilterShaderParityTests +{ + /// Two fp16 steps at 1.0: the reference's own coefficient precision. + private const double MaximumAbsoluteError = 1.0 / 1024; + + /// The largest half value below the smallest normal, so only subnormal outputs are compared. + private const float SubnormalMagnitudeCeiling = 6.103515625e-5f; + + private const int MaximumSubnormalStorageCodeDistance = 2; + + private static readonly Rect s_bounds = new(0, 0, Sweep.Width, Sweep.Height); + + [TestCaseSource(nameof(Amounts))] + [Category("GpuPassFusionGpu")] + public void ShaderColorMatrix_MatchesTheSkiaColorFilter(float amount) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + float[] matrix = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateBrightness(amount, matrix); + + AssertStorageCodeParity( + $"ColorMatrix amount={amount:R}", + context => AppendSkiaColorMatrix(context, matrix), + context => context.Shader(ColorMatrixShader.CurrentPixel(matrix))); + }); + } + + /// + /// A brightness matrix is diagonal with a zero translation column, so it cannot detect a wrongly transposed + /// uniform or a dropped offset. These matrices are asymmetric and carry offsets, so both would show up. + /// + [TestCaseSource(nameof(StructuredMatrices))] + [Category("GpuPassFusionGpu")] + public void ShaderColorMatrix_MatchesTheSkiaColorFilterForAsymmetricAndOffsetMatrices( + string name, + float[] matrix) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + AssertStorageCodeParity( + name, + context => AppendSkiaColorMatrix(context, matrix), + context => context.Shader(ColorMatrixShader.CurrentPixel(matrix))); + }); + } + + /// + /// The Brightness effect must record one fusable shader stage and no legacy Skia segment. + /// + [Test] + public void Brightness_RecordsOneCurrentPixelStageWithoutALegacyBoundary() + { + using var context = new FilterEffectContext(s_bounds); + + context.Brightness(0.75f); + + IReadOnlyList items = context.GetOrderedItems(); + using (Assert.EnterMultipleScope()) + { + Assert.That(items, Has.Count.EqualTo(1)); + Assert.That(items.OfType(), Is.Empty); + Assert.That(items.OfType(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(s_bounds)); + } + + var item = (FEItem_Shader)items.Single(); + Assert.That(item.Description.Kind, Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + } + + [Test] + public void IdentityColorMatrices_RecordNothing() + { + using var brightnessContext = new FilterEffectContext(s_bounds); + using var colorMatrixContext = new FilterEffectContext(s_bounds); + using var genericColorMatrixContext = new FilterEffectContext(s_bounds); + + brightnessContext.Brightness(1f); + colorMatrixContext.ColorMatrix(ColorMatrix.Identity); + genericColorMatrixContext.ColorMatrix(Unit.Default, static _ => ColorMatrix.Identity); + + Assert.Multiple(() => + { + Assert.That(brightnessContext.GetOrderedItems(), Is.Empty); + Assert.That(colorMatrixContext.GetOrderedItems(), Is.Empty); + Assert.That(genericColorMatrixContext.GetOrderedItems(), Is.Empty); + Assert.That(brightnessContext.Bounds, Is.EqualTo(s_bounds)); + Assert.That(colorMatrixContext.Bounds, Is.EqualTo(s_bounds)); + Assert.That(genericColorMatrixContext.Bounds, Is.EqualTo(s_bounds)); + }); + } + + [TestCase(0f)] + [TestCase(0.35f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void Saturate_MatchesTheSkiaColorFilterWithinOneStorageCode(float amount) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + float[] matrix = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateSaturateMatrix(amount, matrix); + + AssertStorageCodeParity( + $"Saturate amount={amount:R}", + context => AppendSkiaColorMatrix(context, matrix), + context => context.Saturate(amount)); + }); + } + + [TestCase(90f)] + [TestCase(180f)] + [TestCase(-45f)] + [Category("GpuPassFusionGpu")] + public void HueRotate_MatchesTheSkiaColorFilterWithinOneStorageCode(float degrees) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + float[] matrix = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateHueRotateMatrix(degrees, matrix); + + AssertStorageCodeParity( + $"HueRotate degrees={degrees:R}", + context => AppendSkiaColorMatrix(context, matrix), + context => context.HueRotate(degrees)); + }); + } + + [TestCaseSource(nameof(LightingCases))] + [Category("GpuPassFusionGpu")] + public void Lighting_WithNonZeroOffsetsMatchesTheSkiaColorFilterWithinOneStorageCode( + string name, + Color multiply, + Color add) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + float[] matrix = CreateLightingMatrix(multiply, add); + + AssertStorageCodeParity( + $"Lighting {name} multiply={multiply} add={add}", + context => AppendSkiaColorMatrix(context, matrix), + context => context.Lighting(multiply, add)); + }); + } + + [Test] + public void MigratedColorEffects_RecordOneCurrentPixelStageWithoutALegacyBoundary() + { + AssertRecordsOneCurrentPixelStage(context => context.Saturate(2f)); + AssertRecordsOneCurrentPixelStage(context => context.HueRotate(90f)); + AssertRecordsOneCurrentPixelStage(context => context.Lighting( + Color.FromRgb(128, 200, 64), + Color.FromRgb(32, 64, 96))); + AssertRecordsOneCurrentPixelStage(context => context.LumaColor()); + AssertRecordsOneCurrentPixelStage(context => context.HighContrast( + grayscale: true, + HighContrastInvertStyle.InvertLightness, + contrast: 0.6f)); + } + + [TestCase(float.NaN)] + [TestCase(float.NegativeInfinity)] + [TestCase(float.PositiveInfinity)] + [TestCase(-1.01f)] + [TestCase(1.01f)] + public void HighContrast_InvalidConfigurationRemainsANoOp(float contrast) + { + using var context = new FilterEffectContext(s_bounds); + + context.HighContrast(false, HighContrastInvertStyle.NoInvert, contrast); + + Assert.That(context.GetOrderedItems(), Is.Empty); + } + + [Test] + public void HighContrast_InvalidInvertStyleRemainsANoOp() + { + using var context = new FilterEffectContext(s_bounds); + + context.HighContrast(false, (HighContrastInvertStyle)int.MaxValue, 0.25f); + + Assert.That(context.GetOrderedItems(), Is.Empty); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void LumaColor_MatchesTheSkiaColorFilterWithinOneStorageCode() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => AssertStorageCodeParity( + "LumaColor", + static context => context.AppendSKColorFilter( + Unit.Default, + static (_, _) => SKColorFilter.CreateLumaColor()), + static context => context.LumaColor())); + } + + [TestCaseSource(nameof(HighContrastCases))] + [Category("GpuPassFusionGpu")] + public void HighContrast_MatchesTheSkiaColorFilterWithinOneStorageCode( + bool grayscale, + HighContrastInvertStyle invertStyle, + float contrast) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => AssertStorageCodeParity( + $"HighContrast grayscale={grayscale} invert={invertStyle} contrast={contrast:R}", + context => context.AppendSKColorFilter( + Unit.Default, + (_, _) => SKColorFilter.CreateHighContrast( + grayscale, + (SKHighContrastConfigInvertStyle)invertStyle, + contrast)), + context => context.HighContrast(grayscale, invertStyle, contrast))); + } + + /// + /// Non-vacuity: the sweep must actually exercise the clamps and the unpremultiply, otherwise a shader that + /// dropped one of those steps could pass the parity assertion. + /// + [Test] + public void Sweep_CoversTransparentSemiTransparentAndOutOfRangeSamples() + { + Rgba[] samples = Sweep.Samples(); + TestCaseData[] highContrastCases = HighContrastCases().ToArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(samples.Any(static sample => sample.A == 0f), "the sweep must contain alpha 0."); + Assert.That( + samples.Any(static sample => sample.A is > 0.4f and < 0.6f), + "the sweep must contain alpha 0.5."); + Assert.That(samples.Any(static sample => sample.A == 1f), "the sweep must contain alpha 1."); + Assert.That( + samples.Any(static sample => sample.R > 1f || sample.G > 1f || sample.B > 1f), + "the sweep must contain premultiplied components above 1."); + Assert.That( + samples.Any(static sample => sample.R < 0f || sample.G < 0f || sample.B < 0f), + "the sweep must contain components below 0."); + Assert.That( + samples.Any(static sample => sample.A > 0f && sample.R / sample.A > 1f), + "the sweep must contain a sample whose unpremultiplied value exceeds 1."); + + // A stage that blanked out every sample below a small alpha threshold would still pass every + // assertion above, so the sweep has to reach into that band explicitly. + Assert.That( + samples.Where(static sample => sample.A > 0f) + .Select(static sample => sample.A) + .Distinct() + .Count(static alpha => alpha <= 1e-4f), + Is.GreaterThanOrEqualTo(3), + "the sweep must contain at least three distinct alphas in the 0 < a <= 1e-4 band."); + Assert.That( + samples.Any(static sample => sample.A is > 0f and <= 1e-4f && sample.R / sample.A > 1f), + "the tiny-alpha band must contain a sample that saturates after the unpremultiply."); + + // Non-canonical premultiplied sample: only this one distinguishes Skia's unconditional + // divide-by-max(a, 1e-4) from a shader that branches on alpha and returns black. + Assert.That( + samples.Any(static sample => sample.A == 0f + && (sample.R != 0f || sample.G != 0f || sample.B != 0f)), + "the sweep must contain a sample with alpha 0 and non-zero premultiplied color."); + + // Likewise, the matrix set has to contain a non-zero alpha offset, otherwise the shortcut a + // transparent pixel would take could never be observed. + Assert.That( + StructuredMatrices().Any(static data => ((float[])data.Arguments[1]!)[19] != 0f), + "the structured matrix set must contain a matrix whose alpha offset is non-zero."); + + foreach (float endpoint in new[] { -1f, 0f, 1f }) + { + Assert.That( + highContrastCases.Select(static data => (float)data.Arguments[2]!), + Does.Contain(endpoint), + $"the HighContrast cases must contain the contrast endpoint {endpoint:R}."); + } + + Assert.That( + highContrastCases.Any(static data => !(bool)data.Arguments[0]! + && (HighContrastInvertStyle)data.Arguments[1]! + == HighContrastInvertStyle.InvertLightness), + "the HighContrast cases must exercise rgbToHsl without first converting to grayscale."); + Assert.That( + samples.Any(static sample => sample.A > 0f && sample.R == sample.G && sample.R > sample.B), + "the sweep must contain an R == G > B sample."); + Assert.That( + samples.Any(static sample => sample.A > 0f && sample.G == sample.B && sample.G > sample.R), + "the sweep must contain a G == B > R sample."); + Assert.That( + samples.Any(static sample => sample.A > 0f && sample.R == sample.B && sample.R > sample.G), + "the sweep must contain an R == B > G sample."); + Assert.That( + samples.Any(static sample => sample.A > 0f + && sample.R == sample.G && sample.G == sample.B), + "the sweep must contain an R == G == B sample."); + } + } + + private static IEnumerable Amounts() + { + // Identity, extinguishing, darkening, brightening, saturating, and a sign flip. The large and negative + // amounts push the product outside [0, 1] where the output clamp decides the result. + yield return 1f; + yield return 0f; + yield return 0.5f; + yield return 2f; + yield return 12.5f; + yield return 1e4f; + yield return -3f; + } + + private static IEnumerable StructuredMatrices() + { + // Asymmetric: every row mixes the channels differently, so a transposed uniform changes the result. + float[] hueRotate = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateHueRotateMatrix(50f, hueRotate); + yield return new TestCaseData("hueRotate50", hueRotate).SetName("Asymmetric_HueRotate"); + + float[] saturate = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateSaturateMatrix(0.35f, saturate); + yield return new TestCaseData("saturate0.35", saturate).SetName("Asymmetric_Saturate"); + + // Carries a non-zero translation column, so a dropped offset uniform changes the result. + float[] contrast = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateContrast(35f, contrast); + yield return new TestCaseData("contrast35", contrast).SetName("Offset_Contrast"); + + // Luminance-to-alpha writes only the alpha row, so a transpose would leak it into the color rows. + float[] luminance = new float[ColorMatrixShader.SkiaColorMatrixLength]; + ColorMatrix.CreateLuminanceToAlphaMatrix(luminance); + yield return new TestCaseData("luminanceToAlpha", luminance).SetName("Asymmetric_LuminanceToAlpha"); + + // None of the ColorMatrix factories writes the alpha offset (slot 19), so these are built directly. + // A non-zero alpha offset is the case a transparency shortcut inside the stage gets wrong: Skia + // produces a non-zero alpha from a fully transparent pixel, and the RGB offsets survive the + // re-premultiply. The shader must reproduce that instead of short-circuiting to zero. + yield return new TestCaseData( + "alphaOffsetOnly", + new float[] + { + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 1f, 0.5f, + }) + .SetName("Offset_AlphaOffsetOnly"); + + // Alpha offset plus RGB offsets and an asymmetric multiplier: a transparent pixel becomes a visibly + // colored one, so dropping either offset or transposing the multiplier all diverge here. + yield return new TestCaseData( + "alphaAndColorOffset", + new float[] + { + 0.6f, 0.2f, 0.1f, 0f, 0.25f, + 0.1f, 0.7f, 0.2f, 0f, 0.125f, + 0.3f, 0.1f, 0.5f, 0f, 0.0625f, + 0f, 0f, 0f, 0.5f, 0.375f, + }) + .SetName("Offset_AlphaAndColorOffset"); + + // A negative alpha offset drives the transformed alpha below zero for part of the sweep, where the + // output clamp - not the shortcut - has to decide the result. + yield return new TestCaseData( + "negativeAlphaOffset", + new float[] + { + 1f, 0f, 0f, 0f, 0.5f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 1f, -0.25f, + }) + .SetName("Offset_NegativeAlphaOffset"); + } + + private static IEnumerable LightingCases() + { + // Both the diagonal multiplier and the translation column are active. The two cases use different + // channels so an accidentally dropped or reordered offset cannot pass vacuously. + yield return new TestCaseData( + "mixedChannels", + Color.FromRgb(128, 200, 64), + Color.FromRgb(32, 64, 96)) + .SetName("Lighting_MixedMultipliersAndOffsets"); + yield return new TestCaseData( + "strongOffset", + Color.FromRgb(224, 96, 160), + Color.FromRgb(80, 16, 48)) + .SetName("Lighting_StrongNonZeroOffset"); + } + + private static IEnumerable HighContrastCases() + { + foreach (bool grayscale in new[] { false, true }) + { + yield return new TestCaseData(grayscale, HighContrastInvertStyle.NoInvert, 0.35f) + .SetName($"HighContrast_Grayscale{grayscale}_NoInvert"); + yield return new TestCaseData(grayscale, HighContrastInvertStyle.InvertBrightness, -0.4f) + .SetName($"HighContrast_Grayscale{grayscale}_InvertBrightness"); + yield return new TestCaseData(grayscale, HighContrastInvertStyle.InvertLightness, 0.6f) + .SetName($"HighContrast_Grayscale{grayscale}_InvertLightness"); + yield return new TestCaseData(grayscale, HighContrastInvertStyle.NoInvert, -1f) + .SetName($"HighContrast_Grayscale{grayscale}_ContrastMinimum"); + yield return new TestCaseData(grayscale, HighContrastInvertStyle.NoInvert, 0f) + .SetName($"HighContrast_Grayscale{grayscale}_ContrastNeutral"); + yield return new TestCaseData(grayscale, HighContrastInvertStyle.NoInvert, 1f) + .SetName($"HighContrast_Grayscale{grayscale}_ContrastMaximum"); + } + } + + private static float[] CreateLightingMatrix(Color multiply, Color add) + { + var mulLinear = multiply.ToLinear(); + var addLinear = add.ToLinear(); + var matrix = new float[ColorMatrixShader.SkiaColorMatrixLength]; + matrix[0] = mulLinear.X; + matrix[6] = mulLinear.Y; + matrix[12] = mulLinear.Z; + matrix[18] = 1; + matrix[4] = addLinear.X; + matrix[9] = addLinear.Y; + matrix[14] = addLinear.Z; + return matrix; + } + + private static void AssertStorageCodeParity( + string label, + Action appendSkia, + Action appendShader) + { + using Bitmap skia = Execute(appendSkia); + using Bitmap shader = Execute(appendShader); + + RgbaMaximumError error = ImageMetrics.MaximumAbsoluteErrorPerChannel(skia, shader); + RgbaMaximumError codes = ImageMetrics.MaximumStorageCodeDistancePerChannel( + skia, + shader, + SubnormalMagnitudeCeiling); + TestContext.WriteLine( + $"{label} max per-channel error r={error.Red:R} g={error.Green:R} " + + $"b={error.Blue:R} a={error.Alpha:R}; subnormal codes r={codes.Red} g={codes.Green} " + + $"b={codes.Blue} a={codes.Alpha}"); + + Assert.That( + ImageMetrics.FirstNonFinite(("skia", skia), ("shader", shader)), + Is.Null, + $"Both {label} paths must produce finite RGBA16F values."); + Assert.That( + error.Maximum, + Is.LessThanOrEqualTo(MaximumAbsoluteError), + $"The CurrentPixel {label} path must reproduce the Skia color filter to within the precision " + + $"Skia itself carries; measured max per-channel error r={error.Red:R} g={error.Green:R} " + + $"b={error.Blue:R} a={error.Alpha:R}."); + Assert.That( + codes.Maximum, + Is.LessThanOrEqualTo(MaximumSubnormalStorageCodeDistance), + $"The CurrentPixel {label} path must stay within {MaximumSubnormalStorageCodeDistance} RgbaF16 " + + "codes of the Skia color filter across the subnormal band, where the absolute bound above is " + + $"too coarse to see anything; measured max per-channel distance r={codes.Red} g={codes.Green} " + + $"b={codes.Blue} a={codes.Alpha}."); + } + + private static void AssertRecordsOneCurrentPixelStage(Action record) + { + using var context = new FilterEffectContext(s_bounds); + record(context); + + IReadOnlyList items = context.GetOrderedItems(); + Assert.Multiple(() => + { + Assert.That(items, Has.Count.EqualTo(1)); + Assert.That(items.OfType(), Is.Empty); + Assert.That(items.OfType(), Is.Empty); + Assert.That(items.Single(), Is.TypeOf()); + Assert.That(((FEItem_Shader)items.Single()).Description.Kind, + Is.EqualTo(ShaderDescriptionKind.CurrentPixel)); + }); + } + + private static void AppendSkiaColorMatrix(FilterEffectContext context, float[] matrix) + { + float[] copy = matrix.ToArray(); + context.AppendSKColorFilter(Unit.Default, (_, _) => SKColorFilter.CreateColorMatrix(copy)); + } + + private static Bitmap Execute(Action record) + { + using RenderTarget backing = RenderTarget.Create(Sweep.Width, Sweep.Height) + ?? throw new InvalidOperationException("The color-matrix parity target could not be allocated."); + Sweep.Fill(backing); + + using var targets = new EffectTargets + { + new EffectTarget(backing, s_bounds, EffectiveScale.At(1)), + }; + using var context = new FilterEffectContext(s_bounds); + record(context); + + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + activator.Apply(context); + activator.Flush(false); + + RenderTarget result = targets.Single().RenderTarget + ?? throw new InvalidOperationException("The color-matrix stage produced no render target."); + return result.Snapshot(); + } + + /// The shared premultiplied linear RGBA16F input sweep. + private static class Sweep + { + // The last three rows are the near-zero band: Skia still unpremultiplies there, so the divide + // produces a huge value that the output clamp pulls back to 1 before the re-premultiply. A stage + // that treated the band as transparent would return zero instead. + private static readonly float[] s_alphas = [0f, 0.5f, 1f, 0.25f, 1e-5f, 5e-5f, 1e-4f]; + + private static readonly float[] s_straightComponents = + [0f, 0.25f, 0.5f, 0.75f, 1f, 1.5f, 4f, -0.5f]; + + private static readonly (float R, float G, float B)[] s_tieStraightColors = + [ + (0.75f, 0.75f, 0.25f), + (0.25f, 0.75f, 0.75f), + (0.75f, 0.25f, 0.75f), + (0.5f, 0.5f, 0.5f), + ]; + + public static int Width => s_straightComponents.Length + s_tieStraightColors.Length; + + public static int Height => s_alphas.Length; + + /// Returns the premultiplied samples written by , in row-major order. + public static Rgba[] Samples() + { + var result = new Rgba[Width * Height]; + for (int y = 0; y < Height; y++) + { + for (int x = 0; x < Width; x++) + result[(y * Width) + x] = Sample(x, y); + } + + return result; + } + + public static void Fill(RenderTarget target) + { + var info = new SKImageInfo( + Width, + Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear()); + var pixels = new ushort[Width * Height * 4]; + for (int y = 0; y < Height; y++) + { + for (int x = 0; x < Width; x++) + { + Rgba sample = Sample(x, y); + int offset = ((y * Width) + x) * 4; + pixels[offset] = BitConverter.HalfToUInt16Bits((Half)sample.R); + pixels[offset + 1] = BitConverter.HalfToUInt16Bits((Half)sample.G); + pixels[offset + 2] = BitConverter.HalfToUInt16Bits((Half)sample.B); + pixels[offset + 3] = BitConverter.HalfToUInt16Bits((Half)sample.A); + } + } + + unsafe + { + fixed (ushort* buffer = pixels) + { + using SKImage image = SKImage.FromPixelCopy(info, (IntPtr)buffer, info.RowBytes); + target.Value.Canvas.Clear(); + target.Value.Canvas.DrawImage(image, 0, 0); + target.Value.Canvas.Flush(); + } + } + } + + // The original columns use distinct per-channel straight values so a wrongly transposed matrix cannot + // cancel out. The added columns pin every maximum-value tie branch in rgbToHsl. + private static Rgba Sample(int x, int y) + { + float alpha = s_alphas[y]; + float red; + float green; + float blue; + if (x < s_straightComponents.Length) + { + red = s_straightComponents[x]; + green = s_straightComponents[(x + 3) % s_straightComponents.Length]; + blue = s_straightComponents[(x + 5) % s_straightComponents.Length]; + } + else + { + (red, green, blue) = s_tieStraightColors[x - s_straightComponents.Length]; + } + + // Non-canonical premultiplied input: alpha 0 with non-zero RGB. A shader that special-cases + // alpha == 0 to black diverges from Skia here, because Skia's unpremultiply divides by + // max(a, 1e-4) unconditionally rather than branching on alpha. + if (alpha == 0f && x == 0) + return new Rgba(1e-3f, 5e-4f, 2.5e-4f, 0f); + + return new Rgba(red * alpha, green * alpha, blue * alpha, alpha); + } + } + + private readonly record struct Rgba(float R, float G, float B, float A); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurrentPixelQuantizationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurrentPixelQuantizationTests.cs new file mode 100644 index 0000000000..8ff32d52e9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurrentPixelQuantizationTests.cs @@ -0,0 +1,138 @@ +using System.Numerics; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class CurrentPixelQuantizationTests +{ + private static readonly PixelSize s_frame = new(32, 32); + + [TestCase(false)] + [TestCase(true)] + public void DoubleInvert_MatchesIdentityMaterialization(bool fusionEnabled) + { + FusionMode fusionMode = fusionEnabled ? FusionMode.Enabled : FusionMode.Disabled; + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap control = Render(CreateDrawable(invertCount: 0), fusionMode); + using Bitmap identity = Render(CreateDrawable(invertCount: -1), fusionMode); + using Bitmap inverted = Render(CreateDrawable(invertCount: 2), fusionMode); + + byte controlRed = ReadRed(control); + byte identityRed = ReadRed(identity); + byte invertedRed = ReadRed(inverted); + (float controlLinear, float controlAlpha) = ReadLinear(control); + (float identityLinear, float identityAlpha) = ReadLinear(identity); + (float invertedLinear, float invertedAlpha) = ReadLinear(inverted); + TestContext.WriteLine( + $"fusion={fusionEnabled}, control={controlRed} ({controlLinear:R}, a={controlAlpha:R}), " + + $"identity-stage={identityRed} ({identityLinear:R}, a={identityAlpha:R}), " + + $"double-invert={invertedRed} ({invertedLinear:R}, a={invertedAlpha:R})"); + Assert.Multiple(() => + { + Assert.That(controlLinear, Is.GreaterThan(0), "The unfiltered control must contain visible color."); + Assert.That(controlAlpha, Is.GreaterThan(0), "The unfiltered control must contain visible alpha."); + Assert.That(identityRed, Is.EqualTo(controlRed).Within(2), + "Identity materialization must preserve the rendered control color " + + "within the RGBA16F round-trip quantization this fixture characterizes."); + Assert.That(identityLinear, Is.EqualTo(controlLinear).Within(0.003f), + "Identity materialization must preserve the rendered control in linear space."); + Assert.That(identityAlpha, Is.EqualTo(controlAlpha).Within(0.001f), + "Identity materialization must preserve the rendered control alpha."); + Assert.That(invertedRed, Is.EqualTo(identityRed), + $"Two full Invert stages must not add quantization beyond the materialization boundary; identity={identityRed}, inverted={invertedRed}."); + Assert.That(invertedLinear, Is.EqualTo(identityLinear).Within(0.001f), + "Two full Invert stages must preserve the identity materialization in linear space."); + Assert.That(invertedAlpha, Is.EqualTo(identityAlpha).Within(0.001f), + "Two full Invert stages must preserve the identity materialization alpha."); + }); + }); + } + + private static Drawable.Resource CreateDrawable(int invertCount) + { + var shape = new RectShape + { + Width = { CurrentValue = s_frame.Width }, + Height = { CurrentValue = s_frame.Height }, + Fill = { CurrentValue = new SolidColorBrush(new Color(255, 51, 51, 51)) }, + AlignmentX = { CurrentValue = AlignmentX.Center }, + AlignmentY = { CurrentValue = AlignmentY.Center }, + }; + if (invertCount < 0) + { + shape.FilterEffect.CurrentValue = new IdentityTypedShaderEffect(); + } + else if (invertCount > 0) + { + var group = new FilterEffectGroup(); + for (int index = 0; index < invertCount; index++) + { + group.Children.Add(new Invert + { + Amount = { CurrentValue = 100 }, + }); + } + + shape.FilterEffect.CurrentValue = group; + } + + return shape.ToResource(CompositionContext.Default); + } + + private static Bitmap Render(Drawable.Resource resource, FusionMode fusionMode) + { + using (resource) + using (var node = new DrawableRenderNode(resource)) + { + using (var graphics = new GraphicsContext2D(node, s_frame.ToSize(1), 1)) + { + resource.GetOriginal()!.Render(graphics, resource); + } + + using RenderTarget target = RenderTarget.Create(s_frame.Width, s_frame.Height) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, 1, logicalSize: s_frame.ToSize(1)); + canvas.Clear(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame.ToSize(1)), + OutputScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + }); + renderer.Render(canvas); + return target.Snapshot(); + } + } + + private static byte ReadRed(Bitmap bitmap) + { + (float red, _) = ReadLinear(bitmap); + return Color.FromLinear(new Vector4(red, red, red, 1)).R; + } + + private static (float Red, float Alpha) ReadLinear(Bitmap bitmap) + { + int offset = ((bitmap.Height / 2 * bitmap.Width) + bitmap.Width / 2) * 4; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + return ( + (float)BitConverter.UInt16BitsToHalf(pixels[offset]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3])); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurvesFiniteOutputTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurvesFiniteOutputTests.cs new file mode 100644 index 0000000000..28a0bebc04 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CurvesFiniteOutputTests.cs @@ -0,0 +1,123 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class CurvesFiniteOutputTests +{ + private static readonly PixelSize s_frame = new(128, 96); + + [Test] + [Category("GpuPassFusionGpu")] + public void OutOfRangeMasterCurve_ProducesFiniteExtendedRangePixels() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var curves = new Curves(); + curves.MasterCurve.CurrentValue = new CurveMap( + [ + new CurveControlPoint(-0.5f, -0.5f), + new CurveControlPoint(1.5f, 1.5f), + ]); + + var shape = new RectShape(); + shape.Width.CurrentValue = s_frame.Width; + shape.Height.CurrentValue = s_frame.Height; + shape.Fill.CurrentValue = new SolidColorBrush(Colors.Black); + shape.FilterEffect.CurrentValue = curves; + + using Drawable.Resource resource = shape.ToResource(CompositionContext.Default); + using Bitmap actual = Render(resource, outputScale: 2f); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("out-of-range curves", actual)), + Is.Null, + "An extended-range curve must not turn a finite input into NaN or infinity."); + Assert.That( + HasVisibleCoverage(actual), + Is.True, + "The curve render must retain visible content so the finiteness assertion is meaningful."); + Assert.That( + HasExtendedRangeRgb(actual), + Is.True, + "The curve render must preserve finite RGB values outside [0, 1]."); + }); + }); + } + + private static Bitmap Render(Drawable.Resource resource, float outputScale) + { + PixelSize pixelSize = PixelSize.FromSize(s_frame.ToSize(1), outputScale); + using RenderTarget target = RenderTarget.Create(pixelSize.Width, pixelSize.Height) + ?? throw new InvalidOperationException("Could not allocate the curves render target."); + using var canvas = new ImmediateCanvas(target, outputScale, logicalSize: s_frame.ToSize(1)); + canvas.Clear(); + + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_frame.ToSize(1), outputScale)) + { + context.DrawDrawable(resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame.ToSize(1)), + OutputScale = outputScale, + MaxWorkingScale = outputScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); + return target.Snapshot(); + } + + private static bool HasVisibleCoverage(Bitmap bitmap) + { + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + if (float.IsFinite(alpha) && alpha > 0.01f) + return true; + } + } + + return false; + } + + private static bool HasExtendedRangeRgb(Bitmap bitmap) + { + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int i = 0; i < row.Length; i += 4) + { + for (int channel = 0; channel < 3; channel++) + { + float value = (float)BitConverter.UInt16BitsToHalf(row[i + channel]); + if (float.IsFinite(value) && (value < 0f || value > 1f)) + return true; + } + } + } + + return false; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectDeviceGridPhaseTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectDeviceGridPhaseTests.cs new file mode 100644 index 0000000000..fa69a60606 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectDeviceGridPhaseTests.cs @@ -0,0 +1,184 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Media.Pixel; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A custom (imperative) filter effect crops and re-lays-out its input in whole device pixels, so the +/// input has to be rasterized on a grid whose phase is zero. An effect that widens its layout box by an +/// odd half device pixel leaves the source off that grid; snapping it costs sub-pixel position, but +/// resampling it onto the grid spreads the outer edge over two pixels, and a DrawableBrush magnifying +/// the result turns that half pixel into a visibly soft fill edge. +/// +[NonParallelizable] +[TestFixture] +public class CustomEffectDeviceGridPhaseTests +{ + private static readonly PixelSize Frame = new(200, 200); + + private static SplitEffect Split(float spacing) + { + var split = new SplitEffect(); + split.HorizontalDivisions.CurrentValue = 2; + split.VerticalDivisions.CurrentValue = 2; + split.HorizontalSpacing.CurrentValue = spacing; + split.VerticalSpacing.CurrentValue = spacing; + return split; + } + + private static Drawable.Resource BrushHost(FilterEffect sourceEffect) + { + var source = new RectShape(); + source.AlignmentX.CurrentValue = AlignmentX.Center; + source.AlignmentY.CurrentValue = AlignmentY.Center; + source.Width.CurrentValue = 60; + source.Height.CurrentValue = 40; + source.Fill.CurrentValue = Brushes.White; + source.FilterEffect.CurrentValue = sourceEffect; + + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = source; + brush.Stretch.CurrentValue = Stretch.Uniform; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + + var host = new RectShape(); + host.AlignmentX.CurrentValue = AlignmentX.Center; + host.AlignmentY.CurrentValue = AlignmentY.Center; + host.Width.CurrentValue = 180; + host.Height.CurrentValue = 120; + host.Fill.CurrentValue = brush; + return host.ToResource(CompositionContext.Default); + } + + // 2x2 tiles with a 6px gap centre the layout box 3 logical pixels outside the 60x40 source, so at + // 0.5x the source sits half a device pixel off the box grid. + [Test] + [Category("GpuPassFusionGpu")] + public void DrawableBrushFill_SplitSourceOffTheBoxGrid_KeepsHardFillEdges() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + AssertMatchesAlignedControl(Split(6f), "offset split"); + }); + } + + // The control: an 8px gap moves the box a whole device pixel at 0.5x, so the source was already on + // the grid and the edge must stay hard whatever the phase handling does. + [Test] + [Category("GpuPassFusionGpu")] + public void DrawableBrushFill_SplitSourceOnTheBoxGrid_KeepsHardFillEdges() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => AssertHardFillEdges(Split(8f), "aligned split")); + } + + // The same off-grid split behind an ordinary colour stage. The colour stage is a separate render + // fragment, so the split's input is materialized one execution frame deeper; the grid the custom + // effect crops on has to reach that frame too. + [Test] + [Category("GpuPassFusionGpu")] + public void DrawableBrushFill_SplitSourceBehindAColourStage_KeepsHardFillEdges() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + AssertMatchesAlignedControl(ColourThen(Split(6f)), "colour-chained offset split"); + }); + } + + private static FilterEffectGroup ColourThen(FilterEffect tail) + { + var hueRotate = new HueRotate(); + hueRotate.Angle.CurrentValue = 120f; + var group = new FilterEffectGroup(); + group.Children.Add(hueRotate); + group.Children.Add(tail); + return group; + } + + /// + /// Measures an off-grid source against the aligned control rendered in the same run. The control + /// cannot lose the half pixel by construction, so it carries whatever ripple the backend's own + /// magnification kernel puts on a fully covered edge — which differs between rasterizers, where the + /// loss under test does not: it costs 23%, against a ripple of about 1.4%. + /// + private static void AssertMatchesAlignedControl(FilterEffect sourceEffect, string label) + { + (float alignedLeft, float alignedRight) = MeasureEdges(Split(8f), "aligned control"); + (float left, float right) = MeasureEdges(sourceEffect, label); + + const float Ripple = 0.05f; + Assert.Multiple(() => + { + Assert.That(left, Is.GreaterThan(alignedLeft - Ripple), + "the leading fill edge lost coverage: the effect's input was resampled onto the device " + + "grid instead of being rasterized on it, and the brush magnified the loss"); + Assert.That(right, Is.GreaterThan(alignedRight - Ripple), + "the trailing fill edge lost coverage: the effect's input was resampled onto the device " + + "grid instead of being rasterized on it, and the brush magnified the loss"); + }); + } + + private static void AssertHardFillEdges(FilterEffect sourceEffect, string label) + { + (float left, float right) = MeasureEdges(sourceEffect, label); + Assert.Multiple(() => + { + Assert.That(left, Is.GreaterThan(0.95f), "the leading fill edge was not hard to begin with"); + Assert.That(right, Is.GreaterThan(0.95f), "the trailing fill edge was not hard to begin with"); + }); + } + + private static (float Left, float Right) MeasureEdges(FilterEffect sourceEffect, string label) + { + using Bitmap rendered = GoldenImageHarness.RenderAtScale(BrushHost(sourceEffect), Frame, 0.5f); + (float left, float right) = EdgeCoverage(rendered); + TestContext.WriteLine($"[{label} brush fill] edge coverage left={left:F4} right={right:F4}"); + return (left, right); + } + + // The weakest leading and trailing edge pixel over the rows the fill covers completely. Rows the + // content only clips into are dimmed by their own vertical coverage and say nothing about the + // horizontal edge, so a row counts only once its interior reaches full coverage; among those, one + // soft row is a failure, which a maximum over all rows would hide. + private static (float Left, float Right) EdgeCoverage(Bitmap bitmap) + { + float leading = float.PositiveInfinity; + float trailing = float.PositiveInfinity; + int rows = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + int first = -1; + int last = -1; + float interior = 0f; + for (int x = 0; x < bitmap.Width; x++) + { + float coverage = Coverage(row, x); + if (coverage < 0.5f) continue; + if (first < 0) first = x; + last = x; + interior = Math.Max(interior, coverage); + } + + if (first < 0 || interior < 0.999f) continue; + rows++; + leading = Math.Min(leading, Coverage(row, first)); + trailing = Math.Min(trailing, Coverage(row, last)); + } + + Assert.That(rows, Is.GreaterThan(0), "no row was fully covered; the scene did not render"); + return (leading, trailing); + } + + // The fill is opaque white, so in premultiplied linear RGBA the red channel is the pixel's coverage. + private static float Coverage(ReadOnlySpan row, int x) + => (float)BitConverter.UInt16BitsToHalf(row[x * 4]); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectSupersampleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectSupersampleTests.cs index 225e7e347c..bf4ad19f2b 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectSupersampleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/CustomEffectSupersampleTests.cs @@ -8,7 +8,7 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; -// Custom effects allocate ceil(bounds * w) buffers and scale absolute-length params by w. +// Deferred whole-source shaders use canonical PixelRect.FromRect(bounds, w) buffers and scale absolute lengths by w. [NonParallelizable] [TestFixture] public class CustomEffectSupersampleTests @@ -33,6 +33,35 @@ private static Drawable.Resource MakeMosaicShape() return shape.ToResource(CompositionContext.Default); } + private static Drawable.Resource MakeMosaicRoiShape() + { + var gradient = new LinearGradientBrush + { + StartPoint = { CurrentValue = new RelativePoint(0, 0, RelativeUnit.Relative) }, + EndPoint = { CurrentValue = new RelativePoint(1, 1, RelativeUnit.Relative) }, + }; + for (int index = 0; index <= 10; index++) + { + gradient.GradientStops.Add(new GradientStop( + index % 2 == 0 ? Colors.Red : Colors.Blue, + index / 10f)); + } + + var shape = new RectShape + { + AlignmentX = { CurrentValue = AlignmentX.Center }, + AlignmentY = { CurrentValue = AlignmentY.Center }, + Width = { CurrentValue = 180 }, + Height = { CurrentValue = 160 }, + Fill = { CurrentValue = gradient }, + }; + shape.FilterEffect.CurrentValue = new MosaicEffect + { + TileSize = { CurrentValue = new Size(14, 14) }, + }; + return shape.ToResource(CompositionContext.Default); + } + [Test] public void Mosaic_Supersampled_KeepsLogicalTiles_AndGainsDensity() { @@ -65,6 +94,47 @@ public void Mosaic_Supersampled_KeepsLogicalTiles_AndGainsDensity() }); } + [Test] + public void Mosaic_CroppedExecution_MatchesFullRenderInsideRequestedRegion() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var requestedRegion = new Rect(30, 40, 60, 50); + PixelRect requestedPixels = PixelRect.FromRect(requestedRegion, 1); + using Bitmap full = GoldenImageHarness.RenderAtScale( + MakeMosaicRoiShape(), + Frame, + 1, + requestedRegion: null); + using Bitmap cropped = GoldenImageHarness.RenderAtScale( + MakeMosaicRoiShape(), + Frame, + 1, + requestedRegion); + using Bitmap expected = full.ExtractSubset(requestedPixels); + using Bitmap actual = cropped.ExtractSubset(requestedPixels); + + double ssim = ImageMetrics.Ssim(expected, actual); + double mae = ImageMetrics.MeanAbsoluteError(expected, actual); + Assert.Multiple(() => + { + Assert.That( + HasNonBlackRgb(expected), + Is.True, + "the requested-region fixture must contain visible gradient pixels"); + Assert.That( + ssim, + Is.GreaterThanOrEqualTo(GoldenThresholds.ExactSsimMin), + "ROI execution must preserve the complete-frame relative mosaic origin"); + Assert.That( + mae, + Is.LessThanOrEqualTo(GoldenThresholds.ExactMaeMax), + "ROI execution must keep the full-render tile phase"); + }); + }); + } + // A spatially-varying displacement map (default RadialGradientBrush) plus a non-zero translate — the // case a constant-map control cannot catch: the map is laid out in LOGICAL space but cross-sampled at // the base's device-px coord, so without the per-effect local-matrix x w the warp is misaligned/zoomed @@ -183,4 +253,72 @@ public void DisplacementMapRotation_Supersampled_KeepsLogicalWarp() "supersampled rotation-displacement warp diverged from 1:1 — the pivot is sampled in the wrong space at w != 1"); }); } + + [TestCaseSource(nameof(DisplacementTransforms))] + public void DisplacementMap_CroppedExecution_MatchesFullRenderInsideRequestedRegion( + Func transformFactory) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var requestedRegion = new Rect(80, 60, 40, 80); + PixelRect requestedPixels = PixelRect.FromRect(requestedRegion, 1); + using Bitmap full = GoldenImageHarness.RenderAtScale( + MakeDisplacedShape(transformFactory()), + Frame, + 1, + requestedRegion: null); + using Bitmap cropped = GoldenImageHarness.RenderAtScale( + MakeDisplacedShape(transformFactory()), + Frame, + 1, + requestedRegion); + using Bitmap expected = full.ExtractSubset(requestedPixels); + using Bitmap actual = cropped.ExtractSubset(requestedPixels); + + double ssim = ImageMetrics.Ssim(expected, actual); + double mae = ImageMetrics.MeanAbsoluteError(expected, actual); + Assert.Multiple(() => + { + Assert.That( + HasNonBlackRgb(expected), + Is.True, + "the requested-region fixture must contain visible displaced pixels"); + Assert.That( + ssim, + Is.GreaterThanOrEqualTo(GoldenThresholds.ExactSsimMin), + "ROI execution must preserve the complete displacement-map layout and pivot"); + Assert.That( + mae, + Is.LessThanOrEqualTo(GoldenThresholds.ExactMaeMax), + "ROI execution must match the full render inside the requested region"); + }); + }); + } + + private static IEnumerable DisplacementTransforms() + { + yield return new TestCaseData((Func)(() => + new DisplacementMapTranslateTransform + { + X = { CurrentValue = 40 }, + Y = { CurrentValue = 40 }, + })).SetName("DisplacementMapTranslate_CroppedExecution_MatchesFullRender"); + yield return new TestCaseData((Func)MakeScaleTransform) + .SetName("DisplacementMapScale_CroppedExecution_MatchesFullRender"); + yield return new TestCaseData((Func)MakeRotationTransform) + .SetName("DisplacementMapRotation_CroppedExecution_MatchesFullRender"); + } + + private static bool HasNonBlackRgb(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int index = 0; index < pixels.Length; index += 4) + { + if (pixels[index] != 0 || pixels[index + 1] != 0 || pixels[index + 2] != 0) + return true; + } + + return false; + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DelayAnimationNestedBrushTimeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DelayAnimationNestedBrushTimeTests.cs new file mode 100644 index 0000000000..6de4049913 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DelayAnimationNestedBrushTimeTests.cs @@ -0,0 +1,218 @@ +using Beutl.Animation; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// A DelayAnimationEffect re-applies its child once per target at that target's own delayed time. These +// cases pin that the re-application reaches the whole child: the animated content of a nested +// DrawableBrush as well as the child's own animated parameters, each applied exactly once. +[NonParallelizable] +[TestFixture] +public class DelayAnimationNestedBrushTimeTests +{ + private static readonly PixelSize Frame = new(200, 200); + + private static readonly TimeSpan CompositionTime = TimeSpan.FromSeconds(1); + + // The right tile is delayed by a full second against a brush whose colour animates red -> blue over + // that second, so it must paint the red the brush had then, not the parent's blue snapshot. + [Test] + public void SplitDelayedNestedBrush_FollowsThePerTileDelayedTime() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource resource = MakeSplitDelayedBrush(); + using Bitmap rendered = GoldenImageHarness.RenderAtScale( + resource, Frame, 1f, clearColor: Colors.Transparent); + + AssertIsCompositionTimeContent(MedianColour(rendered, 0), "the undelayed tile"); + AssertIsDelayedContent(MedianColour(rendered, 1), "the delayed tile"); + }); + } + + // Split(2, 2) under a 250ms-per-tile delay evaluates the child's animation four times. Each tile + // must carry its own delayed amount exactly once; applying it twice squares the factor. + [Test] + public void SplitDelayedBrightness_AppliesEachTilesDelayedAmountOnce() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource control = MakeSplitGrid(delayedBrightness: false); + using Bitmap withoutBrightness = GoldenImageHarness.RenderAtScale( + control, Frame, 1f, clearColor: Colors.Transparent); + using Drawable.Resource delayed = MakeSplitGrid(delayedBrightness: true); + using Bitmap withBrightness = GoldenImageHarness.RenderAtScale( + delayed, Frame, 1f, clearColor: Colors.Transparent); + + double[] factors = new double[4]; + for (int quadrant = 0; quadrant < 4; quadrant++) + { + factors[quadrant] = MedianColour(withBrightness, quadrant).R + / MedianColour(withoutBrightness, quadrant).R; + } + + Array.Sort(factors); + TestContext.WriteLine($"per-tile factors: {string.Join(", ", factors.Select(f => f.ToString("F4")))}"); + + // Amount animates 40 -> 220 over 2s; at 1s the four tiles evaluate it at 1.00, 0.75, 0.50 + // and 0.25s, giving 130, 107.5, 85 and 62.5 percent. + double[] expected = [0.625, 0.85, 1.075, 1.30]; + Assert.Multiple(() => + { + for (int i = 0; i < expected.Length; i++) + { + Assert.That(factors[i], Is.EqualTo(expected[i]).Within(0.02), + $"tile factor {i} must be the delayed amount applied once"); + } + }); + }); + } + + private static void AssertIsCompositionTimeContent((double R, double G, double B) colour, string label) + { + Assert.Multiple(() => + { + Assert.That(colour.B, Is.GreaterThan(0.8), + $"{label} did not paint the brush content at the parent's composition time"); + Assert.That(colour.R, Is.LessThan(0.05), + $"{label} painted delayed brush content where it has no delay"); + }); + } + + private static void AssertIsDelayedContent((double R, double G, double B) colour, string label) + { + Assert.Multiple(() => + { + Assert.That(colour.R, Is.GreaterThan(0.8), + $"{label} did not paint the brush content at its own delayed time"); + Assert.That(colour.B, Is.LessThan(0.05), + $"{label} painted the parent's composition-time snapshot instead of the delayed one"); + }); + } + + // Two tiles from one split, then a one-second-per-tile delay: the second tile re-applies the child at t=0. + private static Drawable.Resource MakeSplitDelayedBrush() + { + var split = new SplitEffect(); + split.HorizontalDivisions.CurrentValue = 2; + split.VerticalDivisions.CurrentValue = 1; + split.HorizontalSpacing.CurrentValue = 20; + + var blend = new BlendEffect(); + blend.Brush.CurrentValue = MakeTimeColouredDrawableBrush(); + blend.BlendMode.CurrentValue = BlendMode.SrcIn; + + var delay = new DelayAnimationEffect(); + delay.Delay.CurrentValue = 1000f; + delay.Effect.CurrentValue = blend; + + var group = new FilterEffectGroup(); + group.Children.Add(split); + group.Children.Add(delay); + + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = 160; + shape.Height.CurrentValue = 120; + shape.Fill.CurrentValue = Brushes.White; + shape.FilterEffect.CurrentValue = group; + return shape.ToResource(new CompositionContext(CompositionTime)); + } + + // Red at t=0, blue at the composition time: a delayed re-application would tint its tile red. + private static Brush MakeTimeColouredDrawableBrush() + { + var fill = new SolidColorBrush(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame { Value = Colors.Red, KeyTime = TimeSpan.Zero }); + animation.KeyFrames.Add(new KeyFrame { Value = Colors.Blue, KeyTime = CompositionTime }); + fill.Color.Animation = animation; + + var content = new RectShape(); + content.AlignmentX.CurrentValue = AlignmentX.Center; + content.AlignmentY.CurrentValue = AlignmentY.Center; + content.Width.CurrentValue = 200; + content.Height.CurrentValue = 200; + content.Fill.CurrentValue = fill; + + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = content; + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.Tile; + return brush; + } + + // A 2x2 split whose child brightness animates, so every tile evaluates it at its own delayed time. + private static Drawable.Resource MakeSplitGrid(bool delayedBrightness) + { + var split = new SplitEffect(); + split.HorizontalDivisions.CurrentValue = 2; + split.VerticalDivisions.CurrentValue = 2; + split.HorizontalSpacing.CurrentValue = 24; + split.VerticalSpacing.CurrentValue = 24; + + var group = new FilterEffectGroup(); + group.Children.Add(split); + if (delayedBrightness) + { + var brightness = new Brightness(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame { Value = 40f, KeyTime = TimeSpan.Zero }); + animation.KeyFrames.Add(new KeyFrame { Value = 220f, KeyTime = TimeSpan.FromSeconds(2) }); + brightness.Amount.Animation = animation; + + var delay = new DelayAnimationEffect(); + delay.Delay.CurrentValue = 250f; + delay.Effect.CurrentValue = brightness; + group.Children.Add(delay); + } + + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = 140; + shape.Height.CurrentValue = 140; + // Mid grey keeps the brightest tile inside the unit range, so no factor is clipped away. + shape.Fill.CurrentValue = new SolidColorBrush(Color.FromArgb(255, 96, 96, 96)); + shape.FilterEffect.CurrentValue = group; + return shape.ToResource(new CompositionContext(CompositionTime)); + } + + /// + /// The median opaque colour of one quadrant of the frame. The split's spacing keeps each tile + /// inside its own quadrant, and the transparent background keeps the median on tile pixels. + /// + private static (double R, double G, double B) MedianColour(Bitmap bitmap, int quadrant) + { + int x0 = (quadrant & 1) == 0 ? 0 : bitmap.Width / 2; + int y0 = quadrant < 2 ? 0 : bitmap.Height / 2; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + var samples = new List<(double R, double G, double B)>(); + for (int y = y0; y < y0 + (bitmap.Height / 2); y++) + { + for (int x = x0; x < x0 + (bitmap.Width / 2); x++) + { + int i = ((y * bitmap.Width) + x) * 4; + double alpha = (double)BitConverter.UInt16BitsToHalf(pixels[i + 3]); + if (alpha < 0.999) continue; + samples.Add(( + (double)BitConverter.UInt16BitsToHalf(pixels[i]) / alpha, + (double)BitConverter.UInt16BitsToHalf(pixels[i + 1]) / alpha, + (double)BitConverter.UInt16BitsToHalf(pixels[i + 2]) / alpha)); + } + } + + Assert.That(samples, Is.Not.Empty, $"quadrant {quadrant} carried no opaque tile pixels"); + samples.Sort((a, b) => a.R.CompareTo(b.R)); + return samples[samples.Count / 2]; + } + +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DirectBlurFiniteOutputTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DirectBlurFiniteOutputTests.cs new file mode 100644 index 0000000000..d1410b36fe --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DirectBlurFiniteOutputTests.cs @@ -0,0 +1,221 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class DirectBlurFiniteOutputTests +{ + private static readonly Rect s_frame = new(0, 0, 256, 144); + private static readonly Rect s_sourceBounds = new(190, 120, 120, 90); + + [Test] + [Category("GpuPassFusionGpu")] + public void DirectDestinationReplay_BlurSamplesOnlyTheDeclaredSourceBounds() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffectRenderNode node = CreateBlurNode(workingScale: null); + using RenderNodeRenderer renderer = CreateRenderer(node); + using RenderNodeRasterization result = renderer.Rasterize(); + + AssertFiniteVisibleResult(result, "direct destination replay"); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.Zero, + "The fixture must exercise Blur's direct destination replay path."); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void DirectMaterialization_BlurSamplesOnlyTheDeclaredSourceBounds() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffectRenderNode node = CreateBlurNode(workingScale: 2f); + using RenderNodeRenderer renderer = CreateRenderer(node); + using RenderNodeRasterization result = renderer.Rasterize(); + + AssertFiniteVisibleResult(result, "direct materialization"); + Assert.That( + renderer.LastExecutionStatistics.IntermediateTargetAcquisitions, + Is.GreaterThan(0), + "A working scale different from the destination must exercise Blur's materialization path."); + }); + } + + [Test] + public void NonMaterializedFilterLayer_UsesTheRegionReplayedForEachGroupChild() + { + Matrix deviceTransform = Matrix.CreateScale(2f, 2f); + Thickness apron = new(0.5f); + Rect hairlineBounds = new(20, 70, 216, 1); + Rect replayedHairlineBounds = new(96, 70, 48, 1); + Rect offFrameBounds = new(-120, 32, 180, 96); + Rect replayedOffFrameBounds = new(-18, 48, 78, 64); + + Assert.Multiple(() => + { + Assert.That( + OpenedLayerBounds(hairlineBounds, replayedHairlineBounds, deviceTransform), + Is.EqualTo(replayedHairlineBounds.Inflate(apron)), + "The hairline layer must be the replayed region plus the raster apron, never widened to " + + "the semantic area that replay did not write."); + Assert.That( + OpenedLayerBounds(offFrameBounds, replayedOffFrameBounds, deviceTransform), + Is.EqualTo(replayedOffFrameBounds.Inflate(apron)), + "The off-frame layer must be the replayed region plus the raster apron, never widened to " + + "the semantic area that replay did not write."); + }); + } + + /// Mirrors what ImmediateCanvas.PushFilterLayer opens for a replayed region. + private static Rect OpenedLayerBounds(Rect semanticBounds, Rect replayedBounds, Matrix deviceTransform) + => ImmediateCanvas.InflateByOneDevicePixel( + RenderRequestExecutor.GetDirectFilterLayerBounds(semanticBounds, replayedBounds), + deviceTransform); + + private static FilterEffectRenderNode CreateBlurNode(float? workingScale) + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(40, 40); + FilterEffect.Resource resource = blur.ToResource(CompositionContext.Default); + FilterEffectRenderNode node = workingScale is { } scale + ? new FixedWorkingScaleFilterRenderNode(resource, scale) + : new FilterEffectRenderNode(resource); + node.AddChild(new PoisonedOutsideBoundsSourceNode()); + return node; + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + Purpose = RenderRequestPurpose.Frame, + TargetDomain = s_frame, + RequestedRegion = s_frame, + OutputScale = 1f, + MaxWorkingScale = 2f, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + private static void AssertFiniteVisibleResult(RenderNodeRasterization result, string label) + { + Assert.That(result.Bitmap, Is.Not.Null); + Bitmap bitmap = result.Bitmap!; + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite((label, bitmap)), + Is.Null, + "Blur must not sample pixels outside the source's declared content bounds."); + float peakAlpha = PeakAlpha(bitmap); + Assert.That( + peakAlpha, + Is.GreaterThan(0.01f), + "The offscreen source must still blur into the frame."); + Assert.That( + peakAlpha, + Is.LessThanOrEqualTo(1.01f), + $"Alpha of {peakAlpha} is a garbage read, not coverage: the blur reached a sample " + + "nobody wrote."); + }); + } + + /// + /// The largest finite alpha in the bitmap. The peak is what separates coverage from a garbage read: + /// a poisoned sample also leaves plenty of individually plausible pixels behind, so a test that + /// accepts the first in-range pixel it finds passes on junk. Measured, a correct blur of this + /// fixture peaks at 0.553; the poisoned x86-64 result peaks at 0 and the arm64 one at 2288. + /// + private static float PeakAlpha(Bitmap bitmap) + { + float peak = 0f; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + if (float.IsFinite(alpha) && alpha > peak) + peak = alpha; + } + } + + return peak; + } + + private sealed class PoisonedOutsideBoundsSourceNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + context.Publish(context.PaintedSource( + s_sourceBounds, + static (canvas, _, _, bounds) => + { + using SKSLShader poison = SKSLShader.Create( + "uniform float zero; half4 main(float2 p) { float n = zero / zero; return half4(n); }"); + using SKSLShaderBuilder builder = poison.CreateBuilder(); + builder.Uniforms["zero"] = 0f; + using SKShader poisonShader = builder.Build(); + using var poisonPaint = new SKPaint + { + Shader = poisonShader, + BlendMode = SKBlendMode.Src, + }; + canvas.Canvas.DrawPaint(poisonPaint); + + // A source may leave the filter layer's one-device-pixel apron alone, and a + // well-behaved one does: it is where the rasterizer puts the antialiased spill of + // in-bounds content, so it holds transparent here. Poisoning it would only assert + // that the apron is unreachable, which is not what the layer promises. + using var apronPaint = new SKPaint + { + ColorF = new SKColorF(0f, 0f, 0f, 0f), + BlendMode = SKBlendMode.Src, + IsAntialias = false, + }; + canvas.Canvas.DrawRect( + ImmediateCanvas.InflateByOneDevicePixel(bounds, canvas.Transform).ToSKRect(), + apronPaint); + + using var paint = new SKPaint + { + ColorF = new SKColorF(1f, 0f, 0f, 1f), + BlendMode = SKBlendMode.Src, + IsAntialias = false, + }; + canvas.Canvas.DrawRect(bounds.ToSKRect(), paint); + }, + fill: null, + pen: null, + outputBounds: s_sourceBounds, + hitTest: RenderHitTestContract.OutputBounds, + scale: RenderScaleContract.Vector)); + } + } + + private sealed class FixedWorkingScaleFilterRenderNode( + FilterEffect.Resource effect, + float workingScale) : FilterEffectRenderNode(effect) + { + private readonly RenderScaleContract _scale = RenderScaleContract.Custom(_ => workingScale); + + protected override RenderScaleContract? GetWorkingScaleContract() => _scale; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableBrushFractionalContentExtentTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableBrushFractionalContentExtentTests.cs new file mode 100644 index 0000000000..55e89fdf50 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableBrushFractionalContentExtentTests.cs @@ -0,0 +1,102 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class DrawableBrushFractionalContentExtentTests +{ + private static readonly PixelSize Frame = new(256, 144); + private const float RenderScale = 2f; + + private const float ContentWidth = 45.9f; + private const float ContentHeight = 30.9f; + private const float HostWidth = 210f; + private const float HostHeight = 130f; + + /// + /// fits the drawable's true fractional bounds into the destination. + /// Rounding the materialized content size to whole logical units inflates the uniform factor by + /// size / floor(size), oversizing the fill and pushing it into the destination clip. + /// + /// + /// Only the free axis is asserted. The destination clip pins the constrained axis at the host + /// extent whatever factor produced it, so measuring it cannot tell an exact fit from an overflow. + /// + [TestCase(TileMode.None)] + [TestCase(TileMode.Tile)] + public void DrawableBrushUniformStretch_FitsFractionalContentBounds(TileMode tileMode) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource host = CreateBrushHost(tileMode); + using Bitmap rendered = GoldenImageHarness.RenderAtScale( + host, Frame, RenderScale, clearColor: Colors.Transparent); + + PixelRect covered = GetCoveredBounds(rendered); + + float uniformScale = MathF.Min(HostWidth / ContentWidth, HostHeight / ContentHeight); + float expectedWidth = ContentWidth * uniformScale * RenderScale; + + TestContext.WriteLine( + $"covered={covered} expected width {expectedWidth:F2} device px " + + $"(uniform scale {uniformScale:F5})"); + + Assert.That(covered.Width, Is.EqualTo(expectedWidth).Within(3d), + "the fill must be scaled by the content's fractional bounds, not a rounded size"); + }); + } + + private static Drawable.Resource CreateBrushHost(TileMode tileMode) + { + var content = new RectShape(); + content.Width.CurrentValue = ContentWidth; + content.Height.CurrentValue = ContentHeight; + content.Fill.CurrentValue = Brushes.White; + + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = content; + brush.Stretch.CurrentValue = Stretch.Uniform; + brush.TileMode.CurrentValue = tileMode; + + var host = new RectShape(); + host.AlignmentX.CurrentValue = AlignmentX.Center; + host.AlignmentY.CurrentValue = AlignmentY.Center; + host.Width.CurrentValue = HostWidth; + host.Height.CurrentValue = HostHeight; + host.Fill.CurrentValue = brush; + return host.ToResource(CompositionContext.Default); + } + + // Half coverage tracks the geometric edge through the resampling ramp on both sides. + private static PixelRect GetCoveredBounds(Bitmap bitmap) + { + int minX = bitmap.Width; + int minY = bitmap.Height; + int maxX = -1; + int maxY = -1; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[(((y * bitmap.Width) + x) * 4) + 3]); + if (alpha < 0.5f) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + Assert.That(maxX, Is.GreaterThanOrEqualTo(minX), "the drawable-brush fill produced no covered pixels"); + return new PixelRect(minX, minY, maxX - minX + 1, maxY - minY + 1); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupIsolationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupIsolationTests.cs new file mode 100644 index 0000000000..0833881eb0 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupIsolationTests.cs @@ -0,0 +1,941 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class DrawableGroupIsolationTests +{ + private static readonly PixelSize s_frame = new(400, 400); + + [Test] + public void OverlappingChildren_GroupOpacityAppliesOnceToComposite() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource group = CreateGroup( + opacity: 50, + effect: null, + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Left), + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Right)); + using Drawable.Resource control = CreateRectangle( + 400, + 240, + Brushes.White, + opacity: 50) + .ToResource(CompositionContext.Default); + using Bitmap actual = RenderScene(out RenderExecutionStatistics statistics, group); + using Bitmap expected = RenderScene(control); + + AssertByteIdentical(expected, actual, "overlapping children at group opacity 50%"); + Rgba left = ReadPixel(actual, 40, 200); + Rgba overlap = ReadPixel(actual, 200, 200); + Rgba right = ReadPixel(actual, 360, 200); + Assert.Multiple(() => + { + Assert.That(left.Alpha, Is.EqualTo(0.5f).Within(0.003f)); + Assert.That(left.Red, Is.EqualTo(left.Alpha).Within(0.003f)); + Assert.That(overlap.Alpha, Is.EqualTo(0.5f).Within(0.003f)); + Assert.That(overlap.Red, Is.EqualTo(overlap.Alpha).Within(0.003f)); + Assert.That(right.Alpha, Is.EqualTo(0.5f).Within(0.003f)); + Assert.That(right.Red, Is.EqualTo(right.Alpha).Within(0.003f)); + Assert.That( + statistics.ShaderRunExecutions, + Is.Zero, + "The fixture must exercise compatibility opacity through ImmediateCanvas.PushOpacity."); + }); + TestContext.WriteLine( + $"Group opacity path: shader runs {statistics.ShaderRunExecutions}, " + + $"fused shader runs {statistics.FusedShaderRunExecutions}."); + }); + } + + [Test] + public void IdentityEffect_DoesNotChangeGroupOpacityComposition() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource plain = CreateGroup( + opacity: 50, + effect: null, + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Left), + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Right)); + using Drawable.Resource filtered = CreateGroup( + opacity: 50, + identity, + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Left), + CreateRectangle(240, 240, Brushes.White, alignmentX: AlignmentX.Right)); + using Bitmap expected = RenderScene(plain); + using Bitmap actual = RenderScene(filtered); + + // Byte-identical: both paths now carry the group opacity in float precision, so an identity + // effect reproduces the unfiltered composition exactly. + AssertByteIdentical(expected, actual, "identity effect on an overlapping group"); + Assert.That( + ReadPixel(actual, 200, 200).Alpha, + Is.EqualTo(0.5f).Within(0.003f), + "the group opacity must be applied exactly once."); + }); + } + + [Test] + public void SplitEffectOnGroup_MatchesSplitEffectOnEachChild() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource groupFiltered = CreateGroup( + opacity: 100, + CreateSplitEffect(), + CreateRectangle(240, 240, Brushes.OrangeRed, alignmentX: AlignmentX.Left), + CreateRectangle(240, 240, Brushes.SteelBlue, alignmentX: AlignmentX.Right)); + + RectShape left = CreateRectangle( + 240, + 240, + Brushes.OrangeRed, + alignmentX: AlignmentX.Left); + left.FilterEffect.CurrentValue = CreateSplitEffect(); + RectShape right = CreateRectangle( + 240, + 240, + Brushes.SteelBlue, + alignmentX: AlignmentX.Right); + right.FilterEffect.CurrentValue = CreateSplitEffect(); + using Drawable.Resource childrenFiltered = CreateGroup( + opacity: 100, + effect: null, + left, + right); + + using Bitmap actual = RenderScene(groupFiltered); + using Bitmap expected = RenderScene(childrenFiltered); + + AssertByteIdentical( + expected, + actual, + "a group SplitEffect and an equivalent SplitEffect on each child"); + }); + } + + [Test] + public void BlurOnGroupWithHairlineChild_ProducesOnlyFiniteChannels() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(6, 6); + using Drawable.Resource group = CreateGroup( + opacity: 100, + blur, + CreateRectangle(200, 1, Brushes.Magenta), + CreateRectangle(40, 40, Brushes.SteelBlue)); + + using Bitmap actual = RenderScene(0.5f, group); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("blurred group hairline", actual)), + Is.Null, + "A per-child filter layer must not sample outside the hairline child it replays."); + Assert.That(HasFiniteVisibleContent(actual), Is.True, "The fixture must render visible content."); + }); + }); + } + + [Test] + public void EffectChainOnGroupWithOffFrameChild_ProducesOnlyFiniteChannels() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + RectShape offFrame = CreateRectangle(160, 120, Brushes.OrangeRed); + offFrame.Transform.CurrentValue = new TranslateTransform(-180, -120); + + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(10, 10); + var shadow = new DropShadow(); + shadow.Position.CurrentValue = new Point(10, 10); + shadow.Sigma.CurrentValue = new Size(20, 20); + shadow.Color.CurrentValue = Colors.Black; + var chain = new FilterEffectGroup(); + chain.Children.Add(blur); + chain.Children.Add(shadow); + + using Drawable.Resource group = CreateGroup( + opacity: 100, + chain, + CreateRectangle(120, 80, Brushes.SteelBlue), + offFrame); + + using Bitmap actual = RenderScene(0.5f, group); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("filtered group with off-frame child", actual)), + Is.Null, + "Each effect layer must stay within the off-frame child content it replays."); + Assert.That(HasFiniteVisibleContent(actual), Is.True, "The fixture must render visible content."); + }); + }); + } + + [TestCase(100f)] + [TestCase(99f)] + public void MultiplyChild_CompositesAgainstIsolatedGroupContent(float opacity) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource backdrop = CreateRectangle(400, 400, Brushes.Cyan) + .ToResource(CompositionContext.Default); + using Drawable.Resource multiplyGroup = CreateGroup( + opacity, + effect: null, + CreateRectangle(240, 240, Brushes.Magenta, blendMode: BlendMode.Multiply)); + using Drawable.Resource sourceOverBackdrop = CreateRectangle(400, 400, Brushes.Cyan) + .ToResource(CompositionContext.Default); + using Drawable.Resource sourceOverGroup = CreateGroup( + opacity, + effect: null, + CreateRectangle(240, 240, Brushes.Magenta)); + using Bitmap actual = RenderScene(backdrop, multiplyGroup); + using Bitmap expected = RenderScene(sourceOverBackdrop, sourceOverGroup); + + AssertByteIdentical( + expected, + actual, + $"Multiply child against an isolated group at opacity {opacity}%"); + Assert.That( + ReadPixel(actual, 20, 20), + Is.EqualTo(ReadPixel(expected, 20, 20)), + "Pixels outside the group content must remain backdrop-only."); + }); + } + + [Test] + public void HalfAlphaDstIn_MasksTheGroupComposite() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var halfWhite = new SolidColorBrush(new Color(128, 255, 255, 255)); + using Drawable.Resource group = CreateGroup( + opacity: 100, + effect: null, + CreateRectangle(240, 240, Brushes.White), + CreateRectangle(240, 240, halfWhite, blendMode: BlendMode.DstIn)); + using Bitmap actual = RenderScene(group); + + Rgba center = ReadPixel(actual, 200, 200); + Assert.Multiple(() => + { + Assert.That(center.Alpha, Is.EqualTo(128f / 255f).Within(0.002f)); + Assert.That(center.Red, Is.EqualTo(center.Alpha).Within(0.002f)); + Assert.That(ReadPixel(actual, 20, 20).Alpha, Is.Zero); + }); + }); + } + + [Test] + public void HalfOpacityGradientDstIn_MasksTheGroupCompositeAtHalfStrength() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var gradient = new LinearGradientBrush(); + gradient.Opacity.CurrentValue = 50; + gradient.GradientStops.Add(new GradientStop(Colors.White, 0)); + gradient.GradientStops.Add(new GradientStop(Colors.White, 1)); + using Drawable.Resource group = CreateGroup( + opacity: 100, + effect: null, + CreateRectangle(240, 240, Brushes.White), + CreateRectangle(240, 240, gradient, blendMode: BlendMode.DstIn)); + using Bitmap actual = RenderScene(group); + + Rgba center = ReadPixel(actual, 200, 200); + Assert.Multiple(() => + { + Assert.That(center.Alpha, Is.EqualTo(0.5f).Within(0.003f)); + Assert.That(center.Red, Is.EqualTo(center.Alpha).Within(0.003f)); + }); + }); + } + + [TestCase(BlendMode.DstIn, 0f, 120f)] + [TestCase(BlendMode.DstIn, 120f, 0f)] + [TestCase(BlendMode.DstOut, 0f, 120f)] + [TestCase(BlendMode.DstOut, 120f, 0f)] + public void FractionalZeroAreaDestructiveRectangle_HasNoEffect( + BlendMode blendMode, + float width, + float height) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var geometry = new RectGeometry + { + Width = { CurrentValue = width }, + Height = { CurrentValue = height }, + }; + using Geometry.Resource geometryResource = geometry.ToResource(CompositionContext.Default); + var nonEmptyGeometry = new RectGeometry + { + Width = { CurrentValue = 4 }, + Height = { CurrentValue = 4 }, + }; + using Geometry.Resource nonEmptyGeometryResource = + nonEmptyGeometry.ToResource(CompositionContext.Default); + + Bitmap Render(bool includeZeroAreaRectangle) + { + using RenderTarget target = RenderTarget.Create(32, 32) + ?? throw new InvalidOperationException( + "RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target); + canvas.Clear(Colors.White); + using PushedState blend = blendMode == BlendMode.DstOut + ? canvas.PushDirectBlendMode(blendMode) + : canvas.PushBlendMode(blendMode); + + if (blendMode == BlendMode.DstIn) + { + using (canvas.PushTransform(Matrix.CreateTranslation(2, 2))) + { + canvas.DrawGeometry( + nonEmptyGeometryResource, + Brushes.Resource.White, + pen: null); + } + } + + if (includeZeroAreaRectangle) + { + using (canvas.PushTransform(Matrix.CreateTranslation(16.5f, 8.5f))) + { + canvas.DrawGeometry(geometryResource, Brushes.Resource.White, pen: null); + } + } + + return target.Snapshot(); + } + + using Bitmap expected = Render(includeZeroAreaRectangle: false); + using Bitmap actual = Render(includeZeroAreaRectangle: true); + + AssertByteIdentical( + expected, + actual, + $"fractional {width}x{height} {blendMode} rectangle"); + }); + } + + [Test] + public void WindowDstIn_RemovesGroupContentOutsideTheWindow() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource group = CreateGroup( + opacity: 100, + effect: null, + CreateRectangle(320, 320, Brushes.White), + CreateRectangle(160, 160, Brushes.White, blendMode: BlendMode.DstIn)); + using Bitmap isolated = RenderScene(group); + + using Drawable.Resource backdrop = CreateRectangle(400, 400, Brushes.Blue) + .ToResource(CompositionContext.Default); + using Drawable.Resource groupOverBackdrop = CreateGroup( + opacity: 100, + effect: null, + CreateRectangle(320, 320, Brushes.White), + CreateRectangle(160, 160, Brushes.White, blendMode: BlendMode.DstIn)); + using Bitmap composited = RenderScene(backdrop, groupOverBackdrop); + + Assert.Multiple(() => + { + Assert.That(ReadPixel(isolated, 200, 200).Alpha, Is.EqualTo(1).Within(0.001f)); + Assert.That( + ReadPixel(isolated, 80, 200).Alpha, + Is.Zero, + "Content outside the DstIn window must be removed across the group scope."); + Assert.That( + ReadPixel(composited, 20, 20), + Is.EqualTo(ReadPixel(composited, 80, 200)), + "Removing group content must reveal, not modify, the outer backdrop."); + }); + }); + } + + [Test] + public void FractionalDstInCorners_IdentityEffectPreservesTwoDimensionalCoverage() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource plain = CreateTranslatedMaskGroup(effect: null); + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource filtered = CreateTranslatedMaskGroup(identity); + + using Bitmap expected = RenderScene(plain); + using Bitmap actual = RenderScene(filtered); + + Assert.Multiple(() => + { + AssertByteIdentical( + expected, + actual, + "fractionally translated DstIn mask with an identity group effect"); + Assert.That( + ReadPixel(expected, 150, 150).Alpha, + Is.EqualTo(0.75f * 0.75f).Within(0.003f), + "A corner pixel must contain the product of the horizontal and vertical mask coverage."); + Assert.That( + ReadPixel(actual, 150, 150).Alpha, + Is.EqualTo(0.75f * 0.75f).Within(0.003f), + "The effect path must preserve two-dimensional mask coverage."); + }); + }); + } + + [Test] + public void FractionalDstOutEdges_IdentityEffectDoesNotChangeCoverage() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource plain = CreateTranslatedDstOutGroup(effect: null); + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource filtered = CreateTranslatedDstOutGroup(identity); + + using Bitmap expected = RenderScene(plain); + using Bitmap actual = RenderScene(filtered); + + Assert.Multiple(() => + { + AssertByteIdentical( + expected, + actual, + "fractionally translated DstOut mask with and without an identity group effect"); + Assert.That( + ReadPixel(expected, 120, 200).Alpha, + Is.EqualTo(0.5f).Within(0.003f), + "The leading vertical eraser edge must preserve its absolute half-pixel coverage."); + Assert.That( + ReadPixel(expected, 200, 120).Alpha, + Is.EqualTo(0.25f).Within(0.003f), + "The leading horizontal eraser edge must retain one minus 75% eraser coverage."); + Assert.That( + ReadPixel(expected, 120, 120).Alpha, + Is.EqualTo(1 - (0.5f * 0.75f)).Within(0.003f), + "The leading corner must use the product of both eraser coverage axes."); + Assert.That( + ReadPixel(expected, 280, 280).Alpha, + Is.EqualTo(1 - (0.5f * 0.25f)).Within(0.003f), + "The trailing corner must use the product of both eraser coverage axes."); + }); + }); + } + + [Test] + public void QuarterScaleDstInMask_IdentityEffectDoesNotCreateADevicePixelFringe() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource plain = CreateTranslatedMaskGroup(effect: null); + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource filtered = CreateTranslatedMaskGroup(identity); + using Bitmap expected = RenderScene(0.25f, plain); + using Bitmap actual = RenderScene(0.25f, filtered); + + Assert.Multiple(() => + { + AssertByteIdentical( + expected, + actual, + "quarter-scale DstIn mask with and without an identity group effect"); + Assert.That( + ReadPixel(expected, 37, 50).Alpha, + Is.GreaterThan(0), + "The first covered device column must remain present."); + Assert.That( + ReadPixel(expected, 36, 50).Alpha, + Is.Zero.Within(0.001f), + "No leading one-device-pixel fringe may escape the mask footprint."); + Assert.That( + ReadPixel(expected, 63, 50).Alpha, + Is.Zero.Within(0.001f), + "No trailing one-device-pixel fringe may escape the mask footprint."); + }); + }); + } + + [Test] + public void DstOutChild_RemovesOnlyItsIntersectionWithGroupContent() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource backdrop = CreateRectangle(400, 400, Brushes.Blue) + .ToResource(CompositionContext.Default); + using Drawable.Resource group = CreateTranslatedDstOutGroup(effect: null); + using Bitmap actual = RenderScene(backdrop, group); + + Rgba backdropOnly = ReadPixel(actual, 20, 20); + Rgba content = ReadPixel(actual, 80, 200); + Rgba erased = ReadPixel(actual, 200, 200); + Assert.Multiple(() => + { + Assert.That( + backdropOnly, + Is.EqualTo(new Rgba(0, 0, 1, 1)), + "The isolated group must not modify the outer backdrop."); + Assert.That( + content, + Is.EqualTo(new Rgba(1, 1, 1, 1)), + "Group content outside the DstOut child bounds must remain opaque."); + Assert.That( + erased, + Is.EqualTo(backdropOnly), + "The DstOut child must reveal the backdrop inside its intersection with group content."); + }); + }); + } + + [Test] + public void NestedGroupOpacity_MultipliesCompositeOpacity() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var sourceColor = new Color(255, 128, 64, 32); + var inner = new DrawableGroup(); + inner.Opacity.CurrentValue = 50; + inner.Children.Add(CreateRectangle( + 240, + 240, + new SolidColorBrush(sourceColor))); + + var outer = new DrawableGroup(); + outer.Opacity.CurrentValue = 50; + outer.Children.Add(inner); + + using Drawable.Resource resource = outer.ToResource(CompositionContext.Default); + using Bitmap actual = RenderScene(resource); + Rgba center = ReadPixel(actual, 200, 200); + const float expectedAlpha = 0.25f; + + Assert.Multiple(() => + { + Assert.That(center.Alpha, Is.EqualTo(expectedAlpha).Within(0.003f)); + Assert.That( + center.Red, + Is.EqualTo(Color.SrgbToLinear(sourceColor.R / 255f) * expectedAlpha).Within(0.001f)); + Assert.That( + center.Green, + Is.EqualTo(Color.SrgbToLinear(sourceColor.G / 255f) * expectedAlpha).Within(0.001f)); + Assert.That( + center.Blue, + Is.EqualTo(Color.SrgbToLinear(sourceColor.B / 255f) * expectedAlpha).Within(0.001f)); + }); + }); + } + + [TestCase(0.75f)] + [TestCase(1f)] + [TestCase(2f)] + public void Opacity100Group_IsByteIdenticalToBareAntialiasedContent(float outputScale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource group = CreateGroup( + opacity: 100, + effect: null, + CreateEllipse(241, 163, Brushes.White)); + using Drawable.Resource bare = CreateEllipse(241, 163, Brushes.White) + .ToResource(CompositionContext.Default); + using Bitmap fusedGroup = RenderScene(outputScale, FusionMode.Enabled, group); + using Bitmap fusedBare = RenderScene(outputScale, FusionMode.Enabled, bare); + using Bitmap replayGroup = RenderScene(outputScale, FusionMode.Disabled, group); + using Bitmap replayBare = RenderScene(outputScale, FusionMode.Disabled, bare); + + AssertByteIdentical( + fusedBare, + fusedGroup, + $"fused 100%-opacity group antialiasing at scale {outputScale}"); + AssertByteIdentical( + replayBare, + replayGroup, + $"replayed 100%-opacity group antialiasing at scale {outputScale}"); + AssertByteIdentical( + fusedGroup, + replayGroup, + $"fused/replayed group parity at scale {outputScale}"); + }); + } + + [Test] + public void BareMultiplyDrawable_StillBlendsAgainstBackdrop() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource backdrop = CreateRectangle(400, 400, Brushes.Cyan) + .ToResource(CompositionContext.Default); + using Drawable.Resource multiply = CreateRectangle( + 240, + 240, + Brushes.Magenta, + blendMode: BlendMode.Multiply) + .ToResource(CompositionContext.Default); + using Bitmap actual = RenderScene(backdrop, multiply); + + Rgba overlap = ReadPixel(actual, 200, 200); + Assert.Multiple(() => + { + Assert.That(overlap.Red, Is.Zero.Within(0.001f)); + Assert.That(overlap.Green, Is.Zero.Within(0.001f)); + Assert.That(overlap.Blue, Is.EqualTo(1).Within(0.001f)); + Assert.That(overlap.Alpha, Is.EqualTo(1).Within(0.001f)); + }); + }); + } + + [Test] + public void SourceBackdropInsideGroup_MatchesBareBackdrop() + { + var frame = new PixelSize(256, 144); + + Drawable.Resource[] CreateScene(bool grouped, bool includeBackdrop = true) + { + var gradient = new LinearGradientBrush(); + gradient.GradientStops.Add(new GradientStop(Colors.Crimson, 0)); + gradient.GradientStops.Add(new GradientStop(Colors.Gold, 1)); + + Drawable.Resource background = CreateRectangle(frame.Width, frame.Height, gradient) + .ToResource(CompositionContext.Default); + Drawable.Resource foreground = CreateRectangle(130, 95, Brushes.Navy) + .ToResource(CompositionContext.Default); + if (!includeBackdrop) + return [background, foreground]; + + var backdrop = new SourceBackdrop + { + Clear = { CurrentValue = false }, + FilterEffect = { CurrentValue = new Invert() }, + }; + Drawable effect = backdrop; + if (grouped) + { + var group = new DrawableGroup(); + group.Children.Add(backdrop); + effect = group; + } + + return + [ + background, + foreground, + effect.ToResource(CompositionContext.Default), + ]; + } + + Drawable.Resource[] expectedResources = CreateScene(grouped: false); + Drawable.Resource[] actualResources = CreateScene(grouped: true); + Drawable.Resource[] omittedResources = CreateScene(grouped: false, includeBackdrop: false); + try + { + using Bitmap expected = RenderScene(frame, expectedResources); + using Bitmap actual = RenderScene(frame, actualResources); + using Bitmap omitted = RenderScene(frame, omittedResources); + + AssertByteIdentical( + expected, + actual, + "a SourceBackdrop nested in a DrawableGroup"); + Assert.That( + actual.GetPixelSpan().SequenceEqual(omitted.GetPixelSpan()), + Is.False, + "the grouped SourceBackdrop must contribute visible pixels"); + } + finally + { + foreach (Drawable.Resource resource in expectedResources) + resource.Dispose(); + foreach (Drawable.Resource resource in actualResources) + resource.Dispose(); + foreach (Drawable.Resource resource in omittedResources) + resource.Dispose(); + } + } + + /// + /// A drawable whose whole output is a target-wide clear records a symbolic full-target write and no value + /// bounds at all. Deriving the isolation region from recorded value bounds alone makes that group an empty + /// scope, and an empty scope renders nothing - the group's content disappears instead of compositing at + /// its opacity. + /// + [Test] + public void GroupOpacityOverAFullTargetClear_StillCompositesTheClearedTarget() + { + var frame = new PixelSize(8, 8); + using Drawable.Resource group = CreateGroup( + opacity: 50, + effect: null, + new ClearOnlyDrawable(Colors.White)); + + using Bitmap actual = RenderScene(frame, group); + + Rgba pixel = ReadPixel(actual, 4, 4); + Assert.Multiple(() => + { + Assert.That(pixel.Alpha, Is.EqualTo(0.5f).Within(0.01f)); + Assert.That(pixel.Red, Is.EqualTo(pixel.Alpha).Within(0.01f)); + }); + } + + private static Drawable.Resource CreateGroup( + float opacity, + FilterEffect? effect, + params Drawable[] children) + { + var group = new DrawableGroup(); + group.Opacity.CurrentValue = opacity; + group.FilterEffect.CurrentValue = effect; + foreach (Drawable child in children) + group.Children.Add(child); + return group.ToResource(CompositionContext.Default); + } + + private static SplitEffect CreateSplitEffect() + { + var effect = new SplitEffect(); + effect.HorizontalDivisions.CurrentValue = 2; + effect.VerticalDivisions.CurrentValue = 2; + effect.HorizontalSpacing.CurrentValue = 20; + effect.VerticalSpacing.CurrentValue = 20; + return effect; + } + + private static Drawable.Resource CreateTranslatedMaskGroup(FilterEffect? effect) + { + var group = new DrawableGroup(); + group.FilterEffect.CurrentValue = effect; + group.Transform.CurrentValue = new TranslateTransform(0.25f, 0.25f); + group.Children.Add(CreateRectangle(200, 200, Brushes.Blue)); + group.Children.Add(CreateRectangle(100, 100, Brushes.White, blendMode: BlendMode.DstIn)); + return group.ToResource(CompositionContext.Default); + } + + private static Drawable.Resource CreateTranslatedDstOutGroup( + FilterEffect? effect, + float eraserOpacity = 100) + { + var eraser = CreateRectangle( + 160, + 160, + Brushes.White, + opacity: eraserOpacity, + blendMode: BlendMode.DstOut); + eraser.Transform.CurrentValue = new TranslateTransform(0.25f, 0.25f); + + var group = new DrawableGroup(); + group.FilterEffect.CurrentValue = effect; + group.Transform.CurrentValue = new TranslateTransform(0.25f, 0); + group.Children.Add(CreateRectangle(320, 320, Brushes.White)); + group.Children.Add(eraser); + return group.ToResource(CompositionContext.Default); + } + + private static RectShape CreateRectangle( + float width, + float height, + Brush fill, + float opacity = 100, + BlendMode blendMode = BlendMode.SrcOver, + AlignmentX alignmentX = AlignmentX.Center, + AlignmentY alignmentY = AlignmentY.Center) + { + var shape = new RectShape(); + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.Fill.CurrentValue = fill; + shape.Opacity.CurrentValue = opacity; + shape.BlendMode.CurrentValue = blendMode; + shape.AlignmentX.CurrentValue = alignmentX; + shape.AlignmentY.CurrentValue = alignmentY; + return shape; + } + + private static EllipseShape CreateEllipse(float width, float height, Brush fill) + { + var shape = new EllipseShape(); + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.Fill.CurrentValue = fill; + return shape; + } + + private static Bitmap RenderScene(params Drawable.Resource[] resources) + => RenderScene(1, resources); + + private static Bitmap RenderScene( + out RenderExecutionStatistics statistics, + params Drawable.Resource[] resources) + => RenderScene(1, FusionMode.Enabled, out statistics, resources); + + private static Bitmap RenderScene(float outputScale, params Drawable.Resource[] resources) + => RenderScene(outputScale, FusionMode.Enabled, resources); + + private static Bitmap RenderScene( + float outputScale, + FusionMode fusionMode, + params Drawable.Resource[] resources) + => RenderScene(outputScale, fusionMode, out _, resources); + + private static Bitmap RenderScene( + float outputScale, + FusionMode fusionMode, + out RenderExecutionStatistics statistics, + params Drawable.Resource[] resources) + => RenderScene(s_frame, outputScale, fusionMode, useCpuTarget: false, out statistics, resources); + + private static Bitmap RenderScene( + PixelSize frame, + params Drawable.Resource[] resources) + => RenderScene(frame, 1, FusionMode.Enabled, useCpuTarget: true, out _, resources); + + private static Bitmap RenderScene( + PixelSize frame, + float outputScale, + FusionMode fusionMode, + bool useCpuTarget, + out RenderExecutionStatistics statistics, + params Drawable.Resource[] resources) + { + int width = (int)MathF.Ceiling(frame.Width * outputScale); + int height = (int)MathF.Ceiling(frame.Height * outputScale); + using RenderTarget target = useCpuTarget + ? new CpuRenderTarget(width, height) + : RenderTarget.Create(width, height) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, outputScale, logicalSize: frame.ToSize(1)); + canvas.Clear(); + + using var root = new DrawableRenderNode(resources[0]); + using (var context = new GraphicsContext2D(root, frame.ToSize(1), outputScale)) + { + foreach (Drawable.Resource resource in resources) + context.DrawDrawable(resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, frame.ToSize(1)), + OutputScale = outputScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + }); + renderer.Render(canvas); + statistics = renderer.LastExecutionStatistics; + return target.Snapshot(); + } + + private static Rgba ReadPixel(Bitmap bitmap, int x, int y) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int offset = ((y * bitmap.Width) + x) * 4; + return new Rgba( + (float)BitConverter.UInt16BitsToHalf(pixels[offset]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 1]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 2]), + (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3])); + } + + private static void AssertByteIdentical(Bitmap expected, Bitmap actual, string scenario) + { + ReadOnlySpan expectedPixels = expected.GetPixelSpan(); + ReadOnlySpan actualPixels = actual.GetPixelSpan(); + bool identical = actualPixels.SequenceEqual(expectedPixels); + Assert.Multiple(() => + { + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + Assert.That( + identical, + Is.True, + $"{scenario} must be byte-identical."); + Assert.That( + HasFiniteVisibleContent(expected), + Is.True, + $"{scenario} must render finite visible content (SC-013 non-vacuity)."); + }); + } + + private static bool HasFiniteVisibleContent(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int i = 3; i < pixels.Length; i += 4) + { + float a = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + if (float.IsFinite(a) && a > 0f) + { + return true; + } + } + return false; + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private readonly record struct Rgba(float Red, float Green, float Blue, float Alpha); +} + +internal sealed partial class ClearOnlyDrawable(Color color) : Drawable +{ + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + => context.Clear(color); + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => availableSize; + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupPublishedBoundsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupPublishedBoundsTests.cs new file mode 100644 index 0000000000..c568909bd7 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DrawableGroupPublishedBoundsTests.cs @@ -0,0 +1,181 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class DrawableGroupPublishedBoundsTests +{ + [Test] + public void ClippingOnGroup_IsIndependentOfSceneSize() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource group = CreateClippedGroup(); + using Drawable.Resource control = CreateIndividuallyClippedChildren(); + var smallFrame = new PixelSize(256, 144); + var largeFrame = new PixelSize(512, 288); + using Bitmap small = GoldenImageHarness.RenderAtScale( + group, smallFrame, 1f, clearColor: Colors.Transparent); + using Bitmap large = GoldenImageHarness.RenderAtScale( + group, largeFrame, 1f, clearColor: Colors.Transparent); + using Bitmap expectedSmall = GoldenImageHarness.RenderAtScale( + control, smallFrame, 1f, clearColor: Colors.Transparent); + using Bitmap expectedLarge = GoldenImageHarness.RenderAtScale( + control, largeFrame, 1f, clearColor: Colors.Transparent); + + LogicalAlphaBounds smallBounds = GetLogicalAlphaBounds(small, smallFrame); + LogicalAlphaBounds largeBounds = GetLogicalAlphaBounds(large, largeFrame); + + Assert.Multiple(() => + { + GoldenImageHarness.AssertByteIdentical(expectedSmall, small); + GoldenImageHarness.AssertByteIdentical(expectedLarge, large); + Assert.That(smallBounds, Is.EqualTo(largeBounds), + "per-child group clipping must be measured from group content, not the scene domain"); + Assert.That(smallBounds.Width, Is.LessThan(200), "the horizontal clipping must be visible"); + Assert.That(smallBounds.Height, Is.LessThan(120), "the vertical clipping must be visible"); + }); + }); + } + + [Test] + public void DrawableBrush_GroupedContent_MatchesUngroupedStretchFill() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var frame = new PixelSize(220, 160); + using Drawable.Resource ungrouped = CreateDrawableBrushHost(grouped: false); + using Drawable.Resource grouped = CreateDrawableBrushHost(grouped: true); + using Bitmap expected = GoldenImageHarness.RenderAtScale( + ungrouped, frame, 1f, clearColor: Colors.Transparent); + using Bitmap actual = GoldenImageHarness.RenderAtScale( + grouped, frame, 1f, clearColor: Colors.Transparent); + + GoldenImageHarness.AssertByteIdentical(expected, actual); + LogicalAlphaBounds bounds = GetLogicalAlphaBounds(actual, frame); + Assert.Multiple(() => + { + Assert.That(bounds.Width, Is.EqualTo(180)); + Assert.That(bounds.Height, Is.EqualTo(120)); + }); + }); + } + + private static Drawable.Resource CreateClippedGroup() + { + var group = new DrawableGroup(); + group.FilterEffect.CurrentValue = CreateClipping(); + group.Children.Add(CreateGradientRectangle(200, 120)); + group.Children.Add(CreateRectangle(60, 60, Brushes.White)); + return group.ToResource(CompositionContext.Default); + } + + private static Drawable.Resource CreateIndividuallyClippedChildren() + { + RectShape large = CreateGradientRectangle(200, 120); + large.FilterEffect.CurrentValue = CreateClipping(); + RectShape small = CreateRectangle(60, 60, Brushes.White); + small.FilterEffect.CurrentValue = CreateClipping(); + + var group = new DrawableGroup(); + group.Children.Add(large); + group.Children.Add(small); + return group.ToResource(CompositionContext.Default); + } + + private static Clipping CreateClipping() + { + var clipping = new Clipping(); + clipping.Left.CurrentValue = 60; + clipping.Top.CurrentValue = 40; + clipping.Right.CurrentValue = 60; + clipping.Bottom.CurrentValue = 40; + return clipping; + } + + private static Drawable.Resource CreateDrawableBrushHost(bool grouped) + { + RectShape content = CreateGradientRectangle(60, 40); + Drawable brushContent; + if (grouped) + { + var group = new DrawableGroup(); + group.Children.Add(content); + brushContent = group; + } + else + { + brushContent = content; + } + + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = brushContent; + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + + return CreateRectangle(180, 120, brush).ToResource(CompositionContext.Default); + } + + private static RectShape CreateGradientRectangle(float width, float height) + { + var gradient = new LinearGradientBrush(); + gradient.GradientStops.Add(new GradientStop(Colors.Crimson, 0)); + gradient.GradientStops.Add(new GradientStop(Colors.Gold, 1)); + return CreateRectangle(width, height, gradient); + } + + private static RectShape CreateRectangle(float width, float height, Brush fill) + { + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = width; + rectangle.Height.CurrentValue = height; + rectangle.Fill.CurrentValue = fill; + return rectangle; + } + + private static LogicalAlphaBounds GetLogicalAlphaBounds(Bitmap bitmap, PixelSize frame) + { + int minX = bitmap.Width; + int minY = bitmap.Height; + int maxX = -1; + int maxY = -1; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[((y * bitmap.Width) + x) * 4 + 3]); + if (alpha <= 0.01f) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + Assert.That(maxX, Is.GreaterThanOrEqualTo(minX), "the render must contain visible pixels"); + Assert.That(maxY, Is.GreaterThanOrEqualTo(minY), "the render must contain visible pixels"); + return new LogicalAlphaBounds( + (minX * 2) - frame.Width, + (minY * 2) - frame.Height, + maxX - minX + 1, + maxY - minY + 1); + } + + private readonly record struct LogicalAlphaBounds( + int TwiceLeftFromFrameCenter, + int TwiceTopFromFrameCenter, + int Width, + int Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectDrawableBrushLoweringTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectDrawableBrushLoweringTests.cs new file mode 100644 index 0000000000..76c1308358 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectDrawableBrushLoweringTests.cs @@ -0,0 +1,318 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// Every effect that paints with a user-settable brush or pen must register it so nested DrawableBrush content is +// lowered into the recorded graph. A registered drawable brush whose content is a flat colour must therefore +// render like the equivalent solid brush, and must not render like an absent brush. +[NonParallelizable] +[TestFixture] +public class EffectDrawableBrushLoweringTests +{ + private static readonly PixelSize Frame = new(200, 200); + + private const double EquivalenceTolerance = 0.02; + + public static IEnumerable Effects() + { + yield return new TestCaseData( + "FlatShadow", + (Func)(brush => + { + var effect = new FlatShadow(); + effect.Angle.CurrentValue = 0; + effect.Length.CurrentValue = 40; + effect.Brush.CurrentValue = brush; + return effect; + })); + yield return new TestCaseData( + "BlendEffect", + (Func)(brush => + { + var effect = new BlendEffect(); + effect.Brush.CurrentValue = brush; + effect.BlendMode.CurrentValue = BlendMode.SrcIn; + return effect; + })); + yield return new TestCaseData( + "StrokeEffect", + (Func)(brush => + { + var effect = new StrokeEffect(); + if (brush is not null) + { + var pen = new Pen(); + pen.Thickness.CurrentValue = 14; + pen.Brush.CurrentValue = brush; + effect.Pen.CurrentValue = pen; + } + + return effect; + })); + yield return new TestCaseData( + "NestedActivate", + (Func)(brush => + { + var effect = new NestedActivateBrushEffect(); + effect.Brush.CurrentValue = brush; + return effect; + })); + yield return new TestCaseData( + "TypedOperationBeforeBrush", + (Func)(brush => + { + var effect = new TypedOperationBeforeBrushEffect(); + effect.Brush.CurrentValue = brush; + return effect; + })); + yield return new TestCaseData( + "DisplacementMap-ShowMap", + (Func)(brush => + { + var effect = new DisplacementMapEffect(); + effect.DisplacementMap.CurrentValue = brush; + effect.ShowDisplacementMap.CurrentValue = true; + return effect; + })); + // A drawable map takes the legacy custom-effect path while every other brush takes the shader + // description, so this case pins both the lowering and the equivalence of the two paths. + yield return new TestCaseData( + "DisplacementMap-Transform", + (Func)(brush => + { + var transform = new DisplacementMapTranslateTransform(); + transform.X.CurrentValue = 24; + transform.Y.CurrentValue = -16; + var effect = new DisplacementMapEffect(); + effect.DisplacementMap.CurrentValue = brush; + effect.Transform.CurrentValue = transform; + // The source fill is flat, so a uniform displacement only shows where the shifted sampling + // leaves the source; clamp sampling would reproduce the unshifted fill instead. + effect.SpreadMethod.CurrentValue = GradientSpreadMethod.Decal; + return effect; + })); + } + + // DelayAnimationEffect re-applies its child effect from an execution-time callback, where the recorder is + // no longer reachable. Its child's brush content must therefore be lowered while the parent is recorded. + public static IEnumerable DelayedEffects() + { + foreach (TestCaseData data in Effects()) + { + var name = (string)data.Arguments[0]!; + var makeEffect = (Func)data.Arguments[1]!; + yield return new TestCaseData( + $"DelayAnimation-{name}", + (Func)(brush => + { + var group = new FilterEffectGroup(); + group.Children.Add(makeEffect(brush)); + var delay = new DelayAnimationEffect(); + delay.Effect.CurrentValue = group; + return delay; + })); + } + } + + [TestCaseSource(nameof(Effects))] + [TestCaseSource(nameof(DelayedEffects))] + public void EffectOwnedDrawableBrush_RendersLikeTheEquivalentSolidBrush( + string name, + Func makeEffect) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap solid = GoldenImageHarness.RenderAtScale( + Make(() => makeEffect(MakeSolid())), + Frame, + 1f); + using Bitmap drawable = GoldenImageHarness.RenderAtScale( + Make(() => makeEffect(MakeDrawableBrush())), + Frame, + 1f); + using Bitmap absent = GoldenImageHarness.RenderAtScale( + Make(() => makeEffect(null)), + Frame, + 1f); + + Assert.That( + ImageMetrics.FirstNonFinite( + ("solid", solid), + ("drawable", drawable), + ("absent", absent)), + Is.Null, + $"{name}: the drawable-brush comparison requires finite renders"); + + double equivalence = ImageMetrics.MeanAbsoluteError(solid, drawable); + double vacuity = ImageMetrics.MeanAbsoluteError(absent, drawable); + TestContext.WriteLine( + $"[{name}] drawable vs solid MAE={equivalence:F4}, drawable vs absent MAE={vacuity:F4}"); + + Assert.That( + vacuity, + Is.GreaterThan(0.001), + $"{name}: the effect-owned DrawableBrush rendered like an absent brush, so its nested content " + + "was never materialized"); + Assert.That( + equivalence, + Is.LessThan(EquivalenceTolerance), + $"{name}: the effect-owned DrawableBrush did not render like the equivalent solid brush"); + }); + } + + private static Brush MakeSolid() + { + var brush = new SolidColorBrush(); + brush.Color.CurrentValue = Colors.Red; + return brush; + } + + private static Brush MakeDrawableBrush() + { + var content = new RectShape(); + content.AlignmentX.CurrentValue = AlignmentX.Center; + content.AlignmentY.CurrentValue = AlignmentY.Center; + content.Width.CurrentValue = 200; + content.Height.CurrentValue = 200; + content.Fill.CurrentValue = MakeSolid(); + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = content; + brush.Stretch.CurrentValue = Stretch.Fill; + // A stroke paints outside the brush frame, where TileMode.None decals to transparent; tiling keeps the + // comparison against a solid brush apples-to-apples. + brush.TileMode.CurrentValue = TileMode.Tile; + return brush; + } + + private static Drawable.Resource Make(Func makeEffect) + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = 140; + shape.Height.CurrentValue = 90; + shape.Fill.CurrentValue = Brushes.White; + shape.FilterEffect.CurrentValue = makeEffect(); + return shape.ToResource(CompositionContext.Default); + } +} + +// RegisterBrush states no ordering requirement: a typed operation authored between the registration and the +// operation that paints with the handle is lowered as its own fragment, and the handle must still reach the +// legacy segment behind it. +internal sealed partial class TypedOperationBeforeBrushEffect : FilterEffect +{ + private const string IdentityShader = "half4 apply(half4 color) { return color; }"; + + public TypedOperationBeforeBrushEffect() + { + ScanProperties(); + } + + public IProperty Brush { get; } = Property.Create(); + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + var r = (Resource)resource; + context.Shader(ShaderDescription.CurrentPixel(IdentityShader)); + context.CustomEffect(new BrushPaintState(r.Brush), PaintBrush, static (_, bounds) => bounds); + } + + private static void PaintBrush(BrushPaintState state, CustomFilterEffectContext context) + { + for (int i = 0; i < context.Targets.Count; i++) + { + EffectTarget target = context.Targets[i]; + if (target.RenderTarget is null) + continue; + + Size size = target.Bounds.Size; + EffectTarget newTarget = context.CreateTarget(target.Bounds); + using var paint = new SKPaint(); + context.CreateBrushConstructor(new Rect(size), state.Brush, BlendMode.SrcIn, newTarget.Scale.Value) + .ConfigurePaint(paint); + using (ImmediateCanvas canvas = context.Open(newTarget)) + { + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.DrawRenderTarget(target.RenderTarget, default); + } + + canvas.Canvas.DrawRect(SKRect.Create(size.ToSKSize()), paint); + } + + target.Dispose(); + context.Targets[i] = newTarget; + } + } +} + +// Exercises the public FilterEffectActivator.Activate(FilterEffectContext) seam: a registered brush must stay +// resolvable inside the nested activator that seam builds. +internal sealed partial class NestedActivateBrushEffect : FilterEffect +{ + public NestedActivateBrushEffect() + { + ScanProperties(); + } + + public IProperty Brush { get; } = Property.Create(); + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + var r = (Resource)resource; + context.AppendSkiaFilter( + new BrushPaintState(r.Brush), + static (state, _, activator) => + { + using var nested = new FilterEffectContext( + activator.CurrentTargets.CalculateBounds(), + activator.OutputScale, + activator.WorkingScale); + nested.CustomEffect(state, PaintBrush, static (_, bounds) => bounds); + return activator.Activate(nested); + }, + static (_, bounds) => bounds); + } + + private static void PaintBrush(BrushPaintState state, CustomFilterEffectContext context) + { + for (int i = 0; i < context.Targets.Count; i++) + { + EffectTarget target = context.Targets[i]; + if (target.RenderTarget is null) + continue; + + Size size = target.Bounds.Size; + EffectTarget newTarget = context.CreateTarget(target.Bounds); + float w = newTarget.Scale.Value; + using var paint = new SKPaint(); + context.CreateBrushConstructor(new Rect(size), state.Brush, BlendMode.SrcIn, w) + .ConfigurePaint(paint); + using (ImmediateCanvas canvas = context.Open(newTarget)) + { + canvas.Clear(); + using (canvas.PushDeviceSpace()) + { + canvas.DrawRenderTarget(target.RenderTarget, default); + } + + canvas.Canvas.DrawRect(SKRect.Create(size.ToSKSize()), paint); + } + + target.Dispose(); + context.Targets[i] = newTarget; + } + } +} + +internal sealed record BrushPaintState(Brush.Resource? Brush); diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectScaleParityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectScaleParityTests.cs index 401486e343..5400695b95 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectScaleParityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/EffectScaleParityTests.cs @@ -15,14 +15,15 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; public class EffectScaleParityTests { private static readonly PixelSize Frame = new(200, 200); + private const double EffectNonVacuityTolerance = 0.02; public static IEnumerable Effects() { yield return new TestCaseData("InnerShadow", (Func)(() => { var e = new InnerShadow(); - e.Position.CurrentValue = new Point(20, 20); - e.Sigma.CurrentValue = new Size(10, 10); + e.Position.CurrentValue = new Point(6, 6); + e.Sigma.CurrentValue = new Size(6, 6); e.Color.CurrentValue = Colors.Black; return e; })); @@ -140,8 +141,8 @@ public static IEnumerable Effects() return e; })); yield return new TestCaseData("PartsSplit", (Func)(() => - // Contour readback: contours in device px must convert to logical via /w. - new PartsSplitEffect())); + // Contour readback: the per-target shrink makes every split contour visibly load-bearing. + MakePartsSplitEffect())); yield return new TestCaseData("LayerEffect-AfterSplit", (Func)(() => { // Split into 9 parts so LayerEffect flattens multiple targets at the working density. @@ -179,9 +180,10 @@ public static IEnumerable Effects() noise.Octaves.CurrentValue = 2; noise.Seed.CurrentValue = 1f; var e = new FlatShadow(); - e.Angle.CurrentValue = 0; - e.Length.CurrentValue = 40; + e.Angle.CurrentValue = 35; + e.Length.CurrentValue = 80; e.Brush.CurrentValue = noise; + e.ShadowOnly.CurrentValue = true; return e; })); yield return new TestCaseData("Mosaic-AbsoluteOrigin", (Func)(() => @@ -192,35 +194,84 @@ public static IEnumerable Effects() e.Origin.CurrentValue = new RelativePoint(50, 30, RelativeUnit.Absolute); return e; })); - yield return new TestCaseData("DisplacementMap-DrawableMap", (Func)(() => + foreach (TestCaseData testCase in DisplacementMapEffects()) + yield return testCase; + } + + public static IEnumerable DisplacementMapEffects() + { + yield return new TestCaseData( + "DisplacementMap-DrawableMap", + (Func)MakeDrawableDisplacementMapEffect); + } + + private static FilterEffect MakeDrawableDisplacementMapEffect() + { + // Non-gradient (DrawableBrush) displacement map: exercises the tile-brush density path. + var stripes = new LinearGradientBrush(); + stripes.StartPoint.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); + stripes.EndPoint.CurrentValue = new RelativePoint(24, 0, RelativeUnit.Absolute); + stripes.SpreadMethod.CurrentValue = GradientSpreadMethod.Repeat; + stripes.GradientStops.Add(new GradientStop(Colors.White, 0)); + stripes.GradientStops.Add(new GradientStop(Colors.White, 0.5f)); + stripes.GradientStops.Add(new GradientStop(Colors.Black, 0.5f)); + stripes.GradientStops.Add(new GradientStop(Colors.Black, 1)); + var mapContent = new RectShape(); + mapContent.AlignmentX.CurrentValue = AlignmentX.Center; + mapContent.AlignmentY.CurrentValue = AlignmentY.Center; + mapContent.Width.CurrentValue = 200; + mapContent.Height.CurrentValue = 200; + mapContent.Fill.CurrentValue = stripes; + var map = new DrawableBrush(); + map.Drawable.CurrentValue = mapContent; + map.Stretch.CurrentValue = Stretch.Fill; + var transform = new DisplacementMapTranslateTransform(); + transform.X.CurrentValue = 16; + transform.Y.CurrentValue = 0; + var effect = new DisplacementMapEffect(); + effect.DisplacementMap.CurrentValue = map; + effect.Transform.CurrentValue = transform; + effect.Channel.CurrentValue = DisplacementMapChannel.Luminance; + return effect; + } + + private static FilterEffect MakeNoDisplacementMapEffect() + { + var effect = new DisplacementMapEffect(); + effect.DisplacementMap.CurrentValue = null; + return effect; + } + + [TestCaseSource(nameof(DisplacementMapEffects))] + public void DisplacementMapEffect_ChangesIdentityOutput(string name, Func makeEffect) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => { - // Non-gradient (DrawableBrush) displacement map: exercises the tile-brush density path. - var stripes = new LinearGradientBrush(); - stripes.StartPoint.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); - stripes.EndPoint.CurrentValue = new RelativePoint(24, 0, RelativeUnit.Absolute); - stripes.SpreadMethod.CurrentValue = GradientSpreadMethod.Repeat; - stripes.GradientStops.Add(new GradientStop(Colors.White, 0)); - stripes.GradientStops.Add(new GradientStop(Colors.White, 0.5f)); - stripes.GradientStops.Add(new GradientStop(Colors.Black, 0.5f)); - stripes.GradientStops.Add(new GradientStop(Colors.Black, 1)); - var mapContent = new RectShape(); - mapContent.AlignmentX.CurrentValue = AlignmentX.Center; - mapContent.AlignmentY.CurrentValue = AlignmentY.Center; - mapContent.Width.CurrentValue = 200; - mapContent.Height.CurrentValue = 200; - mapContent.Fill.CurrentValue = stripes; - var map = new DrawableBrush(); - map.Drawable.CurrentValue = mapContent; - map.Stretch.CurrentValue = Stretch.Fill; - var transform = new DisplacementMapTranslateTransform(); - transform.X.CurrentValue = 16; - transform.Y.CurrentValue = 0; - var e = new DisplacementMapEffect(); - e.DisplacementMap.CurrentValue = map; - e.Transform.CurrentValue = transform; - e.Channel.CurrentValue = DisplacementMapChannel.Luminance; - return e; - })); + // A translate displacement over a flat fill is invisible under clamp sampling, so the source + // carries its own stripes; only a materialized map can move them. + using Bitmap mapped = GoldenImageHarness.RenderAtScale( + Make(makeEffect, MakeStripes(20)), + Frame, + 1f); + using Bitmap identity = GoldenImageHarness.RenderAtScale( + Make(MakeNoDisplacementMapEffect, MakeStripes(20)), + Frame, + 1f); + + Assert.That( + ImageMetrics.FirstNonFinite(("mapped", mapped), ("identity", identity)), + Is.Null, + $"{name}: the drawable-map vacuity comparison requires finite renders"); + double mae = ImageMetrics.MeanAbsoluteError(mapped, identity); + double ssim = ImageMetrics.Ssim(mapped, identity); + TestContext.WriteLine($"[{name}] mapped vs identity MAE={mae:F4} SSIM={ssim:F4}"); + Assert.That( + mae, + Is.GreaterThan(0.001), + $"{name}: the drawable displacement map did not change the identity render; " + + "transparent map materialization would make the scale-parity case vacuous"); + }); } // Border whose thickness is a fixed logical width (10 px): iScale and iResolution are both load-bearing. @@ -248,7 +299,56 @@ private static FilterEffect MakeSkslBorderEffect() return e; } - private static Drawable.Resource Make(Func makeEffect) + private static LinearGradientBrush MakeStripes(float period) + { + var stripes = new LinearGradientBrush(); + stripes.StartPoint.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); + stripes.EndPoint.CurrentValue = new RelativePoint(period, 0, RelativeUnit.Absolute); + stripes.SpreadMethod.CurrentValue = GradientSpreadMethod.Repeat; + stripes.GradientStops.Add(new GradientStop(Colors.White, 0)); + stripes.GradientStops.Add(new GradientStop(Colors.White, 0.5f)); + stripes.GradientStops.Add(new GradientStop(Colors.Gray, 0.5f)); + stripes.GradientStops.Add(new GradientStop(Colors.Gray, 1)); + return stripes; + } + + private static LinearGradientBrush MakeAlphaStripes(float period) + { + var stripes = new LinearGradientBrush(); + stripes.StartPoint.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); + stripes.EndPoint.CurrentValue = new RelativePoint(period, 0, RelativeUnit.Absolute); + stripes.SpreadMethod.CurrentValue = GradientSpreadMethod.Repeat; + stripes.GradientStops.Add(new GradientStop(Colors.White, 0)); + stripes.GradientStops.Add(new GradientStop(Colors.White, 0.45f)); + stripes.GradientStops.Add(new GradientStop(Colors.Transparent, 0.45f)); + stripes.GradientStops.Add(new GradientStop(Colors.Transparent, 1)); + return stripes; + } + + private static FilterEffect MakePartsSplitEffect() + { + var group = new FilterEffectGroup(); + group.Children.Add(new PartsSplitEffect()); + group.Children.Add(MakePartsSplitDifferentiator()); + return group; + } + + private static FilterEffect MakePartsSplitControlEffect() + => MakePartsSplitDifferentiator(); + + private static TransformEffect MakePartsSplitDifferentiator() + { + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = 85; + scale.ScaleY.CurrentValue = 85; + var effect = new TransformEffect(); + effect.Transform.CurrentValue = scale; + return effect; + } + + private static Drawable.Resource Make(Func makeEffect) => Make(makeEffect, Brushes.White); + + private static Drawable.Resource Make(Func makeEffect, Brush fill) { var shape = new RectShape(); shape.AlignmentX.CurrentValue = AlignmentX.Center; @@ -256,7 +356,7 @@ private static Drawable.Resource Make(Func makeEffect) shape.TransformOrigin.CurrentValue = RelativePoint.Center; shape.Width.CurrentValue = 140; shape.Height.CurrentValue = 90; - shape.Fill.CurrentValue = Brushes.White; + shape.Fill.CurrentValue = fill; var rotation = new RotationTransform(); rotation.Rotation.CurrentValue = 21f; shape.Transform.CurrentValue = rotation; @@ -268,7 +368,6 @@ private static Drawable.Resource Make(Func makeEffect) public void Effect_Supersampled_KeepsLogicalAppearance(string name, Func makeEffect) { VulkanTestEnvironment.EnsureAvailable(); - // Non-finite pixels (SwiftShader blur artifact vs real scale defect) are distinguished by // determinism: same location on every attempt = real defect (FAIL); moving = artifact (INCONCLUSIVE). // Reference and scaled renders are scanned separately so a broken reference does not mask a scaled defect. @@ -278,12 +377,20 @@ public void Effect_Supersampled_KeepsLogicalAppearance(string name, Func a.Ref is null); // Compare location only (strip value), not the exact NaN/Inf bit pattern. - bool scaledAllNonFinite = attempts.All(a => a.Scaled is not null); - string[] scaledLocations = attempts - .Where(a => a.Scaled is not null) - .Select(a => a.Scaled!.Split(" = ", StringSplitOptions.None)[0]) - .ToArray(); - bool scaledDeterministic = scaledAllNonFinite && scaledLocations.Distinct().Count() == 1; + bool scaledDeterministic = HasStableNonFiniteLocation(attempts.Select(static item => item.Scaled)); + bool refDeterministic = HasStableNonFiniteLocation(attempts.Select(static item => item.Ref)); + if (refDeterministic) + { + Assert.Fail($"{name}: the 1x reference produced a non-finite pixel at the same location on all " + + $"{maxAttempts} attempts [{attempts[0].Ref}]; deterministic invalid reference output is a " + + "renderer defect, not a run-varying software-Vulkan artifact."); + } // Deterministic non-finite in scaled render with finite reference = real scale defect. if (scaledDeterministic && refEverFinite) @@ -337,18 +456,90 @@ public void Effect_Supersampled_KeepsLogicalAppearance(string name, Func $"ref={a.Ref ?? "ok"}|scaled={a.Scaled ?? "ok"}")) + $"] — {detail}; parity is verified on a hardware GPU."); } } + /// + /// Skips a run-varying non-finite result as a software-Vulkan artifact, unless the job declared a real + /// GPU. + /// + /// + /// Non-finite pixels are exactly what the scratch-memory initialization in the Vulkan backend exists to + /// prevent, so on a job that set BEUTL_REQUIRE_GPU - where the SwiftShader explanation does not + /// apply - swallowing them would hide the regression this suite is here to catch. + /// + private static void InconclusiveOnSoftwareVulkan(string message) + { + string? require = Environment.GetEnvironmentVariable("BEUTL_REQUIRE_GPU"); + if (!string.IsNullOrEmpty(require) + && !string.Equals(require, "0", StringComparison.Ordinal) + && !string.Equals(require, "false", StringComparison.OrdinalIgnoreCase)) + { + Assert.Fail(message + + " BEUTL_REQUIRE_GPU is set, so this is a hardware run: a non-finite pixel is a defect, not " + + "a software-Vulkan artifact."); + } + + Assert.Ignore(message); + } + + private static bool HasFiniteVisibleContent(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int i = 3; i < pixels.Length; i += 4) + { + float a = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + if (float.IsFinite(a) && a > 0f) + { + return true; + } + } + return false; + } + + private static Drawable.Resource MakeForCase(string name, Func makeEffect) + { + if (name.StartsWith("InnerShadow", StringComparison.Ordinal)) + return MakeInnerShadowFixture(makeEffect); + + Brush fill = name.StartsWith("PartsSplit", StringComparison.Ordinal) + ? MakeAlphaStripes(60) + : name.StartsWith("Mosaic-AbsoluteOrigin", StringComparison.Ordinal) + || name.StartsWith("DisplacementMap-DrawableMap", StringComparison.Ordinal) + ? MakeStripes(20) + : Brushes.White; + return Make(makeEffect, fill); + } + + private static Drawable.Resource MakeInnerShadowFixture(Func makeEffect) + { + // Reuse the finite axis-aligned InnerShadow survey input so this fixture isolates density scaling; + // transformed-edge rendering is a separate renderer contract. + var shape = new EllipseShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.TransformOrigin.CurrentValue = RelativePoint.Center; + shape.Width.CurrentValue = 150; + shape.Height.CurrentValue = 110; + shape.Fill.CurrentValue = Brushes.White; + shape.FilterEffect.CurrentValue = makeEffect(); + return shape.ToResource(CompositionContext.Default); + } + + private static Func ControlForCase(string name) + => name.StartsWith("PartsSplit", StringComparison.Ordinal) + ? MakePartsSplitControlEffect + : static () => new FilterEffectGroup(); + public static IEnumerable RepresentativeEffectsWithScales() { float[] scales = [1.5f, 3f]; (string Name, Func Make)[] effects = [ - ("InnerShadow", () => { var e = new InnerShadow(); e.Position.CurrentValue = new Point(20, 20); e.Sigma.CurrentValue = new Size(10, 10); e.Color.CurrentValue = Colors.Black; return e; }), + ("InnerShadow", () => { var e = new InnerShadow(); e.Position.CurrentValue = new Point(6, 6); e.Sigma.CurrentValue = new Size(6, 6); e.Color.CurrentValue = Colors.Black; return e; }), ("StrokeEffect-Offset", () => { var pen = new Pen(); pen.Thickness.CurrentValue = 14; pen.Brush.CurrentValue = Brushes.Red; var e = new StrokeEffect(); e.Pen.CurrentValue = pen; e.Offset.CurrentValue = new Point(20, 12); return e; }), ("Mosaic-AbsoluteOrigin", () => { var e = new MosaicEffect(); e.TileSize.CurrentValue = new Size(16, 16); e.Origin.CurrentValue = new RelativePoint(50, 30, RelativeUnit.Absolute); return e; }), ("FlatShadow", () => { var e = new FlatShadow(); e.Angle.CurrentValue = 0; e.Length.CurrentValue = 40; e.Brush.CurrentValue = Brushes.Red; return e; }), @@ -369,12 +560,20 @@ public void Effect_Supersampled_AtVariousScales(string label, float scale, Func< { for (int attempt = 1; ; attempt++) { - using Bitmap r1 = GoldenImageHarness.RenderAtScale(Make(makeEffect), Frame, 1f); - using Bitmap hi = GoldenImageHarness.RenderAtScale(Make(makeEffect), Frame, scale); + using Bitmap r1 = GoldenImageHarness.RenderAtScale(MakeForCase(label, makeEffect), Frame, 1f); + using Bitmap hi = GoldenImageHarness.RenderAtScale(MakeForCase(label, makeEffect), Frame, scale); using Bitmap delivered = GoldenImageHarness.MitchellResampleTo(hi, Frame); + using Bitmap control = GoldenImageHarness.RenderAtScale( + MakeForCase(label, ControlForCase(label)), + Frame, + 1f); string? refNonFinite = ImageMetrics.FirstNonFinite(("1:1", r1)); string? scaledNonFinite = ImageMetrics.FirstNonFinite(($"{scale}x", hi), ($"{scale}x-delivered", delivered)); + Assert.That( + ImageMetrics.FirstNonFinite(("effect-free-control", control)), + Is.Null, + $"{label}: the effect-free non-vacuity control must be finite"); if (refNonFinite is not null || scaledNonFinite is not null) { TestContext.WriteLine( @@ -386,6 +585,16 @@ public void Effect_Supersampled_AtVariousScales(string label, float scale, Func< } attempts.Clear(); + Assert.That( + HasFiniteVisibleContent(r1), + $"{label}: the 1x reference must contain visible output."); + double effectDelta = Math.Max( + ImageMetrics.MeanAbsoluteError(r1, control), + 1 - ImageMetrics.Ssim(r1, control)); + Assert.That( + effectDelta, + Is.GreaterThan(EffectNonVacuityTolerance), + $"{label}: the fixture is indistinguishable from its effect-free control."); double ssim = ImageMetrics.Ssim(r1, delivered); TestContext.WriteLine($"[{label}] {scale}x-delivered vs 1:1 SSIM={ssim:F4}"); Assert.That(ssim, Is.GreaterThan(0.95), @@ -397,12 +606,13 @@ public void Effect_Supersampled_AtVariousScales(string label, float scale, Func< if (attempts.Count == maxAttempts) { bool refEverFinite = attempts.Any(a => a.Ref is null); - bool scaledAllNonFinite = attempts.All(a => a.Scaled is not null); - string[] scaledLocations = attempts - .Where(a => a.Scaled is not null) - .Select(a => a.Scaled!.Split(" = ", StringSplitOptions.None)[0]) - .ToArray(); - bool scaledDeterministic = scaledAllNonFinite && scaledLocations.Distinct().Count() == 1; + bool scaledDeterministic = HasStableNonFiniteLocation(attempts.Select(static item => item.Scaled)); + bool refDeterministic = HasStableNonFiniteLocation(attempts.Select(static item => item.Ref)); + if (refDeterministic) + { + Assert.Fail($"{label}: deterministic non-finite output in the 1x reference " + + $"[{attempts[0].Ref}] is a renderer defect."); + } if (scaledDeterministic && refEverFinite) { @@ -410,7 +620,7 @@ public void Effect_Supersampled_AtVariousScales(string label, float scale, Func< + "while the 1:1 reference was finite — a scale-parity defect."); } - Assert.Ignore($"{label}: persistent non-finite pixels across {maxAttempts} attempts [" + InconclusiveOnSoftwareVulkan($"{label}: persistent non-finite pixels across {maxAttempts} attempts [" + string.Join("; ", attempts.Select(a => $"ref={a.Ref ?? "ok"}|scaled={a.Scaled ?? "ok"}")) + "] — software-Vulkan artifact; parity is verified on a hardware GPU."); } @@ -428,10 +638,70 @@ public void SkslBorderScript_CompilesAndApplies() using Bitmap bordered = GoldenImageHarness.RenderAtScale(Make(MakeSkslBorderEffect), Frame, 1f); // An empty FilterEffectGroup is an identity effect: same fixture, no shader. using Bitmap plain = GoldenImageHarness.RenderAtScale(Make(() => new FilterEffectGroup()), Frame, 1f); + (int redBorderPixels, int preservedInteriorPixels) = CountSkslBorderPixels(bordered); double ssim = ImageMetrics.Ssim(bordered, plain); TestContext.WriteLine($"[SKSLScript guard] bordered vs plain SSIM={ssim:F4}"); - Assert.That(ssim, Is.LessThan(0.99), - "SKSL border did not change the render — the script likely failed to compile/apply, which would make the SKSLScript-Border parity case vacuous"); + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("bordered", bordered), ("plain", plain)), + Is.Null, + "the SKSL vacuity guard requires finite output"); + Assert.That(redBorderPixels, Is.GreaterThan(0), + "the SKSL output must contain the expected opaque red border"); + Assert.That(preservedInteriorPixels, Is.GreaterThan(0), + "the SKSL output must preserve visible interior source content"); + Assert.That(ssim, Is.LessThan(0.99), + "SKSL border did not change the render — the script likely failed to compile/apply, which would make the SKSLScript-Border parity case vacuous"); + }); + }); + } + + private static (int RedBorderPixels, int PreservedInteriorPixels) CountSkslBorderPixels(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int redBorderPixels = 0; + int preservedInteriorPixels = 0; + for (int index = 0; index < pixels.Length; index += 4) + { + float red = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + float green = (float)BitConverter.UInt16BitsToHalf(pixels[index + 1]); + float blue = (float)BitConverter.UInt16BitsToHalf(pixels[index + 2]); + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index + 3]); + if (alpha > 0.9f && red > 0.9f && green < 0.1f && blue < 0.1f) + redBorderPixels++; + if (alpha > 0.9f && red > 0.8f && green > 0.8f && blue > 0.8f) + preservedInteriorPixels++; + } + return (redBorderPixels, preservedInteriorPixels); + } + + [Test] + public void NonFiniteClassifier_DistinguishesStableFromRunVaryingLocations() + { + Assert.Multiple(() => + { + Assert.That(HasStableNonFiniteLocation([ + "1:1 @(4,5) = NaN", + "1:1 @(4,5) = Infinity", + "1:1 @(4,5) = NaN", + ]), Is.True); + Assert.That(HasStableNonFiniteLocation([ + "1:1 @(4,5) = NaN", + "1:1 @(6,5) = NaN", + "1:1 @(4,5) = NaN", + ]), Is.False); + Assert.That(HasStableNonFiniteLocation(["1:1 @(4,5) = NaN", null, null]), Is.False); }); } + + private static bool HasStableNonFiniteLocation(IEnumerable samples) + { + string?[] values = samples.ToArray(); + return values.Length > 0 + && values.All(static value => value is not null) + && values.Select(static value => value!.Split(" = ", StringSplitOptions.None)[0]) + .Distinct(StringComparer.Ordinal) + .Count() == 1; + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/FrameClearCoverageTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/FrameClearCoverageTests.cs new file mode 100644 index 0000000000..754ac0be3b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/FrameClearCoverageTests.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public class FrameClearCoverageTests +{ + [TestCase(128, 73, 0.25f, 32, 19)] + [TestCase(73, 128, 0.25f, 19, 32)] + [TestCase(1, 1, 0.25f, 1, 1)] + public void EmptyFrame_ClearCoversEveryDevicePixel( + int width, + int height, + float outputScale, + int expectedDeviceWidth, + int expectedDeviceHeight) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget target = RenderTarget.Create(expectedDeviceWidth, expectedDeviceHeight) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using (var prefill = new ImmediateCanvas(target)) + { + prefill.Clear(Colors.Magenta); + } + + using var renderer = new Renderer( + width, + height, + RenderIntent.Preview, + outputScale, + float.PositiveInfinity, + target); + var frame = new CompositionFrame( + ImmutableArray.Empty, + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(width, height), + null); + + renderer.Render(frame); + using Bitmap snapshot = renderer.Snapshot(); + ReadOnlySpan channels = snapshot.GetPixelSpan(); + int nonZeroChannels = 0; + int firstNonZeroChannel = -1; + for (int i = 0; i < channels.Length; i++) + { + if (channels[i] == 0) + continue; + + nonZeroChannels++; + if (firstNonZeroChannel < 0) + firstNonZeroChannel = i; + } + + Assert.Multiple(() => + { + Assert.That(renderer.DeviceSize, Is.EqualTo(new PixelSize(expectedDeviceWidth, expectedDeviceHeight))); + Assert.That( + nonZeroChannels, + Is.Zero, + $"the root clear left channel {firstNonZeroChannel} undefined in the outward-rounded device surface"); + }); + }); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GoldenImageHarness.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GoldenImageHarness.cs index 24a963abc4..d30279f962 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GoldenImageHarness.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GoldenImageHarness.cs @@ -12,8 +12,15 @@ internal static class GoldenImageHarness /// /// Renders into a ceil(logicalSize × scale) device surface with one /// root CreateScale(scale) CTM, exactly as . scale == 1 is byte-identical. + /// When is provided, only that logical region is requested and committed. + /// The surface is black by default; can select another control background. /// - public static Bitmap RenderAtScale(Drawable.Resource resource, PixelSize logicalSize, float scale) + public static Bitmap RenderAtScale( + Drawable.Resource resource, + PixelSize logicalSize, + float scale, + Rect? requestedRegion = null, + Color? clearColor = null) { int dw = (int)MathF.Ceiling(logicalSize.Width * scale); int dh = (int)MathF.Ceiling(logicalSize.Height * scale); @@ -21,23 +28,29 @@ public static Bitmap RenderAtScale(Drawable.Resource resource, PixelSize logical ?? throw new InvalidOperationException("RenderTarget.Create returned null."); // The canvas bakes CreateScale(scale) at construction. using var canvas = new ImmediateCanvas(target, scale, logicalSize: logicalSize.ToSize(1)); - canvas.Clear(Colors.Black); + canvas.Clear(clearColor ?? Colors.Black); // Layout uses logical frame size; canvas base CTM maps to device surface. using var node = new DrawableRenderNode(resource); using (var ctx = new GraphicsContext2D(node, logicalSize.ToSize(1), scale)) { - resource.GetOriginal().Render(ctx, resource); + resource.GetOriginal()!.Render(ctx, resource); } - var processor = new RenderNodeProcessor(node, useRenderCache: false, outputScale: scale); - RenderNodeOperation[] ops = processor.PullToRoot(); - - foreach (RenderNodeOperation op in ops) - { - op.Render(canvas); - op.Dispose(); - } + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, logicalSize.ToSize(1)), + RequestedRegion = requestedRegion, + OutputScale = scale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); return target.Snapshot(); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs new file mode 100644 index 0000000000..5d0e85348f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs @@ -0,0 +1,479 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class GpuPassFusionFeature003RegressionTests +{ + private static readonly PixelSize s_frame = new(192, 128); + + public static IEnumerable DensityCases() + { + yield return DensityCase( + "all-vector uses the output floor", + [EffectiveScale.Unbounded, EffectiveScale.Unbounded], + outputScale: 0.5f, + maxWorkingScale: float.PositiveInfinity, + expected: 0.5f); + yield return DensityCase( + "sub-output proxy is floored for delivery", + [EffectiveScale.At(0.5f)], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity, + expected: 1); + yield return DensityCase( + "matching proxy stays cheap in preview", + [EffectiveScale.At(0.5f)], + outputScale: 0.5f, + maxWorkingScale: 1, + expected: 0.5f); + yield return DensityCase( + "dense bitmap supply is not capped by output", + [EffectiveScale.At(4), EffectiveScale.Unbounded], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity, + expected: 4); + yield return DensityCase( + "densest concrete input wins", + [EffectiveScale.At(0.5f), EffectiveScale.At(2), EffectiveScale.Unbounded], + outputScale: 1, + maxWorkingScale: float.PositiveInfinity, + expected: 2); + yield return DensityCase( + "preview maximum working scale caps dense supply", + [EffectiveScale.At(8), EffectiveScale.At(1)], + outputScale: 1, + maxWorkingScale: 2, + expected: 2); + yield return DensityCase( + "supersample output is a floor", + [EffectiveScale.At(0.5f), EffectiveScale.Unbounded], + outputScale: 2, + maxWorkingScale: float.PositiveInfinity, + expected: 2); + } + + [TestCaseSource(nameof(DensityCases))] + public void Feature003SupplyDrivenDensityMatrix_RemainsStable( + EffectiveScale[] inputs, + float outputScale, + float maxWorkingScale, + float expected) + { + float resolved = RenderScaleUtilities.ResolveWorkingScale( + inputs, + outputScale, + maxWorkingScale); + + Assert.That(resolved, Is.EqualTo(expected).Within(1e-6)); + } + + [Test] + public void Feature003TransformDensityAndDimensionRounding_RemainStable() + { + EffectiveScale anisotropic = TransformRenderNode.RescaleDensity( + EffectiveScale.At(2), + Matrix.CreateScale(0.5f, 0.25f)); + EffectiveScale rotation = TransformRenderNode.RescaleDensity( + EffectiveScale.At(2), + Matrix.CreateRotation(MathF.PI / 3)); + (int width, int height) scaleOne = CustomFilterEffectContext.DeviceBufferSize( + new Rect(0, 0, 100.7f, 50.2f), + 1); + (int width, int height) dense = CustomFilterEffectContext.DeviceBufferSize( + new Rect(0, 0, 100.3f, 50.1f), + 2); + + Assert.Multiple(() => + { + Assert.That(anisotropic, Is.EqualTo(EffectiveScale.At(8)), + "the most detailed transformed axis defines available density"); + Assert.That(rotation, Is.EqualTo(EffectiveScale.At(2)), + "a pure rotation must not invent or discard density"); + Assert.That(scaleOne, Is.EqualTo((100, 50)), + "the scale-one legacy allocation truncates fractional dimensions"); + Assert.That(dense, Is.EqualTo((201, 101)), + "non-unit device allocation uses ceil after scaling"); + }); + } + + [Test] + public void Feature003BufferClampAndCacheIdentity_IncludeResolvedDensity() + { + var bounds = new Rect(0, 0, 10_000.25f, 20); + float clamped = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 4); + RenderOutputCacheIdentity atOne = CreateCacheIdentity(bounds, density: 1); + RenderOutputCacheIdentity atTwo = CreateCacheIdentity(bounds, density: 2); + RenderOutputCacheIdentity atTwoAgain = CreateCacheIdentity(bounds, density: 2); + + Assert.Multiple(() => + { + Assert.That(clamped, Is.LessThan(4)); + Assert.That(Math.Ceiling(bounds.Width * clamped), + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(atOne, Is.Not.EqualTo(atTwo), + "a density change must invalidate a materialized output cache entry"); + Assert.That(atTwo, Is.EqualTo(atTwoAgain), + "equal resolved density and runtime components must be cache-stable"); + }); + } + + [TestCase(RepresentativeContent.Vector)] + [TestCase(RepresentativeContent.Bitmap)] + [TestCase(RepresentativeContent.Text)] + [Category("GpuPassFusionGpu")] + public void Feature003ScaleOneGoldenAnchor_IsByteStableAndNonVacuous( + RepresentativeContent content) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap first = RenderRepresentative(content, 1); + using Bitmap second = RenderRepresentative(content, 1); + + GoldenImageHarness.AssertByteIdentical(first, second); + Assert.That(SumAbsoluteRgb(first), Is.GreaterThan(1), + "a byte-stable opaque-black result is not a useful golden anchor"); + }); + } + + [TestCase(float.PositiveInfinity)] + [TestCase(float.NegativeInfinity)] + public void Feature003ScaleOneGoldenEnergy_RejectsInfiniteRgb(float value) + { + using var bitmap = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + bitmap.GetPixelSpan()[1] = BitConverter.HalfToUInt16Bits((Half)value); + + AssertionException? exception = Assert.Throws(() => SumAbsoluteRgb(bitmap)); + Assert.That(exception!.Message, Does.Contain("pixel 0 channel 1").And.Contain("non-finite")); + } + + [TestCase(float.PositiveInfinity)] + [TestCase(float.NegativeInfinity)] + public void Feature003ScaleOneGoldenEnergy_RejectsInfiniteAlpha(float value) + { + using var bitmap = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + bitmap.GetPixelSpan()[3] = BitConverter.HalfToUInt16Bits((Half)value); + + AssertionException? exception = Assert.Throws(() => SumAbsoluteRgb(bitmap)); + Assert.That(exception!.Message, Does.Contain("pixel 0 channel 3").And.Contain("non-finite")); + } + + [Test] + public void Feature003ScaleOneGoldenEnergy_RejectsTransparentNonzeroRgb() + { + using var bitmap = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + bitmap.GetPixelSpan()[1] = BitConverter.HalfToUInt16Bits((Half)0.25f); + + AssertionException? exception = Assert.Throws(() => SumAbsoluteRgb(bitmap)); + Assert.That( + exception!.Message, + Does.Contain("transparent pixel 0").And.Contain("non-zero premultiplied RGB")); + } + + [TestCase(RepresentativeContent.Vector)] + [TestCase(RepresentativeContent.Bitmap)] + [TestCase(RepresentativeContent.Text)] + [Category("GpuPassFusionGpu")] + public void Feature003HalfPreviewAndDoubleSupersample_PreserveLogicalContentAndDeviceSizes( + RepresentativeContent content) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap full = RenderRepresentative(content, 1); + using Bitmap half = RenderRepresentative(content, 0.5f); + using Bitmap halfDelivered = GoldenImageHarness.MitchellResampleTo(half, s_frame); + using Bitmap supersampled = RenderRepresentative(content, 2); + using Bitmap doubleDelivered = GoldenImageHarness.MitchellResampleTo(supersampled, s_frame); + + double halfSsim = ImageMetrics.Ssim(full, halfDelivered); + double halfMae = ImageMetrics.MeanAbsoluteError(full, halfDelivered); + double doubleSsim = ImageMetrics.Ssim(full, doubleDelivered); + double doubleMae = ImageMetrics.MeanAbsoluteError(full, doubleDelivered); + TestContext.WriteLine( + $"{content}: half SSIM={halfSsim:F4} MAE={halfMae:F4}; " + + $"double SSIM={doubleSsim:F4} MAE={doubleMae:F4}"); + GoldenLimits limits = GoldenLimits.For(content); + + Assert.Multiple(() => + { + Assert.That(half.Width, Is.EqualTo(96)); + Assert.That(half.Height, Is.EqualTo(64)); + Assert.That(supersampled.Width, Is.EqualTo(384)); + Assert.That(supersampled.Height, Is.EqualTo(256)); + Assert.That(halfSsim, Is.GreaterThanOrEqualTo(limits.HalfSsim), + "reduced preview lost the representative logical structure"); + Assert.That(halfMae, Is.LessThanOrEqualTo(limits.HalfMae)); + Assert.That(doubleSsim, Is.GreaterThanOrEqualTo(limits.DoubleSsim), + "supersampled output diverged from the scale-one logical anchor"); + Assert.That(doubleMae, Is.LessThanOrEqualTo(limits.DoubleMae)); + }); + }); + } + + [Test] + public void CharacterizedPreviewAllocationFailure_DropsTheEffectOutputWithoutThrowing() + { + using EffectTargets targets = CreateNonAllocatableTargets(); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 2); + + Assert.That(() => activator.Flush(), Throws.Nothing); + Assert.That(activator.CurrentTargets, Is.Empty, + "preview keeps the current-main drop-on-allocation-failure outcome"); + } + + [Test] + public void CharacterizedDeliveryAllocationFailure_FailsFastInsteadOfDroppingContent() + { + using EffectTargets targets = CreateNonAllocatableTargets(); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: float.PositiveInfinity); + + InvalidOperationException? exception = Assert.Throws(() => activator.Flush()); + Assert.That(exception!.Message, Does.StartWith("Effect flush buffer allocation failed")); + } + + private static TestCaseData DensityCase( + string name, + EffectiveScale[] inputs, + float outputScale, + float maxWorkingScale, + float expected) + => new TestCaseData(inputs, outputScale, maxWorkingScale, expected).SetName(name); + + private static RenderOutputCacheIdentity CreateCacheIdentity(Rect bounds, float density) + { + var fragment = new RenderFragmentReference( + RenderFragmentKind.MaterializedInput, + bounds, + EffectiveScale.At(density), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs: null, + payload: null, + hitTest: null); + var requestId = new RenderRequestId(1); + return new RenderOutputCacheIdentity( + candidateKey: "feature-003-density", + RenderFragmentOutputIdentity.Create(fragment, requestId), + bounds, + RequiredRegion.Region(bounds), + density, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity("device", "context")); + } + + private static Bitmap RenderRepresentative(RepresentativeContent content, float scale) + { + if (content == RepresentativeContent.Bitmap) + return RenderMaterializedBitmap(scale); + + using Drawable.Resource resource = content switch + { + RepresentativeContent.Vector => CreateVector(), + RepresentativeContent.Text => CreateText(), + _ => throw new ArgumentOutOfRangeException(nameof(content), content, null), + }; + return GoldenImageHarness.RenderAtScale(resource, s_frame, scale); + } + + private static Drawable.Resource CreateVector() + { + var shape = new EllipseShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = 126; + shape.Height.CurrentValue = 78; + shape.Fill.CurrentValue = Brushes.OrangeRed; + return shape.ToResource(CompositionContext.Default); + } + + private static Bitmap RenderMaterializedBitmap(float scale) + { + var sourceSize = new PixelSize(96, 64); + using RenderTarget source = RenderTarget.Create(sourceSize.Width, sourceSize.Height) + ?? throw new InvalidOperationException("Could not allocate bitmap source."); + using (var sourceCanvas = new ImmediateCanvas(source, 1, logicalSize: sourceSize.ToSize(1))) + { + sourceCanvas.Clear(Colors.CornflowerBlue); + sourceCanvas.DrawRectangle(new Rect(12, 10, 72, 44), Brushes.Resource.OrangeRed, null); + } + + int width = (int)MathF.Ceiling(s_frame.Width * scale); + int height = (int)MathF.Ceiling(s_frame.Height * scale); + using RenderTarget destination = RenderTarget.Create(width, height) + ?? throw new InvalidOperationException("Could not allocate bitmap destination."); + using (var destinationCanvas = new ImmediateCanvas( + destination, + scale, + logicalSize: s_frame.ToSize(1))) + { + destinationCanvas.Clear(Colors.Black); + using var node = new MaterializedBitmapNode(source); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame.ToSize(1)), + OutputScale = scale, + MaxWorkingScale = float.PositiveInfinity, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(destinationCanvas); + } + + return destination.Snapshot(); + } + + private static Drawable.Resource CreateText() + { + Typeface typeface = TypefaceProvider.Typeface(); + var text = new TextBlock(); + text.AlignmentX.CurrentValue = AlignmentX.Center; + text.AlignmentY.CurrentValue = AlignmentY.Center; + text.FontFamily.CurrentValue = typeface.FontFamily; + text.FontStyle.CurrentValue = typeface.Style; + text.FontWeight.CurrentValue = typeface.Weight; + text.Size.CurrentValue = 34; + text.Fill.CurrentValue = Brushes.White; + text.Text.CurrentValue = "Density"; + return text.ToResource(CompositionContext.Default); + } + + private static EffectTargets CreateNonAllocatableTargets() + { + using RenderTarget source = RenderTarget.CreateNull(1, 1); + return new EffectTargets + { + new EffectTarget( + source, + new Rect(10, 8, -1, 92), + EffectiveScale.At(1)), + }; + } + + private static double SumAbsoluteRgb(Bitmap bitmap) + { + double result = 0; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int offset = 0; offset < pixels.Length; offset += 4) + { + int pixelIndex = offset / 4; + float red = DecodeFiniteChannel(pixels, offset, pixelIndex, channel: 0); + float green = DecodeFiniteChannel(pixels, offset, pixelIndex, channel: 1); + float blue = DecodeFiniteChannel(pixels, offset, pixelIndex, channel: 2); + float alpha = DecodeFiniteChannel(pixels, offset, pixelIndex, channel: 3); + if (alpha == 0 && (red != 0 || green != 0 || blue != 0)) + { + throw new AssertionException( + $"Scale-one golden transparent pixel {pixelIndex} contains non-zero premultiplied RGB: " + + $"({red}, {green}, {blue})."); + } + + result += Math.Abs(red) + Math.Abs(green) + Math.Abs(blue); + } + + return result; + } + + private static float DecodeFiniteChannel( + ReadOnlySpan pixels, + int offset, + int pixelIndex, + int channel) + { + float value = (float)BitConverter.UInt16BitsToHalf(pixels[offset + channel]); + if (!float.IsFinite(value)) + { + throw new AssertionException( + $"Scale-one golden pixel {pixelIndex} channel {channel} is non-finite: {value}."); + } + + return value; + } + + private sealed class MaterializedBitmapNode(RenderTarget source) : RenderNode + { + private static readonly Rect s_bounds = new(48, 32, 96, 64); + + public override void Process(RenderNodeContext context) + { + RenderResource target = context.Borrow(source); + context.Publish(context.MaterializedInput(MaterializedInputDescription.FromRenderTarget( + target, + s_bounds, + EffectiveScale.At(1), + PixelRect.FromRect(s_bounds, 1), + default, + RenderHitTestContract.OutputBounds))); + } + } + + private readonly record struct GoldenLimits( + double HalfSsim, + double HalfMae, + double DoubleSsim, + double DoubleMae) + { + public static GoldenLimits For(RepresentativeContent content) + => content == RepresentativeContent.Text + // Full-hinted glyph rasterization is intentionally resolution-sensitive under feature 003. + ? new GoldenLimits(0.75, 0.04, 0.92, 0.025) + : new GoldenLimits(0.95, 0.035, 0.98, 0.02); + } + + public enum RepresentativeContent + { + Vector, + Bitmap, + Text, + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs new file mode 100644 index 0000000000..a484a280fc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionSameProcessParityHarness.cs @@ -0,0 +1,171 @@ +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Baseline; + +internal static class GpuPassFusionSameProcessParityHarness +{ + public const double MinimumSsim = 0.99; + public const double MinimumWindowedSsim = 0.95; + public const double MaximumLinearRgbMae = 0.02; + public const double MaximumAlphaMae = 0.02; + public const double MaximumAaEdgeChannelError = 0.02; + public const double MaximumAaEdgeMeanError = 0.02; + + public static GpuPassFusionParityResult AssertParity( + Func render, + PixelRect? aaEdgeRegion = null) + { + ArgumentNullException.ThrowIfNull(render); + + using Bitmap disabled = render(FusionMode.Disabled) + ?? throw new InvalidOperationException("The fusion-disabled render returned null."); + using Bitmap enabled = render(FusionMode.Enabled) + ?? throw new InvalidOperationException("The fusion-enabled render returned null."); + if (ReferenceEquals(disabled, enabled)) + throw new InvalidOperationException("Fusion-disabled and enabled runs must return independently owned images."); + + string? nonFinite = ImageMetrics.FirstNonFinite( + ("fusion-disabled", disabled), + ("fusion-enabled", enabled)); + Assert.That(nonFinite, Is.Null, "Same-process parity inputs must contain only finite RGBA16F values."); + + GpuPassFusionParityMetrics fullImage = Measure(disabled, enabled); + GpuPassFusionAaParityMetrics? aaEdge = null; + if (aaEdgeRegion is { } region) + { + ValidateCrop(region, disabled.Width, disabled.Height); + using Bitmap disabledCrop = Crop(disabled, region); + using Bitmap enabledCrop = Crop(enabled, region); + GpuPassFusionParityMetrics cropMetrics = Measure(disabledCrop, enabledCrop); + double edgeMeanError = ImageMetrics.EdgeBandMeanAbsoluteError(disabledCrop, enabledCrop); + RgbaMaximumError edgeMaximum = + ImageMetrics.EdgeBandMaximumAbsoluteErrorPerChannel(disabledCrop, enabledCrop); + aaEdge = new GpuPassFusionAaParityMetrics(cropMetrics, edgeMeanError, edgeMaximum); + } + + using (Assert.EnterMultipleScope()) + { + AssertMetrics(fullImage, "full image"); + if (aaEdge is { } edge) + { + AssertMetrics(edge.Crop, "AA edge crop"); + Assert.That( + edge.EdgeBandMeanError, + Is.LessThanOrEqualTo(MaximumAaEdgeMeanError), + "AA edge-band mean error exceeded the fixed normal-CI bound."); + Assert.That( + edge.MaximumError.Red, + Is.LessThanOrEqualTo(MaximumAaEdgeChannelError), + "AA edge red-channel maximum error exceeded the fixed normal-CI bound."); + Assert.That( + edge.MaximumError.Green, + Is.LessThanOrEqualTo(MaximumAaEdgeChannelError), + "AA edge green-channel maximum error exceeded the fixed normal-CI bound."); + Assert.That( + edge.MaximumError.Blue, + Is.LessThanOrEqualTo(MaximumAaEdgeChannelError), + "AA edge blue-channel maximum error exceeded the fixed normal-CI bound."); + Assert.That( + edge.MaximumError.Alpha, + Is.LessThanOrEqualTo(MaximumAaEdgeChannelError), + "AA edge alpha-channel maximum error exceeded the fixed normal-CI bound."); + } + } + + return new GpuPassFusionParityResult(fullImage, aaEdge); + } + + private static GpuPassFusionParityMetrics Measure(Bitmap disabled, Bitmap enabled) + { + return new GpuPassFusionParityMetrics( + ImageMetrics.Ssim(disabled, enabled), + ImageMetrics.WindowedSsim(disabled, enabled, 16), + ImageMetrics.MeanAbsoluteError(disabled, enabled), + ImageMetrics.AlphaMeanAbsoluteError(disabled, enabled)); + } + + private static void AssertMetrics(GpuPassFusionParityMetrics metrics, string region) + { + Assert.That(metrics.Ssim, Is.GreaterThanOrEqualTo(MinimumSsim), $"{region} SSIM was too low."); + Assert.That( + metrics.WindowedSsim, + Is.GreaterThanOrEqualTo(MinimumWindowedSsim), + $"{region} minimum-window SSIM was too low."); + Assert.That( + metrics.LinearRgbMae, + Is.LessThanOrEqualTo(MaximumLinearRgbMae), + $"{region} linear RGB MAE was too high."); + Assert.That( + metrics.AlphaMae, + Is.LessThanOrEqualTo(MaximumAlphaMae), + $"{region} alpha MAE was too high."); + } + + private static Bitmap Crop(Bitmap source, PixelRect region) + { + var result = new Bitmap( + region.Width, + region.Height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + for (int y = 0; y < region.Height; y++) + { + ReadOnlySpan sourceRow = source.GetRow(region.Y + y); + Span destinationRow = result.GetRow(y); + sourceRow.Slice(region.X * 4, region.Width * 4).CopyTo(destinationRow); + } + + return result; + } + + private static void ValidateCrop(PixelRect region, int width, int height) + { + if (region.X < 0 + || region.Y < 0 + || region.Width <= 0 + || region.Height <= 0 + || region.Right > width + || region.Bottom > height) + { + throw new ArgumentOutOfRangeException( + nameof(region), + region, + $"AA edge region must be a non-empty subset of the {width}x{height} output."); + } + } +} + +internal readonly record struct GpuPassFusionPixelRegion(int X, int Y, int Width, int Height) +{ + public int Right => checked(X + Width); + + public int Bottom => checked(Y + Height); + + public void ValidateInside(int imageWidth, int imageHeight, string description) + { + if (X < 0 || Y < 0 || Width <= 0 || Height <= 0 || Right > imageWidth || Bottom > imageHeight) + { + throw new InvalidDataException( + $"{description} ({X}, {Y}, {Width}, {Height}) is not a non-empty subset of " + + $"{imageWidth}x{imageHeight}."); + } + } +} + +internal readonly record struct GpuPassFusionParityMetrics( + double Ssim, + double WindowedSsim, + double LinearRgbMae, + double AlphaMae); + +internal readonly record struct GpuPassFusionAaParityMetrics( + GpuPassFusionParityMetrics Crop, + double EdgeBandMeanError, + RgbaMaximumError MaximumError); + +internal readonly record struct GpuPassFusionParityResult( + GpuPassFusionParityMetrics FullImage, + GpuPassFusionAaParityMetrics? AaEdge); diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs new file mode 100644 index 0000000000..6079742fdc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs @@ -0,0 +1,1100 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.TextFormatting; +using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering.Baseline; +using Beutl.UnitTests.Engine.Graphics.Rendering.Fusion; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class GpuPassFusionScaleRegionTests +{ + private static readonly Rect s_domain = new(0, 0, 96, 64); + + // The stage runs in half precision and stores RGBA16F, so the CPU oracle and the GPU may disagree by a few + // half-precision ulps (~4.9e-4 relative near 1.0). Vulkan measures 0.000000; this keeps room for a backend + // that rounds differently while staying two orders of magnitude below the effect size. + private const double MaximumSelfAlphaProductDeviation = 0.005; + + // Per premultiplied channel the stage changes a pixel by a - a^2, which peaks at 0.25 for a = 0.5. An + // antialiased stroke edge sweeps through that coverage, so the observed maximum is 0.2406. This floor sits + // well under it and far above the 0 that a stage which never ran would produce. + private const double MinimumSelfAlphaProductChange = 0.10; + + [Test] + public void MixedVectorBitmapAndTextInputs_ResolveDensestSupplyThenMaximumWorkingScale() + { + using var root = new MixedDensityNode(); + using var owner = new RenderRequestOwner(); + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Bounds, + targetDomain: s_domain, + outputScale: 1, + maxWorkingScale: 2.5f, + owner: owner)); + using (request) + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + RenderFragmentReference combined = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(static reference => reference.Kind == RenderFragmentKind.OpaqueCombine); + + Assert.Multiple(() => + { + Assert.That(combined.Inputs, Has.Length.EqualTo(4)); + Assert.That(combined.Inputs.Count(static input => input.EffectiveScale.IsUnbounded), + Is.EqualTo(2), "vector geometry and text must remain re-rasterizable"); + Assert.That( + combined.Inputs + .Where(static input => !input.EffectiveScale.IsUnbounded) + .Select(static input => input.EffectiveScale.Value), + Is.EquivalentTo(new[] { 0.5f, 2.5f }), + "each concrete source is capped as it is recorded before the combined boundary resolves"); + Assert.That(combined.EffectiveScale.IsUnbounded, Is.False); + Assert.That(combined.EffectiveScale.Value, Is.EqualTo(2.5f), + "the dense bitmap supply wins before the request ceiling is applied"); + }); + } + } + + [Test] + public void CompleteBoundsClamp_PrecedesRequestedRegionAndNeverExceedsTheAxisBudget() + { + var completeBounds = new Rect(123.25f, 9, 10_000.25f, 12); + var requestedRegion = new Rect(9_000, 9, 8, 8); + using RenderNode root = ScaleRecordingTestHelper.Source(EffectiveScale.At(4), completeBounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = completeBounds, + RequestedRegion = requestedRegion, + OutputScale = 1, + MaxWorkingScale = float.PositiveInfinity, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + float expected = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(completeBounds, 4); + + Assert.Multiple(() => + { + Assert.That(expected, Is.LessThan(4), "the fixture must exercise the 16,384-axis clamp"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expected)); + Assert.That( + Math.Ceiling(completeBounds.Width * measurement.EffectiveScale.Value), + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That( + measurement.EffectiveScale.Value, + Is.LessThan(RenderScaleUtilities.ClampWorkingScaleToBufferBudget(requestedRegion, 4)), + "a late ROI crop must not raise a density already clamped against complete bounds"); + }); + } + + [Test] + public void RequestedRegionOutsideRootOutputExtent_RasterizesWithoutABitmap() + { + var outputBounds = new Rect(10, 20, 30, 40); + using RenderNode root = ScaleRecordingTestHelper.Source(EffectiveScale.At(1), outputBounds); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 200, 300), + RequestedRegion = new Rect(100, 200, 25, 20), + OutputScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(Rect.Empty)); + Assert.That(rasterization.Bitmap, Is.Null); + }); + } + + [Test] + public void ShiftedGuardedCallback_LateBindsRequiredAndDeviceRegionsWithoutRunningDuringMeasure() + { + var declaredBounds = new Rect(10.25f, 20.5f, 8, 6); + var requestedRegion = new Rect(12.25f, 22.5f, 3, 2); + using var node = new ShiftedCallbackNode(declaredBounds); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 40, 40), + RequestedRegion = requestedRegion, + OutputScale = 1, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + Assert.That(node.CallbackCount, Is.Zero, "metadata resolution must not execute guarded work"); + Assert.That(measurement.OutputBounds, Is.EqualTo(declaredBounds)); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + PixelRect expectedDeviceBounds = PixelRect.FromRect(requestedRegion, 2); + var expectedOrigin = new Point(expectedDeviceBounds.X / 2f, expectedDeviceBounds.Y / 2f); + + Assert.Multiple(() => + { + Assert.That(node.CallbackCount, Is.EqualTo(1)); + Assert.That(node.ObservedOutputBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.ObservedRequiredRegion, Is.EqualTo(requestedRegion)); + Assert.That(node.ObservedSessionDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(node.ObservedCanvasBounds, Is.EqualTo(requestedRegion)); + Assert.That(node.ObservedCanvasDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(node.ObservedCanvasOrigin, Is.EqualTo(expectedOrigin)); + Assert.That(node.ObservedDensity, Is.EqualTo(2)); + Assert.That(rasterization.Bounds, Is.EqualTo(PixelRect.FromRect(requestedRegion, 1).ToRect(1))); + Assert.That(rasterization.Bitmap, Is.Not.Null); + }); + } + + [Test] + public void TypedGeometryShaderAndTargetScope_ReceiveTheCroppedRuntimeRequirement() + { + var declaredBounds = new Rect(10, 20, 12, 8); + var requestedRegion = new Rect(12, 22, 4, 3); + using var node = new TypedValueRoiNode(declaredBounds); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 40, 40), + RequestedRegion = requestedRegion, + OutputScale = 2, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + Assert.That(node.TotalCallbackCount, Is.Zero); + Assert.That(measurement.OutputBounds, Is.EqualTo(declaredBounds)); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + PixelRect expectedDeviceBounds = PixelRect.FromRect(requestedRegion, 2); + + Assert.Multiple(() => + { + Assert.That(node.GeometryOutputBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.GeometryRequiredRegion, Is.EqualTo(requestedRegion)); + Assert.That(node.GeometryDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(node.GeometryCanvasBounds, Is.EqualTo(requestedRegion)); + Assert.That(node.ShaderInputBounds, Is.EqualTo(requestedRegion)); + Assert.That(node.ShaderOutputBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.ShaderRequiredRegion, Is.EqualTo(requestedRegion)); + Assert.That(node.ShaderDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(node.TargetScopeOutputBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.TargetScopeRequiredRegion, Is.EqualTo(requestedRegion)); + Assert.That(node.TargetScopeCanvasBounds, Is.EqualTo(requestedRegion)); + Assert.That(node.TargetScopeCanvasDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(rasterization.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(rasterization.Bitmap, Is.Not.Null); + }); + } + + [Test] + public void TargetReadback_ExpandsThePrecedingSnapshotAndCanvasToItsDeclaredReadRegion() + { + var declaredBounds = new Rect(10, 20, 12, 8); + var requestedRegion = new Rect(12, 22, 4, 3); + using var node = new TargetReadbackRoiNode(declaredBounds); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 40, 40), + RequestedRegion = requestedRegion, + OutputScale = 2, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + Assert.That(node.CallbackCount, Is.Zero); + Assert.That(measurement.OutputBounds, Is.EqualTo(declaredBounds)); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + PixelRect expectedDeviceBounds = PixelRect.FromRect(declaredBounds, 2); + + Assert.Multiple(() => + { + Assert.That(node.CallbackCount, Is.EqualTo(1)); + Assert.That(node.SourceRequiredRegion, Is.EqualTo(declaredBounds)); + Assert.That(node.AffectedBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.RequiredRegion, Is.EqualTo(declaredBounds)); + Assert.That(node.CanvasBounds, Is.EqualTo(declaredBounds)); + Assert.That(node.CanvasDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(node.SnapshotSize, Is.EqualTo(expectedDeviceBounds.Size)); + Assert.That(node.SnapshotOpaquePixelCount, + Is.EqualTo(expectedDeviceBounds.Width * expectedDeviceBounds.Height)); + Assert.That(node.SnapshotCornerAlpha, Is.GreaterThan(0.99f), + "the target-read apron must contain the preceding target pixels, not only an expanded allocation"); + Assert.That(rasterization.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(rasterization.Bitmap, Is.Not.Null); + }); + } + + [Test] + public void RenderWithTargetReadApron_CommitsOnlyTheRequestedRegionToTheBorrowedTarget() + { + var declaredBounds = new Rect(10, 20, 12, 8); + var requestedRegion = new Rect(12, 22, 4, 3); + using var node = new TargetReadbackRoiNode(declaredBounds); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(80, 80); + using var canvas = new ImmediateCanvas( + target, + density: 2, + logicalSize: new Size(40, 40)); + canvas.Clear(Colors.Red); + + renderer.Render(canvas); + using Bitmap completed = target.Snapshot(); + (float outsideRed, float outsideBlue) = RedBlueAt(completed, 21, 41); + (float insideRed, float insideBlue) = RedBlueAt(completed, 26, 46); + + Assert.Multiple(() => + { + Assert.That(node.SourceRequiredRegion, Is.EqualTo(declaredBounds)); + Assert.That(node.SnapshotCornerBlue, Is.GreaterThan(node.SnapshotCornerRed), + "the readback apron must observe the preceding blue graph output, not the borrowed red target"); + Assert.That(outsideRed, Is.GreaterThan(outsideBlue), + "pixels outside the final commit crop must retain the borrowed target content"); + Assert.That(insideBlue, Is.GreaterThan(insideRed), + "pixels inside the final commit crop must receive the completed graph output"); + }); + } + + [Test] + public void ExpandedExecution_RejectsAnActiveCanvasSaveLayerBeforeCopyingStalePixels() + { + var declaredBounds = new Rect(10, 20, 12, 8); + var requestedRegion = new Rect(12, 22, 4, 3); + using var node = new TargetReadbackRoiNode(declaredBounds); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(80, 80); + using var canvas = new ImmediateCanvas( + target, + density: 2, + logicalSize: new Size(40, 40)); + using var opacity = canvas.PushOpacity(0.5f); + + InvalidOperationException? failure = Assert.Throws( + () => renderer.Render(canvas)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("SaveLayer scope is active")); + Assert.That(node.CallbackCount, Is.Zero, + "the request must fail before it copies or executes against a stale root-surface snapshot"); + }); + } + + [Test] + public void ExpandedClipAndBlur_MatchesNonExpandedExecutionPixels() + { + var clip = new Rect(20, 12, 24, 28); + using Bitmap nonExpanded = RenderClipBlur(requestedRegion: null, clip); + using Bitmap expanded = RenderClipBlur(clip, clip); + + Assert.That( + expanded.GetPixelSpan().ToArray(), + Is.EqualTo(nonExpanded.GetPixelSpan().ToArray()), + "Expanded target-reading execution must copy the full destination before clipping subsequent draws."); + } + + [Test] + public void ExpandedExecution_UsesTheBoundingBoxOfANonRectangularDestinationClip() + { + var requestedRegion = new Rect(0, 0, 32, 24); + using var node = new ClipBlurTargetNode(s_domain); + using var renderer = CreateClipBlurRenderer(node, requestedRegion); + using var target = new CpuRenderTarget((int)s_domain.Width, (int)s_domain.Height); + using var canvas = new ImmediateCanvas(target, logicalSize: s_domain.Size); + canvas.Clear(Colors.OrangeRed); + var ellipse = new EllipseGeometry + { + Width = { CurrentValue = requestedRegion.Width }, + Height = { CurrentValue = requestedRegion.Height }, + }; + using Geometry.Resource geometry = ellipse.ToResource(CompositionContext.Default); + using var clip = canvas.PushClip(geometry); + + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + using Bitmap completed = target.Snapshot(); + (float insideRed, float insideBlue) = RedBlueAt(completed, 16, 12); + (float outsideRed, float outsideBlue) = RedBlueAt(completed, 48, 32); + + Assert.Multiple(() => + { + Assert.That(node.CallbackCount, Is.EqualTo(1)); + Assert.That(insideBlue, Is.GreaterThan(insideRed), + "the expanded target-reading effect must execute inside the ellipse"); + Assert.That(outsideRed, Is.GreaterThan(outsideBlue), + "pixels outside the non-rectangular clip's bounding box must remain untouched"); + }); + } + + [Test] + public void ExpandedExecution_RestoresTheClipBeforeReturningThePooledTarget() + { + var requestedRegion = new Rect(20, 12, 24, 28); + using var node = new ClipBlurTargetNode(s_domain); + using var renderer = CreateClipBlurRenderer(node, requestedRegion); + using var target = new CpuRenderTarget((int)s_domain.Width, (int)s_domain.Height); + using var canvas = new ImmediateCanvas(target, logicalSize: s_domain.Size); + canvas.Clear(Colors.OrangeRed); + + using (canvas.PushClip(requestedRegion)) + { + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + } + + Assert.That(() => renderer.Render(canvas), Throws.Nothing, + "the second expanded render must be able to reuse the first render's exact-size pooled target"); + Assert.That(node.CallbackCount, Is.EqualTo(2)); + } + + private static Bitmap RenderClipBlur(Rect? requestedRegion, Rect clip) + { + using var node = new ClipBlurTargetNode(s_domain); + using var renderer = CreateClipBlurRenderer(node, requestedRegion); + using var target = new CpuRenderTarget((int)s_domain.Width, (int)s_domain.Height); + using var canvas = new ImmediateCanvas(target, logicalSize: s_domain.Size); + canvas.Clear(Colors.OrangeRed); + using (canvas.PushClip(clip)) + { + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + private static RenderNodeRenderer CreateClipBlurRenderer(RenderNode node, Rect? requestedRegion) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + [TestCase(CaptureContainer.FiniteLayer)] + [TestCase(CaptureContainer.TargetLayerScope)] + public void DeclaredTargetCaptureResamplesWhileBackdropLateBindsToDenserScopeAndCacheIdentity( + CaptureContainer container) + { + using var node = new CaptureDensityNode(container); + + RenderFragmentOutputIdentity declaredAtOne = RecordCaptureIdentity(node, outputScale: 1, builtIn: false); + RenderFragmentOutputIdentity declaredAtTwo = RecordCaptureIdentity(node, outputScale: 2, builtIn: false); + RenderFragmentOutputIdentity backdropIdentity = RecordCaptureIdentity(node, outputScale: 1, builtIn: true); + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + OutputScale = 1, + MaxWorkingScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(node.PublicCaptureInputDensity, Is.EqualTo(1), + "a public capture uses its no-input, output-derived declared density"); + Assert.That(node.BuiltInCaptureInputDensity, Is.EqualTo(2), + "the engine backdrop must bind to the actual denser owning target"); + Assert.That(node.CommittedBackdropDensity, Is.EqualTo(2)); + Assert.That(declaredAtOne, Is.Not.EqualTo(declaredAtTwo), + "declared capture density must participate in the output-cache identity"); + Assert.That(declaredAtOne, Is.Not.EqualTo(backdropIdentity), + "a late-bound backdrop is request-local and cannot alias a public declared-density capture"); + Assert.That(rasterization.Bitmap, Is.Not.Null); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void AntialiasedThinStroke_CurrentPixelBoundaryPreservesEdgeCoverage() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var node = new ThinStrokeShaderNode(); + GpuPassFusionParityResult result = GpuPassFusionSameProcessParityHarness.AssertParity( + mode => RenderThinStroke(node, mode), + new PixelRect(18, 16, 156, 76)); + + Assert.Multiple(() => + { + Assert.That(result.AaEdge, Is.Not.Null); + Assert.That(result.AaEdge!.Value.EdgeBandMeanError, + Is.LessThanOrEqualTo(GpuPassFusionSameProcessParityHarness.MaximumAaEdgeMeanError)); + Assert.That(result.AaEdge.Value.MaximumError.Maximum, + Is.LessThanOrEqualTo(GpuPassFusionSameProcessParityHarness.MaximumAaEdgeChannelError)); + using Bitmap shaderFree = RenderThinStroke(node.SourceWithoutShader, FusionMode.Disabled); + using Bitmap shaderApplied = RenderThinStroke(node, FusionMode.Disabled); + Assert.That( + ImageMetrics.FirstNonFinite(("shader-free", shaderFree), ("shader-applied", shaderApplied)), + Is.Null, + "thin-stroke control and shader-applied outputs must be finite RGBA16F."); + + // A CurrentPixel stage runs after coverage resolution, so the applied image must equal the + // control image put through color * color.a. Running the stage before antialiasing instead + // would yield (c * c.a) * coverage rather than (c * coverage) * (c.a * coverage), which differs + // on exactly the partially covered edge pixels this test exists to guard. + using Bitmap expected = MultiplyByOwnAlpha(shaderFree); + RgbaMaximumError deviation = + ImageMetrics.MaximumAbsoluteErrorPerChannel(expected, shaderApplied); + RgbaMaximumError change = + ImageMetrics.MaximumAbsoluteErrorPerChannel(shaderFree, shaderApplied); + TestContext.WriteLine( + $"[color*alpha] oracle deviation={deviation.Maximum:F6} change={change.Maximum:F6} " + + $"changeAlpha={change.Alpha:F6}"); + + // A whole-region mean cannot serve here: the stroke covers ~2% of the probe region, so even a + // total change of every covered pixel stays under the harness parity ceiling. + Assert.That( + deviation.Maximum, + Is.LessThanOrEqualTo(MaximumSelfAlphaProductDeviation), + "the CurrentPixel stage must apply color * color.a to coverage-resolved pixels."); + Assert.That( + change.Maximum, + Is.GreaterThanOrEqualTo(MinimumSelfAlphaProductChange), + "the color*alpha CurrentPixel shader must actually change the thin-stroke image."); + }); + }); + } + + private static RenderFragmentOutputIdentity RecordCaptureIdentity( + RenderNode root, + float outputScale, + bool builtIn) + { + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_domain, + outputScale: outputScale, + maxWorkingScale: 2, + owner: owner)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + RenderFragmentReference reference = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(item => item.Kind == (builtIn + ? RenderFragmentKind.BuiltInBackdropCapture + : RenderFragmentKind.TargetCapture)); + return RenderFragmentOutputIdentity.Create(reference, request.Id); + } + + /// CPU oracle for the color * color.a CurrentPixel stage over premultiplied RGBA16F. + private static Bitmap MultiplyByOwnAlpha(Bitmap source) + { + var result = new Bitmap( + source.Width, + source.Height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + for (int y = 0; y < source.Height; y++) + { + ReadOnlySpan sourceRow = source.GetRow(y); + Span resultRow = result.GetRow(y); + for (int x = 0; x < source.Width; x++) + { + int offset = x * 4; + var alpha = (float)BitConverter.UInt16BitsToHalf(sourceRow[offset + 3]); + for (int channel = 0; channel < 4; channel++) + { + var value = (float)BitConverter.UInt16BitsToHalf(sourceRow[offset + channel]); + resultRow[offset + channel] = BitConverter.HalfToUInt16Bits((Half)(value * alpha)); + } + } + } + + return result; + } + + private static Bitmap RenderThinStroke(RenderNode node, FusionMode mode) + { + const int width = 192; + const int height = 108; + using RenderTarget target = RenderTarget.Create(width, height) + ?? throw new InvalidOperationException("Could not allocate the thin-stroke target."); + using (var canvas = new ImmediateCanvas(target, 1, 2, new Size(width, height))) + { + canvas.Clear(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, width, height), + OutputScale = 1, + MaxWorkingScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = mode, + }, + }); + renderer.Render(canvas); + } + + Bitmap result = target.Snapshot(); + Assert.That( + FusionBoundaryExecutionTestSupport.CountFractionalAlphaPixels(result), + Is.GreaterThan(0), + $"The {mode} thin-stroke fixture must contain antialiased coverage before parity is evaluated."); + return result; + } + + public enum CaptureContainer + { + FiniteLayer, + TargetLayerScope, + } + + private sealed class MixedDensityNode : RenderNode + { + private readonly RenderNode _lowDensity = ScaleRecordingTestHelper.Source(EffectiveScale.At(0.5f)); + private readonly RenderNode _highDensity = ScaleRecordingTestHelper.Source(EffectiveScale.At(4)); + private readonly RectangleRenderNode _vector = new( + new Rect(4, 6, 60, 38), + Brushes.Resource.White, + null); + private readonly FormattedText _text; + private readonly TextRenderNode _textNode; + + public MixedDensityNode() + { + Typeface typeface = TypefaceProvider.Typeface(); + _text = new FormattedText + { + Font = typeface.FontFamily, + Style = typeface.Style, + Weight = typeface.Weight, + Size = 18, + Text = "density", + }; + _textNode = new TextRenderNode(_text, Brushes.Resource.White, null); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle[] inputs = + [ + context.RecordNode(_lowDensity, [])[0], + context.RecordNode(_highDensity, [])[0], + context.RecordNode(_vector, [])[0], + context.RecordNode(_textNode, [])[0], + ]; + context.Publish(context.OpaqueCombine(inputs, CreateCombineDescription(typeof(MixedDensityNode)))); + } + + protected override void OnDispose(bool disposing) + { + _lowDensity.Dispose(); + _highDensity.Dispose(); + _vector.Dispose(); + _textNode.Dispose(); + _text.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class ShiftedCallbackNode(Rect bounds) : RenderNode + { + public int CallbackCount { get; private set; } + + public Rect ObservedOutputBounds { get; private set; } + + public Rect ObservedRequiredRegion { get; private set; } + + public PixelRect ObservedSessionDeviceBounds { get; private set; } + + public Rect ObservedCanvasBounds { get; private set; } + + public PixelRect ObservedCanvasDeviceBounds { get; private set; } + + public Point ObservedCanvasOrigin { get; private set; } + + public float ObservedDensity { get; private set; } + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + execute: session => + { + CallbackCount++; + ObservedOutputBounds = session.OutputBounds; + ObservedRequiredRegion = session.RequiredRegion; + ObservedSessionDeviceBounds = session.DeviceBounds; + ObservedDensity = session.WorkingScale; + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + ObservedCanvasBounds = output.Canvas.LogicalBounds; + ObservedCanvasDeviceBounds = output.Canvas.DeviceBounds; + ObservedCanvasOrigin = output.Canvas.LogicalOrigin; + output.Canvas.Use(static _ => { }); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Source(bounds), + hitTest: RenderHitTestContract.OutputBounds, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Custom(static _ => 2)); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class TypedValueRoiNode(Rect bounds) : RenderNode + { + public int TotalCallbackCount { get; private set; } + + public Rect GeometryOutputBounds { get; private set; } + + public Rect GeometryRequiredRegion { get; private set; } + + public PixelRect GeometryDeviceBounds { get; private set; } + + public Rect GeometryCanvasBounds { get; private set; } + + public Rect ShaderInputBounds { get; private set; } + + public Rect ShaderOutputBounds { get; private set; } + + public Rect ShaderRequiredRegion { get; private set; } + + public PixelRect ShaderDeviceBounds { get; private set; } + + public Rect TargetScopeOutputBounds { get; private set; } + + public Rect TargetScopeRequiredRegion { get; private set; } + + public Rect TargetScopeCanvasBounds { get; private set; } + + public PixelRect TargetScopeCanvasDeviceBounds { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(CreateRoiSourceDescription( + bounds, + typeof(TypedValueRoiNode))); + GeometryDescription geometry = GeometryDescription.CreateRequestLocal( + session => + { + TotalCallbackCount++; + GeometryOutputBounds = session.OutputBounds; + GeometryRequiredRegion = session.RequiredRegion; + GeometryDeviceBounds = session.DeviceBounds; + GeometryCanvasBounds = session.Canvas.LogicalBounds; + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput); + RenderFragmentHandle current = context.Geometry(source, geometry); + ShaderDescription shader = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 1f, + (writer, value, execution) => + { + TotalCallbackCount++; + ShaderInputBounds = execution.InputBounds; + ShaderOutputBounds = execution.OutputBounds; + ShaderRequiredRegion = execution.RequiredRegion; + ShaderDeviceBounds = execution.DeviceBounds; + writer.Set(value); + })); + current = context.Shader(current, shader); + TargetScopeDescription scope = TargetScopeDescription.CreateRequestLocal( + session => + { + TotalCallbackCount++; + TargetScopeOutputBounds = session.OutputBounds; + TargetScopeRequiredRegion = session.RequiredRegion; + TargetScopeCanvasBounds = session.Canvas.LogicalBounds; + TargetScopeCanvasDeviceBounds = session.Canvas.DeviceBounds; + session.Canvas.Use(_ => session.ReplayInput()); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent); + context.Publish(context.TargetScope(current, scope)); + } + } + + private sealed class TargetReadbackRoiNode(Rect bounds) : RenderNode + { + public int CallbackCount { get; private set; } + + public Rect SourceRequiredRegion { get; private set; } + + public Rect AffectedBounds { get; private set; } + + public Rect RequiredRegion { get; private set; } + + public Rect CanvasBounds { get; private set; } + + public PixelRect CanvasDeviceBounds { get; private set; } + + public PixelSize SnapshotSize { get; private set; } + + public float SnapshotCornerAlpha { get; private set; } + + public float SnapshotCornerRed { get; private set; } + + public float SnapshotCornerBlue { get; private set; } + + public int SnapshotOpaquePixelCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + SourceRequiredRegion = session.RequiredRegion; + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(static canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 2))); + context.Publish(source); + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => + { + CallbackCount++; + AffectedBounds = session.AffectedBounds; + RequiredRegion = session.RequiredRegion; + CanvasBounds = session.Canvas.LogicalBounds; + CanvasDeviceBounds = session.Canvas.DeviceBounds; + session.UseSnapshot(bitmap => + { + SnapshotSize = new PixelSize(bitmap.Width, bitmap.Height); + Span firstRow = bitmap.GetRow(0); + SnapshotCornerRed = (float)BitConverter.UInt16BitsToHalf(firstRow[0]); + SnapshotCornerBlue = (float)BitConverter.UInt16BitsToHalf(firstRow[2]); + SnapshotCornerAlpha = (float)BitConverter.UInt16BitsToHalf(firstRow[3]); + for (int y = 0; y < bitmap.Height; y++) + { + Span row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]) > 0.99f) + SnapshotOpaquePixelCount++; + } + } + }); + }, + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + } + } + + private sealed class ClipBlurTargetNode(Rect bounds) : RenderNode + { + public int CallbackCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(static canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 1)))); + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => + { + CallbackCount++; + session.UseSnapshot(bitmap => session.Canvas.Use(canvas => + { + using SKImage image = SKImage.FromBitmap(bitmap.SKBitmap); + using SKImageFilter blur = SKImageFilter.CreateBlur(3, 3); + using var paint = new SKPaint + { + BlendMode = SKBlendMode.Src, + ImageFilter = blur, + }; + canvas.Canvas.DrawImage(image, 0, 0, paint); + })); + }, + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.OutputBounds, + TargetAccess.Readback))); + } + } + + private static (float Red, float Blue) RedBlueAt(Bitmap bitmap, int x, int y) + { + Span row = bitmap.GetRow(y); + int offset = x * 4; + return ( + (float)BitConverter.UInt16BitsToHalf(row[offset]), + (float)BitConverter.UInt16BitsToHalf(row[offset + 2])); + } + + private static OpaqueRenderDescription CreateRoiSourceDescription(Rect bounds, object key) + => OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(static canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 2)); + + private sealed class CaptureDensityNode(CaptureContainer container) + : RenderNode, IBuiltInBackdropCaptureSink + { + private readonly RenderNode _localSource = ScaleRecordingTestHelper.Source(EffectiveScale.Unbounded, s_domain); + private readonly RenderNode _denseSource = ScaleRecordingTestHelper.Source(EffectiveScale.At(2), s_domain); + private Bitmap? _committedBackdrop; + + public float PublicCaptureInputDensity { get; private set; } + + public float BuiltInCaptureInputDensity { get; private set; } + + public float CommittedBackdropDensity { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.RecordNode(_localSource, [])[0]; + RenderFragmentHandle publicCapture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(s_domain), + s_domain, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle publicReplay = context.ContributeValues( + context.OpaqueMap(publicCapture, CreateCaptureObserver(CaptureKind.Public))); + + RenderFragmentHandle builtInCapture = context.BuiltInBackdropCapture(this); + RenderFragmentHandle builtInReplay = context.ContributeValues( + context.OpaqueMap(builtInCapture, CreateCaptureObserver(CaptureKind.BuiltIn))); + RenderFragmentHandle[] localInputs = + [source, publicCapture, publicReplay, builtInCapture, builtInReplay]; + + RenderFragmentHandle layer = container switch + { + CaptureContainer.FiniteLayer => context.Layer(localInputs, s_domain), + CaptureContainer.TargetLayerScope => context.Layer( + [context.TargetLayerScope(localInputs, TargetRegion.Region(s_domain))], + s_domain), + _ => throw new ArgumentOutOfRangeException(), + }; + RenderFragmentHandle dense = context.RecordNode(_denseSource, [])[0]; + context.Publish(context.OpaqueCombine( + [layer, dense], + CreateCombineDescription(typeof(CaptureDensityNode)))); + } + + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + { + _committedBackdrop?.Dispose(); + _committedBackdrop = bitmap; + CommittedBackdropDensity = density; + } + + protected override void OnDispose(bool disposing) + { + _committedBackdrop?.Dispose(); + _committedBackdrop = null; + _localSource.Dispose(); + _denseSource.Dispose(); + base.OnDispose(disposing); + } + + private OpaqueRenderDescription CreateCaptureObserver(CaptureKind kind) + { + return OpaqueRenderDescription.CreateRequestLocal( + execute: session => + { + float density = session.Inputs.Single().EffectiveScale.Value; + if (kind == CaptureKind.Public) + PublicCaptureInputDensity = density; + else + BuiltInCaptureInputDensity = density; + + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs[0].Draw); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.PreserveInputSupply); + } + + private enum CaptureKind + { + Public, + BuiltIn, + } + + private readonly record struct CaptureObserverIdentity(CaptureKind Kind); + } + + private sealed class ThinStrokeShaderNode : RenderNode + { + private static readonly ShaderDescription s_colorTimesAlpha = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color * color.a; }"); + + private readonly PathGeometry _geometry; + private readonly Geometry.Resource _geometryResource; + private readonly Pen.Resource _penResource; + private readonly GeometryRenderNode _source; + + public ThinStrokeShaderNode() + { + _geometry = PathGeometry.Parse( + "M18.25,83.6 C51.75,12.4 119.5,96.2 174.4,22.75"); + _geometryResource = _geometry.ToResource(CompositionContext.Default); + var pen = new Pen + { + Thickness = { CurrentValue = 1.25f }, + Brush = { CurrentValue = new SolidColorBrush(new Color(205, 225, 85, 30)) }, + StrokeCap = { CurrentValue = StrokeCap.Round }, + StrokeJoin = { CurrentValue = StrokeJoin.Round }, + }; + _penResource = pen.ToResource(CompositionContext.Default); + _source = new GeometryRenderNode(_geometryResource, null, _penResource); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.RecordNode(_source, [])[0]; + context.Publish(context.Shader(source, s_colorTimesAlpha)); + } + + internal RenderNode SourceWithoutShader => _source; + + protected override void OnDispose(bool disposing) + { + _source.Dispose(); + _penResource.Dispose(); + _geometryResource.Dispose(); + base.OnDispose(disposing); + } + } + + private static OpaqueRenderDescription CreateCombineDescription(object structuralKey) + { + return OpaqueRenderDescription.CreateRequestLocal( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => + { + foreach (RenderExecutionInput input in session.Inputs) + input.Draw(canvas); + }); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.FullInputs( + static inputs => inputs.Aggregate(Rect.Empty, static (result, input) => result.Union(input))), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MaterializeAtWorkingScale); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetrics.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetrics.cs index 6ad512b685..b75aac0b52 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetrics.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetrics.cs @@ -2,9 +2,25 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; -// Image-quality metrics over RgbaF16 (linear) bitmaps. Pure CPU math. +internal readonly record struct RgbaMaximumError(double Red, double Green, double Blue, double Alpha) +{ + public double Maximum => Math.Max(Math.Max(Red, Green), Math.Max(Blue, Alpha)); + + public double this[int channel] => channel switch + { + 0 => Red, + 1 => Green, + 2 => Blue, + 3 => Alpha, + _ => throw new ArgumentOutOfRangeException(nameof(channel)), + }; +} + +// Image-quality metrics over linear-premultiplied RgbaF16 bitmaps. Pure CPU math. internal static class ImageMetrics { + private const int ChannelCount = 4; + // ITU-R BT.709 luma weights, applied in linear light. private const float LumaR = 0.2126f; private const float LumaG = 0.7152f; @@ -14,131 +30,275 @@ internal static class ImageMetrics public static double MeanAbsoluteError(Bitmap a, Bitmap b) { EnsureComparable(a, b); - ReadOnlySpan pa = a.GetPixelSpan(); - ReadOnlySpan pb = b.GetPixelSpan(); - int pixels = a.Width * a.Height; double sum = 0; - for (int i = 0; i < pixels; i++) + for (int y = 0; y < a.Height; y++) { - int o = i * 4; - for (int c = 0; c < 3; c++) + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) { - float va = HalfBitsToFloat(pa[o + c]); - float vb = HalfBitsToFloat(pb[o + c]); - sum += Math.Abs(va - vb); + int offset = x * ChannelCount; + for (int channel = 0; channel < 3; channel++) + { + sum += Math.Abs( + HalfBitsToFloat(rowA[offset + channel]) - HalfBitsToFloat(rowB[offset + channel])); + } } } - return sum / (pixels * 3); + return sum / ((double)a.Width * a.Height * 3); } - /// Global SSIM over linear luminance. Returns 1.0 for identical inputs. - public static double Ssim(Bitmap a, Bitmap b) + /// + /// Mean absolute error over alpha. RGB MAE and luminance SSIM intentionally do not include this channel. + /// + public static double AlphaMeanAbsoluteError(Bitmap a, Bitmap b) { EnsureComparable(a, b); - ReadOnlySpan pa = a.GetPixelSpan(); - ReadOnlySpan pb = b.GetPixelSpan(); - int pixels = a.Width * a.Height; - double meanA = 0, meanB = 0; - for (int i = 0; i < pixels; i++) + double sum = 0; + for (int y = 0; y < a.Height; y++) { - meanA += Luma(pa, i); - meanB += Luma(pb, i); + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) + { + int alpha = x * ChannelCount + 3; + sum += Math.Abs(HalfBitsToFloat(rowA[alpha]) - HalfBitsToFloat(rowB[alpha])); + } } - meanA /= pixels; - meanB /= pixels; + return sum / ((double)a.Width * a.Height); + } + + /// Returns the largest absolute error independently for each RGBA channel. + public static RgbaMaximumError MaximumAbsoluteErrorPerChannel(Bitmap a, Bitmap b) + { + EnsureComparable(a, b); + + double red = 0; + double green = 0; + double blue = 0; + double alpha = 0; - double varA = 0, varB = 0, cov = 0; - for (int i = 0; i < pixels; i++) + for (int y = 0; y < a.Height; y++) { - double da = Luma(pa, i) - meanA; - double db = Luma(pb, i) - meanB; - varA += da * da; - varB += db * db; - cov += da * db; + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) + { + int offset = x * ChannelCount; + red = Math.Max(red, AbsoluteError(rowA[offset], rowB[offset])); + green = Math.Max(green, AbsoluteError(rowA[offset + 1], rowB[offset + 1])); + blue = Math.Max(blue, AbsoluteError(rowA[offset + 2], rowB[offset + 2])); + alpha = Math.Max(alpha, AbsoluteError(rowA[offset + 3], rowB[offset + 3])); + } } - varA /= pixels; - varB /= pixels; - cov /= pixels; - - const double c1 = 0.01 * 0.01; - const double c2 = 0.03 * 0.03; - double num = (2 * meanA * meanB + c1) * (2 * cov + c2); - double den = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2); - return num / den; + return new RgbaMaximumError(red, green, blue, alpha); } /// - /// Minimum SSIM over non-overlapping tiles. A localized defect cannot hide in the global average. + /// Returns the largest per-channel distance in RgbaF16 storage codes, where 1 is a single + /// representable step at that magnitude, over the channels whose two samples are both below + /// . /// - public static double WindowedSsim(Bitmap a, Bitmap b, int windowSize = 16) + /// + /// An absolute-error bound cannot express "one step" for a half-float buffer: the step is 2^-24 near + /// zero and 2^-11 near one, so a bound tight enough for the top of the range rejects an adjacent code + /// at the bottom of it. The ceiling exists for the reverse case: it restricts the comparison to the + /// magnitudes where an absolute bound is too coarse to say anything at all. Non-finite codes carry no + /// ordering; pair this with . + /// + public static RgbaMaximumError MaximumStorageCodeDistancePerChannel( + Bitmap a, + Bitmap b, + float magnitudeCeiling = float.PositiveInfinity) { EnsureComparable(a, b); - if (windowSize <= 0) throw new ArgumentOutOfRangeException(nameof(windowSize)); - ReadOnlySpan pa = a.GetPixelSpan(); - ReadOnlySpan pb = b.GetPixelSpan(); - int w = a.Width, h = a.Height; + if (float.IsNaN(magnitudeCeiling) || magnitudeCeiling <= 0) + throw new ArgumentOutOfRangeException(nameof(magnitudeCeiling)); - double min = 1.0; - for (int ty = 0; ty < h; ty += windowSize) + Span maximum = stackalloc int[ChannelCount]; + for (int y = 0; y < a.Height; y++) { - for (int tx = 0; tx < w; tx += windowSize) + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) { - int x1 = Math.Min(tx + windowSize, w); - int y1 = Math.Min(ty + windowSize, h); - double s = WindowSsim(pa, pb, w, tx, ty, x1, y1); - if (s < min) min = s; + int offset = x * ChannelCount; + for (int channel = 0; channel < ChannelCount; channel++) + { + ushort codeA = rowA[offset + channel]; + ushort codeB = rowB[offset + channel]; + if (Math.Abs(HalfBitsToFloat(codeA)) >= magnitudeCeiling + || Math.Abs(HalfBitsToFloat(codeB)) >= magnitudeCeiling) + { + continue; + } + + maximum[channel] = Math.Max(maximum[channel], StorageCodeDistance(codeA, codeB)); + } } } - return min; + return new RgbaMaximumError(maximum[0], maximum[1], maximum[2], maximum[3]); } - private static double WindowSsim( - ReadOnlySpan pa, ReadOnlySpan pb, int stride, int x0, int y0, int x1, int y1) + /// + /// Computes RGBA MAE over the nontrivial coverage band in . + /// + /// + /// Coverage bounds are exclusive. Deriving the mask only from the frozen reference keeps the oracle independent + /// of the implementation under test. A reference with no qualifying pixel is rejected so an edge-specific + /// assertion cannot pass vacuously. + /// + public static double EdgeBandMeanAbsoluteError( + Bitmap reference, + Bitmap actual, + float minimumCoverage = 0, + float maximumCoverage = 1) { - int n = (x1 - x0) * (y1 - y0); - double meanA = 0, meanB = 0; - for (int y = y0; y < y1; y++) + EnsureComparable(reference, actual); + ValidateCoverageBounds(minimumCoverage, maximumCoverage); + + double sum = 0; + long pixelCount = 0; + for (int y = 0; y < reference.Height; y++) { - for (int x = x0; x < x1; x++) + ReadOnlySpan referenceRow = reference.GetRow(y); + ReadOnlySpan actualRow = actual.GetRow(y); + for (int x = 0; x < reference.Width; x++) { - int i = y * stride + x; - meanA += Luma(pa, i); - meanB += Luma(pb, i); + int offset = x * ChannelCount; + if (!IsInCoverageBand(referenceRow, offset, minimumCoverage, maximumCoverage)) + continue; + + for (int channel = 0; channel < ChannelCount; channel++) + { + sum += AbsoluteError(referenceRow[offset + channel], actualRow[offset + channel]); + } + + pixelCount++; } } - meanA /= n; - meanB /= n; + if (pixelCount == 0) + throw new InvalidOperationException("The reference bitmap contains no pixel in the requested coverage band."); + + return sum / (pixelCount * ChannelCount); + } + + /// + /// Returns the largest absolute error independently for each RGBA channel over the reference coverage band. + /// + public static RgbaMaximumError EdgeBandMaximumAbsoluteErrorPerChannel( + Bitmap reference, + Bitmap actual, + float minimumCoverage = 0, + float maximumCoverage = 1) + { + EnsureComparable(reference, actual); + ValidateCoverageBounds(minimumCoverage, maximumCoverage); + + double red = 0; + double green = 0; + double blue = 0; + double alpha = 0; + long pixelCount = 0; - double varA = 0, varB = 0, cov = 0; - for (int y = y0; y < y1; y++) + for (int y = 0; y < reference.Height; y++) { - for (int x = x0; x < x1; x++) + ReadOnlySpan referenceRow = reference.GetRow(y); + ReadOnlySpan actualRow = actual.GetRow(y); + for (int x = 0; x < reference.Width; x++) { - int i = y * stride + x; - double da = Luma(pa, i) - meanA; - double db = Luma(pb, i) - meanB; - varA += da * da; - varB += db * db; - cov += da * db; + int offset = x * ChannelCount; + if (!IsInCoverageBand(referenceRow, offset, minimumCoverage, maximumCoverage)) + continue; + + red = Math.Max(red, AbsoluteError(referenceRow[offset], actualRow[offset])); + green = Math.Max(green, AbsoluteError(referenceRow[offset + 1], actualRow[offset + 1])); + blue = Math.Max(blue, AbsoluteError(referenceRow[offset + 2], actualRow[offset + 2])); + alpha = Math.Max(alpha, AbsoluteError(referenceRow[offset + 3], actualRow[offset + 3])); + pixelCount++; } } - varA /= n; - varB /= n; - cov /= n; + if (pixelCount == 0) + throw new InvalidOperationException("The reference bitmap contains no pixel in the requested coverage band."); - const double c1 = 0.01 * 0.01; - const double c2 = 0.03 * 0.03; - double num = (2 * meanA * meanB + c1) * (2 * cov + c2); - double den = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2); - return num / den; + return new RgbaMaximumError(red, green, blue, alpha); + } + + /// Global SSIM over linear luminance. Returns 1.0 for identical inputs. + public static double Ssim(Bitmap a, Bitmap b) + { + EnsureComparable(a, b); + + double meanA = 0; + double meanB = 0; + for (int y = 0; y < a.Height; y++) + { + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) + { + meanA += Luma(rowA, x); + meanB += Luma(rowB, x); + } + } + + double pixels = (double)a.Width * a.Height; + meanA /= pixels; + meanB /= pixels; + + double varianceA = 0; + double varianceB = 0; + double covariance = 0; + for (int y = 0; y < a.Height; y++) + { + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = 0; x < a.Width; x++) + { + double deltaA = Luma(rowA, x) - meanA; + double deltaB = Luma(rowB, x) - meanB; + varianceA += deltaA * deltaA; + varianceB += deltaB * deltaB; + covariance += deltaA * deltaB; + } + } + + varianceA /= pixels; + varianceB /= pixels; + covariance /= pixels; + + return Ssim(meanA, meanB, varianceA, varianceB, covariance); + } + + /// + /// Minimum SSIM over non-overlapping tiles. A localized defect cannot hide in the global average. + /// + public static double WindowedSsim(Bitmap a, Bitmap b, int windowSize = 16) + { + EnsureComparable(a, b); + if (windowSize <= 0) + throw new ArgumentOutOfRangeException(nameof(windowSize)); + + double minimum = 1; + for (int top = 0; top < a.Height; top += windowSize) + { + for (int left = 0; left < a.Width; left += windowSize) + { + int right = Math.Min(left + windowSize, a.Width); + int bottom = Math.Min(top + windowSize, a.Height); + minimum = Math.Min(minimum, WindowSsim(a, b, left, top, right, bottom)); + } + } + + return minimum; } /// @@ -146,28 +306,31 @@ private static double WindowSsim( /// public static double AliasingEnergy(Bitmap bitmap) { - int w = bitmap.Width, h = bitmap.Height; - ReadOnlySpan p = bitmap.GetPixelSpan(); + EnsureSupported(bitmap, nameof(bitmap)); double sum = 0; long count = 0; - for (int y = 0; y < h; y++) + for (int y = 0; y < bitmap.Height; y++) { - for (int x = 0; x < w; x++) + ReadOnlySpan row = bitmap.GetRow(y); + ReadOnlySpan nextRow = y + 1 < bitmap.Height + ? bitmap.GetRow(y + 1) + : default; + + for (int x = 0; x < bitmap.Width; x++) { - int i = y * w + x; - double l = Luma(p, i); - if (x + 1 < w) + double luminance = Luma(row, x); + if (x + 1 < bitmap.Width) { - double d = l - Luma(p, i + 1); - sum += d * d; + double difference = luminance - Luma(row, x + 1); + sum += difference * difference; count++; } - if (y + 1 < h) + if (y + 1 < bitmap.Height) { - double d = l - Luma(p, i + w); - sum += d * d; + double difference = luminance - Luma(nextRow, x); + sum += difference * difference; count++; } } @@ -183,14 +346,19 @@ public static double AliasingEnergy(Bitmap bitmap) { foreach ((string label, Bitmap bitmap) in bitmaps) { - ReadOnlySpan px = bitmap.GetPixelSpan(); - for (int i = 0; i < px.Length; i++) + EnsureSupported(bitmap, nameof(bitmaps)); + for (int y = 0; y < bitmap.Height; y++) { - float v = HalfBitsToFloat(px[i]); - if (!float.IsFinite(v)) + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) { - int pixel = i / 4; - return $"{label} (x={pixel % bitmap.Width}, y={pixel / bitmap.Width}, c={i % 4}) = {v}"; + int offset = x * ChannelCount; + for (int channel = 0; channel < ChannelCount; channel++) + { + float value = HalfBitsToFloat(row[offset + channel]); + if (!float.IsFinite(value)) + return $"{label} (x={x}, y={y}, c={channel}) = {value}"; + } } } } @@ -198,19 +366,129 @@ public static double AliasingEnergy(Bitmap bitmap) return null; } - private static double Luma(ReadOnlySpan px, int pixelIndex) + private static double WindowSsim(Bitmap a, Bitmap b, int left, int top, int right, int bottom) { - int o = pixelIndex * 4; - return LumaR * HalfBitsToFloat(px[o]) + LumaG * HalfBitsToFloat(px[o + 1]) + LumaB * HalfBitsToFloat(px[o + 2]); + int count = (right - left) * (bottom - top); + double meanA = 0; + double meanB = 0; + for (int y = top; y < bottom; y++) + { + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = left; x < right; x++) + { + meanA += Luma(rowA, x); + meanB += Luma(rowB, x); + } + } + + meanA /= count; + meanB /= count; + + double varianceA = 0; + double varianceB = 0; + double covariance = 0; + for (int y = top; y < bottom; y++) + { + ReadOnlySpan rowA = a.GetRow(y); + ReadOnlySpan rowB = b.GetRow(y); + for (int x = left; x < right; x++) + { + double deltaA = Luma(rowA, x) - meanA; + double deltaB = Luma(rowB, x) - meanB; + varianceA += deltaA * deltaA; + varianceB += deltaB * deltaB; + covariance += deltaA * deltaB; + } + } + + varianceA /= count; + varianceB /= count; + covariance /= count; + + return Ssim(meanA, meanB, varianceA, varianceB, covariance); + } + + private static double Ssim( + double meanA, + double meanB, + double varianceA, + double varianceB, + double covariance) + { + const double c1 = 0.01 * 0.01; + const double c2 = 0.03 * 0.03; + double numerator = (2 * meanA * meanB + c1) * (2 * covariance + c2); + double denominator = (meanA * meanA + meanB * meanB + c1) * (varianceA + varianceB + c2); + return numerator / denominator; + } + + private static bool IsInCoverageBand( + ReadOnlySpan referenceRow, + int offset, + float minimumCoverage, + float maximumCoverage) + { + float alpha = HalfBitsToFloat(referenceRow[offset + 3]); + return alpha > minimumCoverage && alpha < maximumCoverage; + } + + private static void ValidateCoverageBounds(float minimumCoverage, float maximumCoverage) + { + if (!float.IsFinite(minimumCoverage) || minimumCoverage < 0 || minimumCoverage >= 1) + throw new ArgumentOutOfRangeException(nameof(minimumCoverage)); + if (!float.IsFinite(maximumCoverage) || maximumCoverage <= 0 || maximumCoverage > 1) + throw new ArgumentOutOfRangeException(nameof(maximumCoverage)); + if (minimumCoverage >= maximumCoverage) + throw new ArgumentException("Minimum coverage must be less than maximum coverage."); + } + + private static double Luma(ReadOnlySpan row, int x) + { + int offset = x * ChannelCount; + return LumaR * HalfBitsToFloat(row[offset]) + + LumaG * HalfBitsToFloat(row[offset + 1]) + + LumaB * HalfBitsToFloat(row[offset + 2]); } + private static double AbsoluteError(ushort a, ushort b) + => Math.Abs(HalfBitsToFloat(a) - HalfBitsToFloat(b)); + private static float HalfBitsToFloat(ushort bits) => (float)BitConverter.UInt16BitsToHalf(bits); + private static int StorageCodeDistance(ushort a, ushort b) + => Math.Abs(OrderedHalfCode(a) - OrderedHalfCode(b)); + + // Half is sign-magnitude, so its raw codes are not monotonic across zero. Mirroring the negative + // half restores the ordering that makes adjacent representable values exactly one apart, and folds + // -0 onto +0. + private static int OrderedHalfCode(ushort bits) + { + int magnitude = bits & 0x7FFF; + return (bits & 0x8000) != 0 ? -magnitude : magnitude; + } + private static void EnsureComparable(Bitmap a, Bitmap b) { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); if (a.Width != b.Width || a.Height != b.Height) throw new ArgumentException($"Bitmap sizes differ: {a.Width}x{a.Height} vs {b.Width}x{b.Height}."); - if (a.ColorType != BitmapColorType.RgbaF16 || b.ColorType != BitmapColorType.RgbaF16) - throw new ArgumentException("ImageMetrics expects RgbaF16 bitmaps (linear)."); + + EnsureSupported(a, nameof(a)); + EnsureSupported(b, nameof(b)); + } + + private static void EnsureSupported(Bitmap bitmap, string parameterName) + { + ArgumentNullException.ThrowIfNull(bitmap); + if (bitmap.ColorType != BitmapColorType.RgbaF16 + || bitmap.AlphaType != BitmapAlphaType.Premul + || bitmap.ColorSpace != BitmapColorSpace.LinearSrgb) + { + throw new ArgumentException( + "ImageMetrics expects linear-sRGB, premultiplied RgbaF16 bitmaps.", + parameterName); + } } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetricsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetricsTests.cs index 600447ab84..66fa03d4c9 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetricsTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ImageMetricsTests.cs @@ -44,6 +44,166 @@ private static Bitmap Checkerboard(int w, int h) return bmp; } + private static void SetCode(Bitmap bitmap, int x, int y, int channel, int code) + => bitmap.GetRow(y)[(x * 4) + channel] = (ushort)code; + + private static void SetPremultipliedGray(Bitmap bitmap, int x, int y, float coverage) + { + Span row = bitmap.GetRow(y); + int offset = x * 4; + ushort value = BitConverter.HalfToUInt16Bits((Half)coverage); + row[offset] = value; + row[offset + 1] = value; + row[offset + 2] = value; + row[offset + 3] = value; + } + + [Test] + public void AlphaMeanAbsoluteError_SeesAlphaOnlyDrift() + { + using var a = Flat(16, 16, 0.2f, 0.3f, 0.4f, a: 0.75f); + using var b = Flat(16, 16, 0.2f, 0.3f, 0.4f, a: 0.5f); + + Assert.Multiple(() => + { + Assert.That(ImageMetrics.MeanAbsoluteError(a, b), Is.Zero); + Assert.That(ImageMetrics.Ssim(a, b), Is.EqualTo(1).Within(1e-12)); + Assert.That(ImageMetrics.AlphaMeanAbsoluteError(a, b), Is.EqualTo(0.25).Within(1e-12)); + }); + } + + [Test] + public void MaximumAbsoluteErrorPerChannel_ReportsEachChannelIndependently() + { + using var a = Flat(4, 4, 0, 0, 0, 0); + using var b = Flat(4, 4, 0.125f, 0.25f, 0.5f, 0.75f); + + RgbaMaximumError error = ImageMetrics.MaximumAbsoluteErrorPerChannel(a, b); + + Assert.Multiple(() => + { + Assert.That(error.Red, Is.EqualTo(0.125)); + Assert.That(error.Green, Is.EqualTo(0.25)); + Assert.That(error.Blue, Is.EqualTo(0.5)); + Assert.That(error.Alpha, Is.EqualTo(0.75)); + Assert.That(error.Maximum, Is.EqualTo(0.75)); + Assert.That(error[2], Is.EqualTo(error.Blue)); + }); + } + + [Test] + public void MaximumStorageCodeDistancePerChannel_CountsAdjacentCodesAsOneAtEveryMagnitude() + { + using var a = Flat(2, 2, 0, 0, 0, 0); + using var b = Flat(2, 2, 0, 0, 0, 0); + // The subnormal step (2^-24) and the step below one (2^-11) differ by 8192x in absolute terms, + // so only a code distance scores them the same. + SetCode(b, 0, 0, 0, BitConverter.HalfToUInt16Bits((Half)0f) + 1); + SetCode(a, 0, 0, 1, BitConverter.HalfToUInt16Bits((Half)1f)); + SetCode(b, 0, 0, 1, BitConverter.HalfToUInt16Bits((Half)1f) + 1); + SetCode(a, 0, 0, 2, BitConverter.HalfToUInt16Bits((Half)0.5f)); + SetCode(b, 0, 0, 2, BitConverter.HalfToUInt16Bits((Half)0.5f) - 1); + + RgbaMaximumError codes = ImageMetrics.MaximumStorageCodeDistancePerChannel(a, b); + RgbaMaximumError absolute = ImageMetrics.MaximumAbsoluteErrorPerChannel(a, b); + + Assert.Multiple(() => + { + Assert.That(codes, Is.EqualTo(new RgbaMaximumError(1, 1, 1, 0))); + Assert.That(absolute.Red, Is.LessThan(absolute.Green)); + }); + } + + [Test] + public void MaximumStorageCodeDistancePerChannel_OrdersTheCodesAcrossZero() + { + using var a = Flat(2, 2, 0, 0, 0, 1); + using var b = Flat(2, 2, 0, 0, 0, 1); + SetCode(a, 1, 1, 0, 0x8000); + SetCode(b, 1, 1, 1, 0x8001); + SetCode(b, 1, 1, 2, BitConverter.HalfToUInt16Bits((Half)1f)); + + RgbaMaximumError codes = ImageMetrics.MaximumStorageCodeDistancePerChannel(a, b); + + Assert.Multiple(() => + { + Assert.That(codes.Red, Is.Zero, "negative zero is the same value as positive zero."); + Assert.That(codes.Green, Is.EqualTo(1), "the first negative subnormal is one step below zero."); + Assert.That(codes.Blue, Is.GreaterThan(1)); + }); + } + + [Test] + public void MaximumStorageCodeDistancePerChannel_IgnoresChannelsAboveTheCeiling() + { + const float subnormalCeiling = 6.103515625e-5f; + using var a = Flat(2, 2, 0, 0, 0, 1); + using var b = Flat(2, 2, 0, 0, 0, 1); + SetCode(a, 0, 0, 0, 8); + SetCode(b, 0, 0, 0, 12); + SetCode(b, 0, 0, 1, BitConverter.HalfToUInt16Bits((Half)0.5f)); + + RgbaMaximumError bounded = ImageMetrics.MaximumStorageCodeDistancePerChannel(a, b, subnormalCeiling); + RgbaMaximumError unbounded = ImageMetrics.MaximumStorageCodeDistancePerChannel(a, b); + + Assert.Multiple(() => + { + Assert.That(bounded.Red, Is.EqualTo(4), "subnormal samples stay in the comparison."); + Assert.That(bounded.Green, Is.Zero, "a sample at or above the ceiling drops out."); + Assert.That(unbounded.Green, Is.GreaterThan(0)); + Assert.That( + () => ImageMetrics.MaximumStorageCodeDistancePerChannel(a, b, 0), + Throws.InstanceOf()); + }); + } + + [Test] + public void EdgeBandMetrics_UseOnlyNontrivialReferenceCoverage() + { + using var reference = Flat(16, 16, 0, 0, 0, 0); + using var actual = Flat(16, 16, 0, 0, 0, 0); + + for (int y = 4; y < 12; y++) + { + for (int x = 4; x < 12; x++) + { + float coverage = x == 4 || x == 11 || y == 4 || y == 11 ? 0.5f : 1; + SetPremultipliedGray(reference, x, y, coverage); + SetPremultipliedGray(actual, x, y, coverage); + } + } + + SetPremultipliedGray(actual, 4, 4, 0.25f); + + double wholeImageRgbMae = ImageMetrics.MeanAbsoluteError(reference, actual); + double edgeBandMae = ImageMetrics.EdgeBandMeanAbsoluteError(reference, actual); + RgbaMaximumError edgeMaximum = ImageMetrics.EdgeBandMaximumAbsoluteErrorPerChannel(reference, actual); + + Assert.Multiple(() => + { + Assert.That(edgeBandMae, Is.EqualTo(0.25 / 28).Within(1e-12)); + Assert.That(edgeBandMae, Is.GreaterThan(wholeImageRgbMae)); + Assert.That(edgeMaximum, Is.EqualTo(new RgbaMaximumError(0.25, 0.25, 0.25, 0.25))); + }); + } + + [Test] + public void EdgeBandMetrics_RejectReferenceWithoutNontrivialCoverage() + { + using var reference = Flat(4, 4, 1, 1, 1, 1); + using var actual = Flat(4, 4, 1, 1, 1, 1); + + Assert.Multiple(() => + { + Assert.That( + () => ImageMetrics.EdgeBandMeanAbsoluteError(reference, actual), + Throws.InvalidOperationException); + Assert.That( + () => ImageMetrics.EdgeBandMaximumAbsoluteErrorPerChannel(reference, actual), + Throws.InvalidOperationException); + }); + } + [Test] public void Ssim_Identical_IsOne() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/KeyingDegenerateBoundaryTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/KeyingDegenerateBoundaryTests.cs new file mode 100644 index 0000000000..426d49aaf9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/KeyingDegenerateBoundaryTests.cs @@ -0,0 +1,238 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// Pins that keying a solid fill against its own colour removes it whatever the boundary is. +/// +/// +/// A solid fill reaches the shaders quantized onto an 8-bit grid in the render target's colour space, which is +/// linear light, so it arrives up to half a linear code away from the CPU-computed key uniform. Half a linear +/// code spans about ten sRGB levels near black, so a tolerance carried after the transfer curve cannot absorb +/// it: the dark cases below collapse onto an exact grey, and their saturation then disagrees with the key by +/// two orders of magnitude more than a one-8-bit-step tolerance. Only an axis-aligned rectangle ever gave Skia +/// a full-coverage quad, so the ellipse cases pin a shape that was wrong before the fused pipeline as well. +/// +[TestFixture] +[NonParallelizable] +public sealed class KeyingDegenerateBoundaryTests +{ + private static readonly Color s_key = Color.FromRgb(206, 92, 42); + private static readonly PixelSize s_frame = new(64, 48); + + private static readonly float[] s_boundaries = [0f, 0.5f, 2f]; + + private static readonly Color[] s_chromaKeys = + [ + s_key, + Color.FromRgb(20, 18, 22), + Color.FromRgb(10, 40, 20), + Color.FromRgb(12, 12, 12), + ]; + + [TestCase(0f)] + [TestCase(0.5f)] + [TestCase(2f)] + [Category("GpuPassFusionGpu")] + public void ColorKey_OnItsOwnFlatFill_RemovesEverything(float boundary) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var key = new ColorKey(); + key.Color.CurrentValue = s_key; + key.Range.CurrentValue = 0f; + key.Boundary.CurrentValue = boundary; + + AssertKeyedAway(key, s_key, ellipse: false, $"ColorKey Boundary={boundary:R}"); + }); + } + + [TestCaseSource(nameof(ChromaKeySelfKeyCases))] + [Category("GpuPassFusionGpu")] + public void ChromaKey_OnItsOwnFlatFill_RemovesEverything(Color key, float boundary, bool ellipse) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var effect = new ChromaKey(); + effect.Color.CurrentValue = key; + effect.HueRange.CurrentValue = 0f; + effect.SaturationRange.CurrentValue = 0f; + effect.Boundary.CurrentValue = boundary; + + AssertKeyedAway( + effect, + key, + ellipse, + $"ChromaKey rgb({key.R},{key.G},{key.B}) Boundary={boundary:R} Ellipse={ellipse}"); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ColorKey_LeavesAColourTheKeyDoesNotMatch() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var key = new ColorKey(); + key.Color.CurrentValue = Colors.Blue; + key.Range.CurrentValue = 0f; + key.Boundary.CurrentValue = 0f; + + AssertSurvives(key, s_key, ellipse: false, "ColorKey Blue"); + }); + } + + [TestCaseSource(nameof(ChromaKeyNonMatchCases))] + [Category("GpuPassFusionGpu")] + public void ChromaKey_LeavesAColourTheKeyDoesNotMatch(Color fill, Color key, bool ellipse) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var effect = new ChromaKey(); + effect.Color.CurrentValue = key; + effect.HueRange.CurrentValue = 0f; + effect.SaturationRange.CurrentValue = 0f; + effect.Boundary.CurrentValue = 0f; + + AssertSurvives( + effect, + fill, + ellipse, + $"ChromaKey rgb({key.R},{key.G},{key.B}) over rgb({fill.R},{fill.G},{fill.B}) Ellipse={ellipse}"); + }); + } + + /// + /// Pins that a dark, fully saturated colour survives a key it only differs from in hue. + /// + /// + /// A pure primary shares its saturation with a pure key, so the saturation term cannot separate them and + /// hue is the only discriminator left. Quantization makes a dark pixel's hue unreliable, but withholding + /// the hue term there removes the pixel instead of keeping it: the shader keeps what no term claims. Every + /// shadow in a keyed plate lands in this range, so the levels below span the whole band a chroma floor of + /// one linear code covers. + /// + [TestCase(4)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + [TestCase(20)] + [Category("GpuPassFusionGpu")] + public void ChromaKey_LeavesADarkFullySaturatedColourOpaque(int level) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var effect = new ChromaKey(); + effect.Color.CurrentValue = Colors.Lime; + effect.HueRange.CurrentValue = 0f; + effect.SaturationRange.CurrentValue = 0f; + effect.Boundary.CurrentValue = 2f; + + Assert.That( + RenderMaximumAlpha(effect, Color.FromRgb(0, 0, (byte)level), ellipse: false), + Is.GreaterThan(0.99f), + $"ChromaKey Lime over a pure blue at sRGB {level}: blue is not green at any brightness."); + }); + } + + private static IEnumerable ChromaKeySelfKeyCases() + { + foreach (Color key in s_chromaKeys) + { + foreach (float boundary in s_boundaries) + { + foreach (bool ellipse in new[] { false, true }) + yield return [key, boundary, ellipse]; + } + } + } + + private static IEnumerable ChromaKeyNonMatchCases() + { + (Color Fill, Color Key)[] pairs = + [ + (s_key, Colors.Blue), + (Color.FromRgb(20, 18, 22), Colors.Lime), + (Color.FromRgb(10, 40, 20), Color.FromRgb(60, 10, 10)), + (Color.FromRgb(12, 12, 12), Colors.Lime), + ]; + + foreach ((Color fill, Color key) in pairs) + { + foreach (bool ellipse in new[] { false, true }) + yield return [fill, key, ellipse]; + } + } + + private static void AssertKeyedAway(FilterEffect key, Color fill, bool ellipse, string label) + { + Assert.That( + RenderMaximumAlpha(key, fill, ellipse), + Is.Zero, + $"{label}: keying a solid fill against its own colour must remove every pixel."); + } + + private static void AssertSurvives(FilterEffect key, Color fill, bool ellipse, string label) + { + Assert.That( + RenderMaximumAlpha(key, fill, ellipse), + Is.GreaterThan(0.5f), + $"{label}: a tolerance that swallowed an unrelated key colour would make the effect useless."); + } + + private static float RenderMaximumAlpha(FilterEffect key, Color fill, bool ellipse) + { + using Drawable.Resource resource = CreateFlatShape(fill, ellipse, key); + using Bitmap rendered = GoldenImageHarness.RenderAtScale( + resource, s_frame, 1f, clearColor: Colors.Transparent); + + return MaximumAlpha(rendered); + } + + private static Drawable.Resource CreateFlatShape(Color fill, bool ellipse, FilterEffect? effect = null) + { + Shape shape; + if (ellipse) + { + var ellipseShape = new EllipseShape(); + ellipseShape.Width.CurrentValue = 40f; + ellipseShape.Height.CurrentValue = 30f; + shape = ellipseShape; + } + else + { + var rect = new RectShape(); + rect.Width.CurrentValue = 40f; + rect.Height.CurrentValue = 30f; + shape = rect; + } + + shape.Fill.CurrentValue = new SolidColorBrush(fill); + if (effect is not null) + shape.FilterEffect.CurrentValue = effect; + return (Drawable.Resource)shape.ToResource(CompositionContext.Default); + } + + private static float MaximumAlpha(Bitmap bitmap) + { + float maximum = 0f; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + maximum = MathF.Max(maximum, (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3])); + } + + return maximum; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/LosslessCompositeCoverageTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/LosslessCompositeCoverageTests.cs new file mode 100644 index 0000000000..faa6500fda --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/LosslessCompositeCoverageTests.cs @@ -0,0 +1,752 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// Guards the "rasterize once at the final density" contract: content whose buffer already lands on +/// exact device pixels must be copied, never resampled, and a genuine resample must stay inside the +/// range of the samples it interpolated. +/// +[NonParallelizable] +[TestFixture] +public sealed class LosslessCompositeCoverageTests +{ + private static readonly PixelSize s_frame = new(200, 140); + private static readonly PixelSize s_fractionalFrame = new(800, 400); + + [TestCase(1f)] + [TestCase(2f)] + public void EffectFreeCurvedGeometry_MatchesDirectRasterization(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource resource = CreateEllipse(effect: null); + using Bitmap expected = RenderDirect(resource, density); + using Bitmap actual = RenderThroughPipeline(resource, density); + + AssertByteIdentical(expected, actual, $"effect-free ellipse at density {density}"); + }); + } + + [TestCase(1f)] + [TestCase(2f)] + public void IdentityColorEffect_PreservesEdgeCoverage(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource plain = CreateRectangle(effect: null); + using Drawable.Resource filtered = CreateRectangle(identity); + using Bitmap expected = RenderThroughPipeline(plain, density); + using Bitmap actual = RenderThroughPipeline(filtered, density); + + AssertByteIdentical(expected, actual, $"identity Brightness at density {density}"); + }); + } + + [TestCase(0.25f)] + [TestCase(0.75f)] + public void IdentityColorEffect_AtFractionalDevicePosition_IsByteIdenticalToUnfiltered(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource plain = CreateRectangle( + width: 301, + height: 201, + effect: null); + using Drawable.Resource filtered = CreateRectangle( + width: 301, + height: 201, + identity); + using Bitmap expected = RenderThroughPipeline(plain, density, s_fractionalFrame); + using Bitmap actual = RenderThroughPipeline(filtered, density, s_fractionalFrame); + + AssertByteIdentical( + expected, + actual, + $"identity Brightness at fractional device position and density {density}"); + }); + } + + [TestCase(0.25f)] + [TestCase(0.75f)] + public void IdentityColorEffect_OnFractionallyPositionedText_IsByteIdenticalToUnfiltered(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var identity = new Brightness(); + identity.Amount.CurrentValue = 100; + using Drawable.Resource plain = CreateText(effect: null); + using Drawable.Resource filtered = CreateText(identity); + using Bitmap expected = RenderThroughPipeline(plain, density, s_fractionalFrame); + using Bitmap actual = RenderThroughPipeline(filtered, density, s_fractionalFrame); + + AssertByteIdentical( + expected, + actual, + $"identity Brightness on fractionally positioned text at density {density}"); + }); + } + + [TestCase(0.25f)] + [TestCase(0.75f)] + public void IdentityTypedShader_AtFractionalDevicePosition_IsByteIdenticalToUnfiltered(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource plain = CreateRectangle( + width: 301, + height: 201, + effect: null); + using Drawable.Resource filtered = CreateRectangle( + width: 301, + height: 201, + new IdentityTypedShaderEffect()); + using Bitmap expected = RenderThroughPipeline(plain, density, s_fractionalFrame); + using Bitmap actual = RenderThroughPipeline(filtered, density, s_fractionalFrame); + + AssertByteIdentical( + expected, + actual, + $"identity typed shader at fractional device position and density {density}"); + }); + } + + [TestCase(false, TestName = "FractionalTranslationCacheMove_LegacyFilter_MatchesUncached")] + [TestCase(true, TestName = "FractionalTranslationCacheMove_TypedShader_MatchesUncached")] + public void FractionalTranslationCacheMove_MatchesUncached(bool typedShader) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffect.Resource effect = CreateIdentityEffectResource(typedShader); + using TransformRenderNode cachedRoot = CreateCachedEffectTree(effect, translation: 0.25f); + using var cachedRenderer = CreateNodeRenderer(cachedRoot, useRenderCache: true); + using Bitmap warm = RenderNodeRendererToBitmap(cachedRenderer); + + cachedRoot.Update( + Matrix.CreateTranslation(0.75f, 0.75f), + TransformOperator.Prepend); + using Bitmap actual = RenderNodeRendererToBitmap(cachedRenderer); + + using FilterEffect.Resource controlEffect = CreateIdentityEffectResource(typedShader); + using TransformRenderNode controlRoot = CreateCachedEffectTree( + controlEffect, + translation: 0.75f, + enableCaches: false); + using var controlRenderer = CreateNodeRenderer(controlRoot, useRenderCache: false); + using Bitmap expected = RenderNodeRendererToBitmap(controlRenderer); + + AssertByteIdentical( + expected, + actual, + $"{(typedShader ? "typed shader" : "legacy filter")} after a device-phase cache move"); + Assert.That( + cachedRoot.Children[0].Cache.IsCached, + Is.False, + "A subtree rasterized against an ambient device grid must not publish a phase-ambiguous cache."); + }); + } + + [Test] + public void IntegralDestinationTranslation_UsesEffectDensityWhenRejectingPhaseAmbiguousCache() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffect.Resource effect = CreateIdentityEffectResource(typedShader: false); + using TransformRenderNode cachedRoot = CreateCachedEffectTree(effect, translation: 0); + using var cachedRenderer = CreateNodeRenderer( + cachedRoot, + useRenderCache: true, + maxWorkingScale: 0.75f); + using Bitmap warm = RenderNodeRendererToBitmap(cachedRenderer, destinationTranslation: 1); + using Bitmap actual = RenderNodeRendererToBitmap(cachedRenderer, destinationTranslation: 2); + + using FilterEffect.Resource controlEffect = CreateIdentityEffectResource(typedShader: false); + using TransformRenderNode controlRoot = CreateCachedEffectTree( + controlEffect, + translation: 0, + enableCaches: false); + using var controlRenderer = CreateNodeRenderer( + controlRoot, + useRenderCache: false, + maxWorkingScale: 0.75f); + using Bitmap expected = RenderNodeRendererToBitmap( + controlRenderer, + destinationTranslation: 2); + + Assert.Multiple(() => + { + AssertByteIdentical( + expected, + actual, + "identity effect after an integral destination move at density 0.75"); + Assert.That( + cachedRoot.Children[0].Cache.IsCached, + Is.False, + "The logical destination offset must be evaluated at the effect's working density."); + }); + }); + } + + [Test] + public void CancellingTransformChain_DoesNotPublishPhaseAmbiguousEffectCache() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using FilterEffect.Resource effect = CreateIdentityEffectResource(typedShader: true); + var source = new RectangleRenderNode( + new Rect(20, 20, 101, 81), + Brushes.Resource.White, + pen: null); + var filter = new FilterEffectRenderNode(effect); + filter.AddChild(source); + var inner = new TransformRenderNode( + Matrix.CreateScale(new Vector(0.5f, 0.5f)) + .Append(Matrix.CreateTranslation(0.75f, 0.75f)), + TransformOperator.Prepend); + inner.AddChild(filter); + using var root = new TransformRenderNode( + Matrix.CreateScale(new Vector(2, 2)), + TransformOperator.Prepend); + root.AddChild(inner); + source.Cache.RecordStableRequests(); + filter.Cache.RecordStableRequests(); + inner.Cache.RecordStableRequests(); + root.Cache.RecordStableRequests(); + using var renderer = CreateNodeRenderer(root, useRenderCache: true); + + using Bitmap first = RenderNodeRendererToBitmap(renderer); + using Bitmap second = RenderNodeRendererToBitmap(renderer); + + Assert.Multiple(() => + { + Assert.That( + filter.Cache.IsCached, + Is.False, + "The composed transform is translation-only even though neither transform is."); + AssertByteIdentical(first, second, "repeated cancelling-transform render"); + }); + }); + } + + [Test] + public void FusedShaderStages_ReportTheSameGridAwareFootprintsAsStandaloneStages() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var fusedChain = new FootprintObservingShaderChainNode(); + using TransformRenderNode fusedRoot = WrapInFractionalTranslation(fusedChain); + using var fusedRenderer = CreateNodeRenderer( + fusedRoot, + useRenderCache: false, + fusionMode: FusionMode.Enabled); + using Bitmap fusedBitmap = RenderNodeRendererToBitmap(fusedRenderer); + + using var standaloneChain = new FootprintObservingShaderChainNode(); + using TransformRenderNode standaloneRoot = WrapInFractionalTranslation(standaloneChain); + using var standaloneRenderer = CreateNodeRenderer( + standaloneRoot, + useRenderCache: false, + fusionMode: FusionMode.Disabled); + using Bitmap standaloneBitmap = RenderNodeRendererToBitmap(standaloneRenderer); + + Assert.That(fusedChain.Observations, Has.Count.EqualTo(2)); + Assert.That(standaloneChain.Observations, Has.Count.EqualTo(2)); + Assert.Multiple(() => + { + Assert.That(fusedChain.Observations, Is.EqualTo(standaloneChain.Observations)); + foreach (ShaderFootprintObservation observation in fusedChain.Observations) + { + Assert.That( + observation.DeviceBounds + .ToRect(observation.WorkingScale) + .Translate(-observation.DeviceGridOffset) + .Position, + Is.EqualTo(observation.LogicalOrigin)); + } + }); + }); + } + + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + public void MosaicClamp_PreservesConstantOpaqueSourceAndFarEdgeAlpha(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var mosaic = new MosaicEffect(); + mosaic.TileSize.CurrentValue = new Size(20, 20); + mosaic.Origin.CurrentValue = RelativePoint.Center; + var frame = new PixelSize(180, 180); + using Drawable.Resource plain = CreateRectangle( + width: 180, + height: 180, + effect: null, + alignmentX: AlignmentX.Left, + alignmentY: AlignmentY.Top); + using Drawable.Resource filtered = CreateRectangle( + width: 180, + height: 180, + mosaic, + AlignmentX.Left, + AlignmentY.Top); + using Bitmap expected = RenderThroughPipeline(plain, density, frame); + using Bitmap actual = RenderThroughPipeline(filtered, density, frame); + + ReadOnlySpan expectedPixels = expected.GetPixelSpan(); + ReadOnlySpan actualPixels = actual.GetPixelSpan(); + int differingChannels = 0; + float minimumFarEdgeAlpha = 1; + for (int y = 0; y < actual.Height; y++) + { + for (int x = 0; x < actual.Width; x++) + { + int pixelOffset = ((y * actual.Width) + x) * 4; + for (int channel = 0; channel < 4; channel++) + { + if (actualPixels[pixelOffset + channel] != expectedPixels[pixelOffset + channel]) + differingChannels++; + } + + if (x == actual.Width - 1 || y == actual.Height - 1) + { + minimumFarEdgeAlpha = Math.Min( + minimumFarEdgeAlpha, + (float)BitConverter.UInt16BitsToHalf(actualPixels[pixelOffset + 3])); + } + } + } + + Assert.Multiple(() => + { + Assert.That( + differingChannels, + Is.Zero, + "Mosaic over a constant opaque source must preserve every premultiplied channel."); + Assert.That( + minimumFarEdgeAlpha, + Is.EqualTo(1).Within(0.001f), + "Clamp sampling must preserve opaque alpha at the source's right and bottom edges."); + }); + }); + } + + [Test] + public void ScaledComposite_StaysInsideTheSourceRange() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget source = RenderTarget.Create(16, 16) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using (var sourceCanvas = new ImmediateCanvas(source, 1f, logicalSize: new Size(16, 16))) + { + sourceCanvas.Clear(); + using var dark = new SKPaint { IsAntialias = false, Color = new SKColor(64, 64, 64) }; + using var bright = new SKPaint { IsAntialias = false, Color = new SKColor(192, 192, 192) }; + sourceCanvas.Canvas.DrawRect(SKRect.Create(0, 0, 16, 8), dark); + sourceCanvas.Canvas.DrawRect(SKRect.Create(0, 8, 16, 8), bright); + } + + using RenderTarget destination = RenderTarget.Create(64, 64) + ?? throw new InvalidOperationException( + "RenderTarget.Create returned null."); + using (var canvas = new ImmediateCanvas(destination, 1f, logicalSize: new Size(64, 64))) + { + canvas.Clear(); + canvas.DrawRenderTargetScaled(source, new Rect(4, 4, 40, 40)); + } + + using Bitmap result = destination.Snapshot(); + double darkPlateau = ReadRed(result, 22, 10); + double brightPlateau = ReadRed(result, 22, 38); + double minimum = double.PositiveInfinity; + double maximum = double.NegativeInfinity; + for (int y = 6; y < 42; y++) + { + for (int x = 6; x < 42; x++) + { + double value = ReadRed(result, x, y); + minimum = Math.Min(minimum, value); + maximum = Math.Max(maximum, value); + } + } + + // One RgbaF16 step near the bright plateau is ~4.9e-4, so only a real kernel lobe clears this. + const double halfFloatTolerance = 1e-3; + TestContext.WriteLine( + $"plateaus [{darkPlateau:F6}, {brightPlateau:F6}], resampled [{minimum:F6}, {maximum:F6}]"); + Assert.Multiple(() => + { + Assert.That(darkPlateau, Is.GreaterThan(0), + "The scaled composite must retain the nonzero dark source plateau."); + Assert.That(brightPlateau, Is.GreaterThan(darkPlateau + halfFloatTolerance), + "The scaled composite must retain two distinct source plateaus."); + Assert.That(maximum, Is.LessThanOrEqualTo(brightPlateau + halfFloatTolerance), + "The resample kernel overshot the brightest sample it interpolated."); + Assert.That(minimum, Is.GreaterThanOrEqualTo(darkPlateau - halfFloatTolerance), + "The resample kernel undershot the darkest sample it interpolated."); + }); + }); + } + + private static double ReadRed(Bitmap bitmap, int x, int y) + => (double)BitConverter.UInt16BitsToHalf(bitmap.GetPixelSpan()[((y * bitmap.Width) + x) * 4]); + + private static PixelRect MeasureAlphaBounds(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int left = bitmap.Width; + int top = bitmap.Height; + int right = 0; + int bottom = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + int pixelOffset = ((y * bitmap.Width) + x) * 4; + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[pixelOffset + 3]); + Assert.That( + float.IsFinite(alpha), + Is.True, + $"The footprint alpha at ({x}, {y}) must be finite."); + if (alpha <= 0) + continue; + left = Math.Min(left, x); + top = Math.Min(top, y); + right = Math.Max(right, x + 1); + bottom = Math.Max(bottom, y + 1); + } + } + + Assert.That(right, Is.GreaterThan(left), "The footprint fixture must render non-transparent pixels."); + Assert.That(bottom, Is.GreaterThan(top), "The footprint fixture must render non-transparent pixels."); + return new PixelRect(left, top, right - left, bottom - top); + } + + private static void AssertByteIdentical(Bitmap expected, Bitmap actual, string scenario) + { + int differing = 0; + double maximum = 0; + ReadOnlySpan a = expected.GetPixelSpan(); + ReadOnlySpan b = actual.GetPixelSpan(); + for (int index = 0; index < a.Length; index++) + { + double left = (double)BitConverter.UInt16BitsToHalf(a[index]); + double right = (double)BitConverter.UInt16BitsToHalf(b[index]); + if (a[index] != b[index]) + differing++; + maximum = Math.Max(maximum, Math.Abs(left - right)); + } + + Assert.Multiple(() => + { + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + Assert.That(differing, Is.Zero, + $"{scenario}: {differing} channels differ, maximum delta {maximum:F6}."); + }); + } + + private static Drawable.Resource CreateEllipse(FilterEffect? effect) + { + var shape = new EllipseShape(); + shape.Width.CurrentValue = 120; + shape.Height.CurrentValue = 80; + return Configure(shape, effect); + } + + private static Drawable.Resource CreateRectangle(FilterEffect? effect) + => CreateRectangle(120, 80, effect); + + private static Drawable.Resource CreateRectangle( + float width, + float height, + FilterEffect? effect, + AlignmentX alignmentX = AlignmentX.Center, + AlignmentY alignmentY = AlignmentY.Center) + { + var shape = new RectShape(); + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.AlignmentX.CurrentValue = alignmentX; + shape.AlignmentY.CurrentValue = alignmentY; + return Configure(shape, effect); + } + + private static Drawable.Resource CreateText(FilterEffect? effect) + { + Typeface typeface = TypefaceProvider.Typeface(); + var text = new TextBlock(); + text.AlignmentX.CurrentValue = AlignmentX.Center; + text.AlignmentY.CurrentValue = AlignmentY.Center; + text.FontFamily.CurrentValue = typeface.FontFamily; + text.FontStyle.CurrentValue = typeface.Style; + text.FontWeight.CurrentValue = typeface.Weight; + text.Size.CurrentValue = 160; + text.Fill.CurrentValue = Brushes.White; + text.Text.CurrentValue = "Phase"; + if (effect is not null) + text.FilterEffect.CurrentValue = effect; + return text.ToResource(CompositionContext.Default); + } + + private static Drawable.Resource Configure(Shape shape, FilterEffect? effect) + { + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Fill.CurrentValue = Brushes.White; + if (effect is not null) + shape.FilterEffect.CurrentValue = effect; + return shape.ToResource(CompositionContext.Default); + } + + private static FilterEffect.Resource CreateIdentityEffectResource(bool typedShader) + { + FilterEffect effect; + if (typedShader) + { + effect = new IdentityTypedShaderEffect(); + } + else + { + var brightness = new Brightness(); + brightness.Amount.CurrentValue = 100; + effect = brightness; + } + + return effect.ToResource(CompositionContext.Default); + } + + private static TransformRenderNode CreateCachedEffectTree( + FilterEffect.Resource effect, + float translation, + bool enableCaches = true) + { + var source = new RectangleRenderNode( + new Rect(20, 20, 101, 81), + Brushes.Resource.White, + pen: null); + var filter = new FilterEffectRenderNode(effect); + filter.AddChild(source); + var transform = new TransformRenderNode( + Matrix.CreateTranslation(translation, translation), + TransformOperator.Prepend); + transform.AddChild(filter); + if (enableCaches) + { + source.Cache.RecordStableRequests(); + filter.Cache.RecordStableRequests(); + transform.Cache.RecordStableRequests(); + } + + return transform; + } + + private static RenderNodeRenderer CreateNodeRenderer( + RenderNode root, + bool useRenderCache, + FusionMode fusionMode = FusionMode.Enabled, + float maxWorkingScale = 1) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(0, 0, 180, 140), + OutputScale = 1, + MaxWorkingScale = maxWorkingScale, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + FusionMode = fusionMode, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + private static Bitmap RenderNodeRendererToBitmap( + RenderNodeRenderer renderer, + float destinationTranslation = 0) + { + using RenderTarget target = RenderTarget.Create(180, 140) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, logicalSize: new Size(180, 140)); + canvas.Clear(); + using (canvas.PushTransform( + Matrix.CreateTranslation(destinationTranslation, destinationTranslation))) + { + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + private static TransformRenderNode WrapInFractionalTranslation(RenderNode child) + { + var transform = new TransformRenderNode( + Matrix.CreateTranslation(0.75f, 0.75f), + TransformOperator.Prepend); + transform.AddChild(child); + return transform; + } + + private readonly record struct ShaderFootprintObservation( + PixelRect DeviceBounds, + PixelSize DeviceSize, + Point LogicalOrigin, + Vector DeviceGridOffset, + float WorkingScale); + + private sealed class FootprintObservingShaderChainNode : ContainerRenderNode + { + public FootprintObservingShaderChainNode() + { + AddChild(new RectangleRenderNode( + new Rect(20, 20, 101, 81), + Brushes.Resource.White, + pen: null)); + } + + public List Observations { get; } = []; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle current = context.Inputs.Single(); + for (int stage = 0; stage < 2; stage++) + { + int capturedStage = stage; + ShaderDescription description = ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 1f, + (writer, value, execution) => + { + Observations.Add(new ShaderFootprintObservation( + execution.DeviceBounds, + execution.DeviceSize, + execution.LogicalOrigin, + execution.DeviceGridOffset, + execution.WorkingScale)); + writer.Set(value); + })); + current = context.Shader(current, description); + } + + context.Publish(current); + } + } + + private static Bitmap RenderDirect(Drawable.Resource resource, float density) + { + Shape shape = (Shape)resource.GetOriginal()!; + var shapeResource = (Shape.Resource)resource; + Size frameSize = s_frame.ToSize(1); + Size shapeSize = shape.MeasureInternal(frameSize, resource); + Matrix transform = shape.GetTransformMatrix(frameSize, shapeSize, resource); + Geometry.Resource geometry = shapeResource.GetGeometry() + ?? throw new InvalidOperationException("The shape produced no geometry."); + + using RenderTarget target = CreateFrameTarget(density); + using var canvas = new ImmediateCanvas(target, density, logicalSize: frameSize); + canvas.Clear(); + using (canvas.PushTransform(transform)) + { + canvas.DrawGeometry(geometry, shapeResource.Fill, shapeResource.Pen); + } + + return target.Snapshot(); + } + + private static Bitmap RenderThroughPipeline(Drawable.Resource resource, float density) + => RenderThroughPipeline(resource, density, s_frame); + + private static Bitmap RenderThroughPipeline( + Drawable.Resource resource, + float density, + PixelSize frame) + { + using var node = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(node, frame.ToSize(1), density)) + { + resource.GetOriginal()!.Render(context, resource); + } + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, frame.ToSize(1)), + OutputScale = density, + MaxWorkingScale = density, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + }); + + using RenderTarget target = CreateFrameTarget(density, frame); + using var canvas = new ImmediateCanvas(target, density, logicalSize: frame.ToSize(1)); + canvas.Clear(); + renderer.Render(canvas); + return target.Snapshot(); + } + + private static RenderTarget CreateFrameTarget(float density) + => CreateFrameTarget(density, s_frame); + + private static RenderTarget CreateFrameTarget(float density, PixelSize frame) + => RenderTarget.Create( + (int)MathF.Ceiling(frame.Width * density), + (int)MathF.Ceiling(frame.Height * density)) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); +} + +[SuppressResourceClassGeneration] +internal sealed partial class IdentityTypedShaderEffect : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => context.Shader(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NativeBackendWriteSurvivesTargetClearTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NativeBackendWriteSurvivesTargetClearTests.cs new file mode 100644 index 0000000000..d5afb01407 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NativeBackendWriteSurvivesTargetClearTests.cs @@ -0,0 +1,97 @@ +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A render target is cleared to transparent before it is handed out so no consumer can observe +/// uninitialised device memory. A custom effect that writes the target through the Vulkan backend does +/// so outside Skia's task graph, so the clear has to be submitted before that writer runs; an unflushed +/// clear lands on top of the effect's output and blanks it. +/// +[NonParallelizable] +[TestFixture] +public class NativeBackendWriteSurvivesTargetClearTests +{ + private const string InvertShader = """ + #version 450 + layout(location = 0) in vec2 vTexCoord; + layout(location = 0) out vec4 fragColor; + layout(binding = 0) uniform sampler2D uTexture; + void main() + { + vec4 src = texture(uTexture, vTexCoord); + fragColor = vec4(src.a - src.r, src.a - src.g, src.a - src.b, src.a); + } + """; + + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + public void GlslEffectOutput_IsNotBlankedByTheTargetClear(float outputScale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var effect = new GLSLScriptEffect(); + ScriptCompilationResult compilation = effect.ValidateScript(InvertShader); + Assert.That( + compilation.Status, + Is.EqualTo(ScriptCompilationStatus.Compiled), + $"the fixture's shader must compile, otherwise the effect degrades to a no-op: {compilation.Error}"); + effect.FragmentShader.CurrentValue = InvertShader; + + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = 120; + rectangle.Height.CurrentValue = 80; + rectangle.Fill.CurrentValue = new SolidColorBrush(Colors.OrangeRed); + rectangle.FilterEffect.CurrentValue = effect; + + var scene = new Scene(256, 144, "glsl-clear") + { + Uri = new Uri("file:///glsl-clear/scene"), + }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(4), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///glsl-clear/element"), + }; + element.AddObject(rectangle); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, outputScale, false, outputScale * 2f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); + using Bitmap bitmap = renderer.Snapshot(); + + long opaque = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int x = 3; x < row.Length; x += 4) + { + if ((float)BitConverter.UInt16BitsToHalf(row[x]) > 0.5f) + opaque++; + } + } + + Assert.That( + opaque, + Is.GreaterThan(0), + "the GLSL effect wrote the target through the Vulkan backend, so its output must survive " + + "the transparent clear the allocator issues."); + }); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NoTargetTransformOffsetInputTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NoTargetTransformOffsetInputTests.cs new file mode 100644 index 0000000000..3029d77033 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/NoTargetTransformOffsetInputTests.cs @@ -0,0 +1,182 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// Guards TransformEffect(ApplyToTarget=false) over effect inputs that are not origin-anchored, with no custom +// effect in the chain. The matrix filter is the one in-tree Skia item that is not translation-invariant, so it +// is the only one that can move a target's Bounds and OriginalBounds apart relative to each other, and it does +// so only where their positions already differ. Three properties of this scene put it there, and each one is +// load-bearing: +// +// - DrawableGroup pushes the filter-effect node outside OnDraw, so each child's own placement lands inside +// the effect's coordinate space. A bare Drawable pushes its placement outside the node instead, which +// leaves every effect input anchored at the origin and the case vacuous. +// - Two children give the segment several values. A single-value input whose items are all direct-replayable +// composes one SKImageFilter without the activator's pending-target frame, so one child never gets there. +// - Ordinary shapes keep the recorded bounds concrete, which routes FilterEffectContext.Transform down its +// non-deferred branch. +// +// The oracle is the same geometry driven through the group's own Transform, which builds its matrix about +// TransformOrigin over the same recorded content bounds the effect resolves its shared matrix from and +// carries no target re-anchoring at all. The children do not overlap, so compositing them separately cannot +// diverge from drawing them in one pass. +[NonParallelizable] +[TestFixture] +[Category("GpuPassFusionGpu")] +public class NoTargetTransformOffsetInputTests +{ + private static readonly PixelSize s_frame = new(240, 240); + + private static readonly Rect s_firstChild = new(40, 50, 80, 60); + private static readonly Rect s_secondChild = new(130, 110, 60, 50); + + private const float EffectRotation = 25f; + private const float EffectScaleX = 120f; + private const float EffectScaleY = 100f; + + // The effect resamples each rasterized child while the oracle transforms the recorded geometry, so the two + // differ at the rotated edges. SplitTransformEffectCombinationTests holds the same bound against the same + // kind of oracle. Measured on Vulkan: 0.9986. + private const double MinimumOracleSsim = 0.97; + + // The two transformed children together cover about a sixth of the frame. This floor only rules out a + // blank-versus-blank agreement; SSIM is what carries the regression signal. + private const double MinimumCoverageRatio = 0.05; + + [Test] + public void NoTargetTransform_OverOffsetGroupChildren_MatchesTheDrawableTransform() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap viaEffect = Render(MakeNoTargetTransform(), groupTransform: null); + using Bitmap viaDrawableTransform = Render(filterEffect: null, MakeEffectTransformGroup()); + using Bitmap untransformed = Render(filterEffect: null, groupTransform: null); + + double ssim = ImageMetrics.Ssim(viaEffect, viaDrawableTransform); + double mae = ImageMetrics.MeanAbsoluteError(viaEffect, viaDrawableTransform); + Point contentOffset = AlphaBoundsPosition(untransformed); + TestContext.WriteLine( + $"offset-input notarget transform vs oracle SSIM={ssim:F4} MAE={mae:F6} content offset={contentOffset}"); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("effect", viaEffect), ("oracle", viaDrawableTransform)), + Is.Null); + + // Without this the effect inputs could be origin-anchored and the case would be vacuous. + Assert.That( + contentOffset, + Is.EqualTo(s_firstChild.Position), + "the group's effect inputs must not be origin-anchored."); + + // Without this both sides could agree on a blank frame. + Assert.That( + CoveredPixelRatio(viaEffect), + Is.GreaterThan(MinimumCoverageRatio), + "the transformed children must produce substantial coverage."); + Assert.That( + ssim, + Is.GreaterThan(MinimumOracleSsim), + "the notarget transform diverged from its independently rendered oracle"); + }); + }); + } + + private static TransformEffect MakeNoTargetTransform() + { + var effect = new TransformEffect(); + effect.Transform.CurrentValue = MakeEffectTransformGroup(); + effect.TransformOrigin.CurrentValue = RelativePoint.Center; + effect.ApplyToTarget.CurrentValue = false; + return effect; + } + + private static TransformGroup MakeEffectTransformGroup() + { + var group = new TransformGroup(); + var rotation = new RotationTransform(); + rotation.Rotation.CurrentValue = EffectRotation; + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = EffectScaleX; + scale.ScaleY.CurrentValue = EffectScaleY; + group.Children.Add(rotation); + group.Children.Add(scale); + return group; + } + + private static Bitmap Render(FilterEffect? filterEffect, Transform? groupTransform) + { + var group = new DrawableGroup(); + group.Children.Add(MakeChild(s_firstChild, Colors.White)); + group.Children.Add(MakeChild(s_secondChild, Colors.Aqua)); + group.TransformOrigin.CurrentValue = RelativePoint.Center; + if (filterEffect != null) + group.FilterEffect.CurrentValue = filterEffect; + if (groupTransform != null) + group.Transform.CurrentValue = groupTransform; + + return GoldenImageHarness.RenderAtScale( + group.ToResource(CompositionContext.Default), s_frame, 1f, clearColor: Colors.Transparent); + } + + private static RectShape MakeChild(Rect placement, Color fill) + { + var translate = new TranslateTransform(); + translate.X.CurrentValue = placement.X; + translate.Y.CurrentValue = placement.Y; + + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Left; + shape.AlignmentY.CurrentValue = AlignmentY.Top; + shape.Width.CurrentValue = placement.Width; + shape.Height.CurrentValue = placement.Height; + shape.Fill.CurrentValue = new SolidColorBrush(fill); + shape.Transform.CurrentValue = translate; + return shape; + } + + private static Point AlphaBoundsPosition(Bitmap bitmap) + { + int minX = bitmap.Width; + int minY = bitmap.Height; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]) <= 0.5f) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + } + } + + Assert.That(minX, Is.LessThan(bitmap.Width), "the group rendered nothing"); + return new Point(minX, minY); + } + + private static double CoveredPixelRatio(Bitmap bitmap) + { + long covered = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]) > 0.5f) + covered++; + } + } + + return covered / ((double)bitmap.Width * bitmap.Height); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/OpaqueSourceCoverageTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/OpaqueSourceCoverageTests.cs new file mode 100644 index 0000000000..cd220d508b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/OpaqueSourceCoverageTests.cs @@ -0,0 +1,275 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class OpaqueSourceCoverageTests +{ + private static readonly PixelSize s_frame = new(200, 200); + + [TestCase(false)] + [TestCase(true)] + public void MaterializingAnOpaqueSource_KeepsItsAntialiasedCoverage(bool shifted) + { + using Drawable.Resource direct = CreateAliasingProneSource(shifted, legacyIdentityEffect: false); + using Drawable.Resource materialized = CreateAliasingProneSource(shifted, legacyIdentityEffect: true); + + using Bitmap expected = RenderProductionSource(direct, density: 1f, useRenderCache: false); + using Bitmap actual = RenderProductionSource(materialized, density: 1f, useRenderCache: false); + + AssertCoverageParity(expected, actual, $"aliasing-prone shifted={shifted}"); + } + + [Test] + public void ADrawableBrushRastersItsContentAtTheRequestedDensity() + { + using Drawable.Resource source = CreateDrawableBrushSource(); + using RenderNodeRasterization rasterization = RasterizeDrawableBrushContent( + source, + new Size(146, 82), + density: 2f); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(GetNonBlackExtent(rasterization.Bitmap!), Is.Not.EqualTo(default(PixelRect))); + }); + } + + [Test] + public void AFallbackBrushDrawsNothingWithoutFailingTheFrame() + { + using Drawable.Resource source = CreateFallbackBrushSource(); + + using Bitmap rendered = RenderProductionSource(source, density: 1f, useRenderCache: false); + + Assert.That( + GetNonBlackExtent(rendered), + Is.EqualTo(default(PixelRect)), + "A brush that could not be resolved must leave the frame untouched rather than paint a placeholder."); + } + + + private static RenderNodeRasterization RasterizeDrawableBrushContent( + Drawable.Resource drawable, + Size brushSize, + float density) + { + using var node = new DrawableRenderNode(drawable); + using (var context = new GraphicsContext2D(node, brushSize, density)) + { + drawable.GetOriginal()!.Render(context, drawable); + } + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + OutputScale = density, + MaxWorkingScale = density, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Auxiliary, + }, + }); + return renderer.Rasterize(); + } + + private static Bitmap RenderProductionSource( + Drawable.Resource resource, + float density, + bool useRenderCache) + { + using DrawableRenderNode node = CreateProductionNode(resource, density); + using var renderer = CreateRenderer(node, density, useRenderCache); + return RenderWithRenderer(renderer, density); + } + + private static DrawableRenderNode CreateProductionNode(Drawable.Resource resource, float density) + { + var node = new DrawableRenderNode(resource); + using var context = new GraphicsContext2D(node, s_frame.ToSize(1), density); + resource.GetOriginal()!.Render(context, resource); + return node; + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + float density, + bool useRenderCache) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, s_frame.ToSize(1)), + OutputScale = density, + MaxWorkingScale = density, + CacheOptions = new Beutl.Graphics.Rendering.Cache.RenderCacheOptions(useRenderCache, Beutl.Graphics.Rendering.Cache.RenderCacheRules.Default), + Purpose = RenderRequestPurpose.Frame, + }, + }); + + private static Bitmap RenderWithRenderer(RenderNodeRenderer renderer, float density) + { + int deviceWidth = (int)MathF.Ceiling(s_frame.Width * density); + int deviceHeight = (int)MathF.Ceiling(s_frame.Height * density); + using RenderTarget target = RenderTarget.Create(deviceWidth, deviceHeight) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, density, logicalSize: s_frame.ToSize(1)); + canvas.Clear(); + renderer.Render(canvas); + return target.Snapshot(); + } + + private static Drawable.Resource CreateAliasingProneSource(bool shifted, bool legacyIdentityEffect) + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.TransformOrigin.CurrentValue = RelativePoint.Center; + shape.Width.CurrentValue = 150; + shape.Height.CurrentValue = 18; + shape.Fill.CurrentValue = Brushes.White; + var transform = new TransformGroup(); + transform.Children.Add(new RotationTransform(27)); + if (shifted) + transform.Children.Add(new TranslateTransform(0.25f, 0.5f)); + shape.Transform.CurrentValue = transform; + if (legacyIdentityEffect) + { + var effect = new TransformEffect(); + effect.Transform.CurrentValue = new MatrixTransform(Matrix.Identity); + shape.FilterEffect.CurrentValue = effect; + } + + return shape.ToResource(CompositionContext.Default); + } + + private static Drawable.Resource CreateDrawableBrushSource() + { + var content = new RectShape(); + content.AlignmentX.CurrentValue = AlignmentX.Center; + content.AlignmentY.CurrentValue = AlignmentY.Center; + content.Width.CurrentValue = 76; + content.Height.CurrentValue = 44; + content.Fill.CurrentValue = Brushes.White; + + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.TransformOrigin.CurrentValue = RelativePoint.Center; + shape.Width.CurrentValue = 146; + shape.Height.CurrentValue = 82; + shape.Fill.CurrentValue = brush; + var transform = new TransformGroup(); + transform.Children.Add(new RotationTransform(27)); + transform.Children.Add(new TranslateTransform(0.25f, 0.5f)); + shape.Transform.CurrentValue = transform; + return shape.ToResource(CompositionContext.Default); + } + + private static Drawable.Resource CreateFallbackBrushSource() + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.TransformOrigin.CurrentValue = RelativePoint.Center; + shape.Width.CurrentValue = 146; + shape.Height.CurrentValue = 82; + shape.Fill.CurrentValue = new FallbackBrush(); + var transform = new TransformGroup(); + transform.Children.Add(new RotationTransform(27)); + transform.Children.Add(new TranslateTransform(0.25f, 0.5f)); + shape.Transform.CurrentValue = transform; + return shape.ToResource(CompositionContext.Default); + } + + private static void AssertCoverageParity(Bitmap expected, Bitmap actual, string scenario) + { + RgbaMaximumError maximum = ImageMetrics.MaximumAbsoluteErrorPerChannel(expected, actual); + RgbaMaximumError edgeMaximum = ImageMetrics.EdgeBandMaximumAbsoluteErrorPerChannel(expected, actual); + PixelRect expectedExtent = GetNonBlackExtent(expected); + int fractionalReferencePixels = CountFractionalAlphaPixels(expected); + TestContext.WriteLine( + $"{scenario}: extent={GetNonBlackExtent(actual)}, max={maximum.Maximum:F6}, edge-max={edgeMaximum.Maximum:F6}"); + + Assert.Multiple(() => + { + Assert.That(actual.Width, Is.EqualTo(expected.Width)); + Assert.That(actual.Height, Is.EqualTo(expected.Height)); + Assert.That(expectedExtent, Is.Not.EqualTo(default(PixelRect)), + "The antialiased reference must have nonempty visible coverage."); + Assert.That(fractionalReferencePixels, Is.GreaterThan(0), + "The antialiased reference must contain fractional-alpha edge coverage."); + Assert.That( + GetNonBlackExtent(actual), + Is.EqualTo(expectedExtent), + "Materialization must retain the complete antialiased coverage fringe."); + Assert.That(maximum.Maximum, Is.LessThanOrEqualTo(0.02), + "Materialization introduced a visible whole-frame pixel error."); + Assert.That(edgeMaximum.Maximum, Is.LessThanOrEqualTo(0.02), + "Materialization changed an antialiased edge pixel."); + }); + } + + private static int CountFractionalAlphaPixels(Bitmap bitmap) + { + int count = 0; + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + for (int index = 3; index < pixels.Length; index += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + Assert.That(float.IsFinite(alpha), Is.True, "Reference alpha must be finite."); + if (alpha > 0 && alpha < 1) + count++; + } + + return count; + } + + private static PixelRect GetNonBlackExtent(Bitmap bitmap) + { + int minX = bitmap.Width; + int minY = bitmap.Height; + int maxX = -1; + int maxY = -1; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + var color = bitmap.SKBitmap.GetPixel(x, y); + if (color.Red <= 1 && color.Green <= 1 && color.Blue <= 1) + continue; + + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + if (maxX < minX) + return default; + return new PixelRect(minX, minY, maxX - minX + 1, maxY - minY + 1); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/PerspectiveNearPlaneCrossingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/PerspectiveNearPlaneCrossingTests.cs new file mode 100644 index 0000000000..955b6d8688 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/PerspectiveNearPlaneCrossingTests.cs @@ -0,0 +1,279 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class PerspectiveNearPlaneCrossingTests +{ + private static readonly PixelSize s_frame = new(256, 144); + + // At the default Depth of 500 any layer wider than the frame goes past the camera plane at ~31 degrees + // of Y rotation, so this is the shape the defect takes in an ordinary project. + [Test] + [Category("GpuPassFusionGpu")] + public void DefaultDepth_WideLayerRotatedPastTheCameraPlane_MatchesTheAnalyticImage() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + const float Width = 1200f; + const float Height = 54f; + using Drawable.Resource straddling = CreateRotatedRect(Width, Height, 60f, depth: 500f) + .ToResource(CompositionContext.Default); + using Bitmap bitmap = GoldenImageHarness.RenderAtScale( + straddling, s_frame, 1f, clearColor: Colors.Transparent); + + Matrix matrix = ComposeCenteredRotation(Width, Height, 60f, depth: 500f); + AnalyticAgreement agreement = CompareWithAnalyticImage(bitmap, matrix, Width, Height); + TestContext.WriteLine( + $"[wdefault 1200x54 @60deg depth500] rendered={CountCoveredPixels(bitmap)} " + + $"analytic={agreement.AnalyticCovered} missing={agreement.MissingFromRender} " + + $"agreement={agreement.Ratio:P3}"); + Assert.Multiple(() => + { + Assert.That(agreement.AnalyticCovered, Is.GreaterThan(8000), + "the fixture must put a substantial wedge inside the frame"); + Assert.That(agreement.MissingFromRender, Is.LessThan(agreement.AnalyticCovered / 100), + "the render dropped part of the front half of a camera-plane-crossing layer"); + Assert.That(agreement.Ratio, Is.GreaterThan(0.99), + "the render must reproduce the near-plane-clipped image, not a mirrored one"); + }); + }); + } + + /// + /// Rotated near edge-on, the same layer's w = 0.05 image line lands inside the frame, so a scope + /// declaring bounds cut a wedge the rasterizer draws - thousands of + /// frame pixels the viewer should have seen. A transform now declares + /// , which is exact wherever the request delivers, so the + /// wedge survives. See PerspectiveNearPlaneResidualTests for what the bare default still gives up. + /// + [Test] + [Category("GpuPassFusionGpu")] + public void DefaultDepth_NearEdgeOnRotation_DrawsTheWedgeThePragmaticBoundsExcluded() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + const float Width = 1200f; + const float Height = 54f; + const float RotationY = 89.5f; + using Drawable.Resource straddling = CreateRotatedRect(Width, Height, RotationY, depth: 500f) + .ToResource(CompositionContext.Default); + using Bitmap bitmap = GoldenImageHarness.RenderAtScale( + straddling, s_frame, 1f, clearColor: Colors.Transparent); + + Matrix matrix = ComposeCenteredRotation(Width, Height, RotationY, depth: 500f); + Rect declared = new Rect(0, 0, Width, Height).TransformToAABB(matrix); + NearPlaneResidual residual = MeasureResidual(bitmap, matrix, Width, Height, declared); + TestContext.WriteLine( + $"[wdefault 1200x54 @89.5deg depth500] declared={declared} " + + $"inside={residual.InsideDeclared} insideMissing={residual.InsideDeclaredMissing} " + + $"outside={residual.OutsideDeclared} outsideRendered={residual.OutsideDeclaredRendered}"); + Assert.Multiple(() => + { + Assert.That(residual.InsideDeclaredMissing, Is.LessThan(residual.InsideDeclared / 100), + "everything the pragmatic bounds do cover must still be drawn"); + Assert.That(residual.OutsideDeclared, Is.GreaterThan(6000), + "the fixture must put a substantial wedge outside the pragmatic bounds"); + Assert.That( + residual.OutsideDeclaredRendered, + Is.GreaterThan(residual.OutsideDeclared - (residual.OutsideDeclared / 100)), + "the wedge outside the pragmatic bounds is inside the frame, so it must be drawn"); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void NestedGroupTranslate_StraddlingChild_KeepsContentAtTheFrameOrigin() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var group = new DrawableGroup(); + group.Children.Add(CreateRotatedRect(120, 54, 60f, depth: 50f)); + group.Transform.CurrentValue = new TranslateTransform(60f, 0f); + using Drawable.Resource nested = group.ToResource(CompositionContext.Default); + using Bitmap bitmap = GoldenImageHarness.RenderAtScale( + nested, s_frame, 1f, clearColor: Colors.Transparent); + int covered = CountCoveredPixels(bitmap); + int leftmost = LeftmostCoveredColumn(bitmap); + TestContext.WriteLine($"[nested group-t60 depth50] covered={covered} leftmost={leftmost}"); + Assert.Multiple(() => + { + Assert.That(covered, Is.GreaterThan(8000), + "the straddling child must survive the group's own transform scope"); + Assert.That(leftmost, Is.Zero, + "a root-space bound would clip the wedge at the group's translate, not at the frame edge"); + }); + }); + } + + private static RectShape CreateRotatedRect(float width, float height, float rotationY, float depth) + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = width; + shape.Height.CurrentValue = height; + shape.Fill.CurrentValue = Brushes.White; + shape.Transform.CurrentValue = new Rotation3DTransform(0f, rotationY, 0f, 0f, 0f, 0f) + { + Depth = { CurrentValue = depth }, + }; + return shape; + } + + /// + /// Rebuilds the matrix collapses a centre-aligned + /// into, so the expected image is derived independently of the + /// renderer rather than recorded from it. + /// + private static Matrix ComposeCenteredRotation(float width, float height, float rotationY, float depth) + { + float radians = MathF.PI * rotationY / 180f; + var rotation = new Matrix( + MathF.Cos(radians), 0, MathF.Sin(radians) / depth, + 0, 1, 0, + 0, 0, 1); + return Matrix.CreateTranslation(-width / 2, -height / 2) + * rotation + * Matrix.CreateTranslation(s_frame.Width / 2f, s_frame.Height / 2f); + } + + /// + /// Back-projects every device pixel centre through , keeps it only when it + /// recovers a positive homogeneous divisor (in front of the camera plane) and lands inside the local + /// rectangle, and compares that mask against the render. + /// + private static AnalyticAgreement CompareWithAnalyticImage( + Bitmap bitmap, Matrix matrix, float width, float height) + { + Matrix inverse = matrix.Invert(); + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int analyticCovered = 0; + int missing = 0; + int agreeing = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + var device = new Point(x + 0.5f, y + 0.5f); + Point local = device.Transform(inverse); + float recoveredDivisor = + (device.X * inverse.M13) + (device.Y * inverse.M23) + inverse.M33; + bool expected = recoveredDivisor > 0 + && local.X >= 0 && local.X <= width + && local.Y >= 0 && local.Y <= height; + float alpha = (float)BitConverter.UInt16BitsToHalf( + pixels[(((y * bitmap.Width) + x) * 4) + 3]); + bool actual = alpha > 0.5f; + if (expected) analyticCovered++; + if (expected && !actual) missing++; + if (expected == actual) agreeing++; + } + } + + return new AnalyticAgreement( + analyticCovered, missing, (double)agreeing / (bitmap.Width * bitmap.Height)); + } + + /// + /// Splits the rasterizer's own image — the analytic mask bounded at + /// rather than at the divisor the declared bounds clip at — by the device pixel rect + /// becomes, and reports how much of each half the render actually drew. + /// + private static NearPlaneResidual MeasureResidual( + Bitmap bitmap, Matrix matrix, float width, float height, Rect declared) + { + Matrix inverse = matrix.Invert(); + // The planner rasterizes the declared bounds with a one-pixel apron, so that column is drawn too. + PixelRect clip = RenderScaleUtilities.AddRasterApron(PixelRect.FromRect(declared, 1f)); + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int insideDeclared = 0; + int insideDeclaredMissing = 0; + int outsideDeclared = 0; + int outsideDeclaredRendered = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + var device = new Point(x + 0.5f, y + 0.5f); + float recoveredDivisor = inverse.GetTransformDivisor(device); + if (recoveredDivisor <= 0 || recoveredDivisor > 1f / Rect.RasterizerNearPlane) + continue; + + Point local = device.Transform(inverse); + if (local.X < 0 || local.X > width || local.Y < 0 || local.Y > height) + continue; + + float alpha = (float)BitConverter.UInt16BitsToHalf( + pixels[(((y * bitmap.Width) + x) * 4) + 3]); + bool rendered = alpha > 0.5f; + bool covered = clip.Contains(new PixelPoint(x, y)); + if (covered) + { + insideDeclared++; + if (!rendered) insideDeclaredMissing++; + } + else + { + outsideDeclared++; + if (rendered) outsideDeclaredRendered++; + } + } + } + + return new NearPlaneResidual( + insideDeclared, insideDeclaredMissing, outsideDeclared, outsideDeclaredRendered); + } + + private static int CountCoveredPixels(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int count = 0; + for (int i = 3; i < pixels.Length; i += 4) + { + if ((float)BitConverter.UInt16BitsToHalf(pixels[i]) > 0.5f) + count++; + } + + return count; + } + + private static int LeftmostCoveredColumn(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int leftmost = bitmap.Width; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < leftmost; x++) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[(((y * bitmap.Width) + x) * 4) + 3]); + if (alpha > 0.5f) + { + leftmost = x; + break; + } + } + } + + return leftmost; + } + + private readonly record struct AnalyticAgreement(int AnalyticCovered, int MissingFromRender, double Ratio); + + private readonly record struct NearPlaneResidual( + int InsideDeclared, + int InsideDeclaredMissing, + int OutsideDeclared, + int OutsideDeclaredRendered); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/RenderScaleBenchmarkTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/RenderScaleBenchmarkTests.cs index d413b16b74..ba8f46ef79 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/RenderScaleBenchmarkTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/RenderScaleBenchmarkTests.cs @@ -16,6 +16,8 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; [TestFixture] public class RenderScaleBenchmarkTests { + private const int ScheduleSeed = 20040719; + private const double RasterizationBoundTarget = 0.25; private static readonly PixelSize Frame = new(1280, 720); private static Drawable.Resource MakeWork() @@ -33,36 +35,271 @@ private static Drawable.Resource MakeWork() return shape.ToResource(CompositionContext.Default); } - private static double MedianRenderMs(float scale, int iterations) + private static BenchmarkMeasurement MeasureRenderMedians(int seed) { - var samples = new double[iterations]; - for (int i = 0; i < iterations; i++) + using var fullSession = new BenchmarkRenderSession(MakeWork(), Frame, 1f); + using var halfSession = new BenchmarkRenderSession(MakeWork(), Frame, 0.5f); + for (int i = 0; i < 3; i++) { - var sw = Stopwatch.StartNew(); - using Bitmap b = GoldenImageHarness.RenderAtScale(MakeWork(), Frame, scale); - sw.Stop(); - samples[i] = sw.Elapsed.TotalMilliseconds; + using Bitmap fullWarmup = fullSession.Render(); + using Bitmap halfWarmup = halfSession.Render(); } - Array.Sort(samples); - return samples[iterations / 2]; + RenderPairOrder[] schedule = CreateSchedule(seed); + var fullSamples = new double[schedule.Length]; + var halfSamples = new double[schedule.Length]; + for (int i = 0; i < schedule.Length; i++) + { + if (schedule[i] == RenderPairOrder.HalfThenFull) + { + halfSamples[i] = MeasureRenderMs(halfSession); + fullSamples[i] = MeasureRenderMs(fullSession); + } + else + { + fullSamples[i] = MeasureRenderMs(fullSession); + halfSamples[i] = MeasureRenderMs(halfSession); + } + } + + PairOutcome outcome = SummarizePairOutcomes(fullSamples, halfSamples); + Array.Sort(fullSamples); + Array.Sort(halfSamples); + return new BenchmarkMeasurement( + FullMedian: fullSamples[schedule.Length / 2], + HalfMedian: halfSamples[schedule.Length / 2], + outcome.HalfWins, + outcome.Ties, + schedule); + } + + internal static RenderPairOrder[] CreateSchedule(int seed) + { + var random = new ScheduleRandom(unchecked((uint)seed)); + var schedule = new RenderPairOrder[11]; + Array.Fill(schedule, RenderPairOrder.HalfThenFull, 0, 5); + Array.Fill(schedule, RenderPairOrder.FullThenHalf, 5, 5); + schedule[^1] = random.Next(2) == 0 + ? RenderPairOrder.HalfThenFull + : RenderPairOrder.FullThenHalf; + + for (int index = schedule.Length - 1; index > 0; index--) + { + int replacement = random.Next(index + 1); + (schedule[index], schedule[replacement]) = (schedule[replacement], schedule[index]); + } + + return schedule; + } + + internal static PairOutcome SummarizePairOutcomes( + IReadOnlyList fullSamples, + IReadOnlyList halfSamples) + { + if (fullSamples.Count != halfSamples.Count) + throw new ArgumentException("Paired benchmark sample counts must match."); + + int halfWins = 0; + int ties = 0; + for (int index = 0; index < fullSamples.Count; index++) + { + if (halfSamples[index] < fullSamples[index]) + halfWins++; + else if (halfSamples[index] == fullSamples[index]) + ties++; + } + + return new PairOutcome(halfWins, ties); + } + + private static double MeasureRenderMs(BenchmarkRenderSession session) + { + var stopwatch = Stopwatch.StartNew(); + using Bitmap bitmap = session.Render(); + stopwatch.Stop(); + return stopwatch.Elapsed.TotalMilliseconds; } [Test] [Explicit("timing-sensitive")] - public void HalfScale_IsMateriallyFaster() + public void HalfScale_IsSignificantlyFaster() { VulkanTestEnvironment.EnsureAvailable(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - // Warm up the pipeline / shader cache. - MedianRenderMs(1f, 3); - - double full = MedianRenderMs(1f, 11); - double half = MedianRenderMs(0.5f, 11); - double ratio = half / full; - TestContext.WriteLine($"render median: 1.0={full:F2}ms 0.5={half:F2}ms ratio={ratio:F3}"); - Assert.That(ratio, Is.LessThan(0.6), $"0.5x/1.0x render-time ratio {ratio:F3} not < 0.6"); + BenchmarkMeasurement measurement = MeasureRenderMedians(ScheduleSeed); + double ratio = measurement.HalfMedian / measurement.FullMedian; + string realizedOrder = string.Join( + ", ", + measurement.Schedule.Select(static item => item == RenderPairOrder.HalfThenFull + ? "0.5/1.0" + : "1.0/0.5")); + TestContext.WriteLine( + $"render median: 1.0={measurement.FullMedian:F2}ms 0.5={measurement.HalfMedian:F2}ms " + + $"ratio={ratio:F3} faster-pairs={measurement.HalfWins}/11 ties={measurement.Ties} " + + $"seed={ScheduleSeed} order=[{realizedOrder}] " + + $"rasterization-bound-target≈{RasterizationBoundTarget:F2}"); + using (Assert.EnterMultipleScope()) + { + Assert.That( + measurement.HalfWins, + Is.GreaterThanOrEqualTo(9), + $"0.5x was faster in only {measurement.HalfWins}/11 pairs with {measurement.Ties} ties; " + + "a one-sided exact sign test requires at least 9/11 for p < 0.05"); + // Half scale shades one quarter as many pixels, but this short benchmark also includes + // fixed planner and readback cost. Requiring a 15% median reduction rejects a nominal + // 0.99x result while remaining stable when fixed work dominates the measured interval. + Assert.That( + ratio, + Is.LessThan(0.85), + $"the half-scale median was {ratio:F3}x full scale, which is not a material reduction"); + } + }); + } + + [Test] + public void Schedule_HasFiveOrdersEachAndOneSeedSelectedOrder() + { + RenderPairOrder[] schedule = CreateSchedule(ScheduleSeed); + + int halfFirst = schedule.Count(static item => item == RenderPairOrder.HalfThenFull); + int fullFirst = schedule.Count(static item => item == RenderPairOrder.FullThenHalf); + Assert.Multiple(() => + { + Assert.That(schedule, Has.Length.EqualTo(11)); + Assert.That(Math.Min(halfFirst, fullFirst), Is.EqualTo(5)); + Assert.That(Math.Max(halfFirst, fullFirst), Is.EqualTo(6)); }); } + + [Test] + public void Schedule_IsReproducibleForThePinnedSeed() + { + Assert.That(CreateSchedule(ScheduleSeed), Is.EqualTo(CreateSchedule(ScheduleSeed))); + } + + [Test] + public void Schedule_UsesTheSeededPermutation() + { + Assert.That( + CreateSchedule(ScheduleSeed), + Is.EqualTo(new[] + { + RenderPairOrder.HalfThenFull, + RenderPairOrder.HalfThenFull, + RenderPairOrder.FullThenHalf, + RenderPairOrder.FullThenHalf, + RenderPairOrder.FullThenHalf, + RenderPairOrder.FullThenHalf, + RenderPairOrder.HalfThenFull, + RenderPairOrder.HalfThenFull, + RenderPairOrder.FullThenHalf, + RenderPairOrder.FullThenHalf, + RenderPairOrder.HalfThenFull, + })); + } + + [Test] + public void PairOutcome_ReportsTiesAsNonWins() + { + PairOutcome outcome = SummarizePairOutcomes( + fullSamples: new[] { 3d, 2d, 1d, 4d }, + halfSamples: new[] { 2d, 2d, 3d, 4d }); + + Assert.Multiple(() => + { + Assert.That(outcome.HalfWins, Is.EqualTo(1)); + Assert.That(outcome.Ties, Is.EqualTo(2)); + }); + } + + internal enum RenderPairOrder : byte + { + HalfThenFull, + FullThenHalf, + } + + internal readonly record struct PairOutcome(int HalfWins, int Ties); + + private readonly record struct BenchmarkMeasurement( + double FullMedian, + double HalfMedian, + int HalfWins, + int Ties, + IReadOnlyList Schedule); + + private struct ScheduleRandom + { + private uint _state; + + public ScheduleRandom(uint seed) + { + _state = seed == 0 ? 0x9E3779B9u : seed; + } + + public int Next(int exclusiveMaximum) + { + uint value = _state; + value ^= value << 13; + value ^= value >> 17; + value ^= value << 5; + _state = value; + return (int)(value % (uint)exclusiveMaximum); + } + } + + private sealed class BenchmarkRenderSession : IDisposable + { + private readonly DrawableRenderNode _node; + private readonly RenderNodeRenderer _renderer; + private readonly Drawable.Resource _resource; + private readonly PixelSize _deviceSize; + private readonly Size _logicalSize; + private readonly float _scale; + + public BenchmarkRenderSession(Drawable.Resource resource, PixelSize logicalSize, float scale) + { + _resource = resource; + _logicalSize = logicalSize.ToSize(1); + _deviceSize = new PixelSize( + (int)MathF.Ceiling(logicalSize.Width * scale), + (int)MathF.Ceiling(logicalSize.Height * scale)); + _scale = scale; + _node = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(_node, _logicalSize, scale)) + { + resource.GetOriginal()!.Render(context, resource); + } + + _renderer = new RenderNodeRenderer( + _node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Delivery, + TargetDomain = new Rect(default, _logicalSize), + OutputScale = scale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + } + + public Bitmap Render() + { + using RenderTarget target = RenderTarget.Create(_deviceSize.Width, _deviceSize.Height) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var canvas = new ImmediateCanvas(target, _scale, logicalSize: _logicalSize); + canvas.Clear(Colors.Black); + _renderer.Render(canvas); + return target.Snapshot(); + } + + public void Dispose() + { + _renderer.Dispose(); + _node.Dispose(); + _resource.Dispose(); + } + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStore.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStore.cs new file mode 100644 index 0000000000..27ab10f477 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStore.cs @@ -0,0 +1,190 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// Reads and writes immutable, row-packed, linear-premultiplied RGBA16F golden images. +/// +/// +/// The artifact is the raw payload required by the evidence contract: row-major RGBA half-float bit patterns +/// encoded little-endian, without a header. Dimensions live in the provenance manifest and are required when +/// reading. Row padding and host byte order never enter the artifact. +/// +internal static class Rgba16fGoldenStore +{ + private const int BytesPerChannel = sizeof(ushort); + private const int ChannelCount = 4; + private const int BytesPerPixel = BytesPerChannel * ChannelCount; + + public const string Extension = ".rgba16f"; + + /// + /// Writes a new golden artifact. An existing artifact is never replaced. + /// + public static void Write(string path, Bitmap bitmap) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(bitmap); + EnsureCanonicalBitmap(bitmap, nameof(bitmap)); + + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath)!; + Directory.CreateDirectory(directory); + + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 64 * 1024, + FileOptions.SequentialScan)) + { + WritePixels(stream, bitmap); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, fullPath, overwrite: false); + } + finally + { + File.Delete(temporaryPath); + } + } + + /// + /// Reads a raw golden artifact into an owned linear-premultiplied RGBA16F bitmap. + /// + public static Bitmap Read(string path, int width, int height) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (width <= 0) + throw new ArgumentOutOfRangeException(nameof(width)); + if (height <= 0) + throw new ArgumentOutOfRangeException(nameof(height)); + + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.SequentialScan); + + long payloadLength; + try + { + payloadLength = checked((long)width * height * BytesPerPixel); + } + catch (OverflowException ex) + { + throw new InvalidDataException($"RGBA16F golden dimensions overflow in {path}.", ex); + } + + if (stream.Length != payloadLength) + { + throw new InvalidDataException( + $"RGBA16F golden length mismatch in {path}: expected {payloadLength} bytes for {width}x{height}, " + + $"found {stream.Length}."); + } + + var bitmap = new Bitmap( + width, + height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + + try + { + ReadPixels(stream, bitmap, path); + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + + /// Computes the lowercase SHA-256 digest of the complete canonical artifact. + public static string ComputeSha256(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + using FileStream stream = File.OpenRead(path); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + + private static void WritePixels(Stream stream, Bitmap bitmap) + { + byte[] encodedRow = GC.AllocateUninitializedArray(checked(bitmap.Width * BytesPerPixel)); + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan source = bitmap.GetRow(y); + for (int i = 0; i < source.Length; i++) + { + BinaryPrimitives.WriteUInt16LittleEndian(encodedRow.AsSpan(i * BytesPerChannel), source[i]); + } + + stream.Write(encodedRow); + } + } + + private static void ReadPixels(Stream stream, Bitmap bitmap, string path) + { + byte[] encodedRow = GC.AllocateUninitializedArray(checked(bitmap.Width * BytesPerPixel)); + for (int y = 0; y < bitmap.Height; y++) + { + ReadExactly(stream, encodedRow, path); + Span destination = bitmap.GetRow(y); + for (int i = 0; i < destination.Length; i++) + { + destination[i] = BinaryPrimitives.ReadUInt16LittleEndian(encodedRow.AsSpan(i * BytesPerChannel)); + } + } + } + + private static void ReadExactly(Stream stream, Span destination, string path) + { + int read = 0; + while (read < destination.Length) + { + int count = stream.Read(destination[read..]); + if (count == 0) + { + throw new InvalidDataException( + $"Truncated RGBA16F golden artifact {path}: read {read} of {destination.Length} requested bytes."); + } + + read += count; + } + } + + private static void EnsureCanonicalBitmap(Bitmap bitmap, string parameterName) + { + if (bitmap.Width <= 0 || bitmap.Height <= 0) + { + throw new ArgumentException( + "Golden images must have positive dimensions.", + parameterName); + } + + if (bitmap.ColorType != BitmapColorType.RgbaF16 + || bitmap.AlphaType != BitmapAlphaType.Premul + || bitmap.ColorSpace != BitmapColorSpace.LinearSrgb) + { + throw new ArgumentException( + "Golden images must use linear-sRGB, premultiplied RgbaF16 pixels.", + parameterName); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStoreTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStoreTests.cs new file mode 100644 index 0000000000..ed57a6cdc0 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/Rgba16fGoldenStoreTests.cs @@ -0,0 +1,171 @@ +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[TestFixture] +public sealed class Rgba16fGoldenStoreTests +{ + private string _temporaryDirectory = null!; + + [SetUp] + public void SetUp() + { + _temporaryDirectory = Path.Combine( + Path.GetTempPath(), + $"beutl-rgba16f-golden-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_temporaryDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_temporaryDirectory)) + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [Test] + public void WriteAndRead_RoundTripsRawHalfBitsAndMetadata() + { + const int width = 3; + const int height = 2; + using var source = new Bitmap( + width, + height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + + for (int y = 0; y < height; y++) + { + Span row = source.GetRow(y); + for (int i = 0; i < row.Length; i++) + { + row[i] = unchecked((ushort)(0x1000 + y * row.Length + i)); + } + } + + string path = Path.Combine(_temporaryDirectory, "round-trip" + Rgba16fGoldenStore.Extension); + Rgba16fGoldenStore.Write(path, source); + using Bitmap restored = Rgba16fGoldenStore.Read(path, width, height); + + Assert.Multiple(() => + { + Assert.That(new FileInfo(path).Length, Is.EqualTo(width * height * 8)); + Assert.That(restored.Width, Is.EqualTo(width)); + Assert.That(restored.Height, Is.EqualTo(height)); + Assert.That(restored.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + Assert.That(restored.AlphaType, Is.EqualTo(BitmapAlphaType.Premul)); + Assert.That(restored.ColorSpace, Is.EqualTo(BitmapColorSpace.LinearSrgb)); + }); + + for (int y = 0; y < height; y++) + { + Assert.That(restored.GetRow(y).ToArray(), Is.EqualTo(source.GetRow(y).ToArray())); + } + } + + [Test] + public void Write_UsesHeaderlessLittleEndianPayload_AndHashesCompleteBlob() + { + using var source = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + Span pixel = source.GetRow(0); + pixel[0] = BitConverter.HalfToUInt16Bits((Half)0.5f); + pixel[1] = BitConverter.HalfToUInt16Bits((Half)0.25f); + pixel[2] = BitConverter.HalfToUInt16Bits((Half)0f); + pixel[3] = BitConverter.HalfToUInt16Bits((Half)1f); + + string path = Path.Combine(_temporaryDirectory, "canonical" + Rgba16fGoldenStore.Extension); + Rgba16fGoldenStore.Write(path, source); + + Assert.Multiple(() => + { + Assert.That( + File.ReadAllBytes(path), + Is.EqualTo(new byte[] { 0x00, 0x38, 0x00, 0x34, 0x00, 0x00, 0x00, 0x3c })); + Assert.That( + Rgba16fGoldenStore.ComputeSha256(path), + Is.EqualTo("0def1baa18cbddd7a49b5460d10dd76b2131885086197380111c3e3ed51408a9")); + }); + } + + [Test] + public void Write_ExistingArtifactIsNeverReplaced() + { + using var original = CreateFlat(0.25f); + using var replacement = CreateFlat(0.75f); + string path = Path.Combine(_temporaryDirectory, "immutable" + Rgba16fGoldenStore.Extension); + + Rgba16fGoldenStore.Write(path, original); + byte[] frozen = File.ReadAllBytes(path); + + Assert.That(() => Rgba16fGoldenStore.Write(path, replacement), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(frozen)); + Assert.That(Directory.GetFiles(_temporaryDirectory, "*.tmp", SearchOption.TopDirectoryOnly), Is.Empty); + } + + [Test] + public void Read_RejectsLengthThatDoesNotMatchManifestDimensions() + { + using var source = CreateFlat(0.5f); + string path = Path.Combine(_temporaryDirectory, "length" + Rgba16fGoldenStore.Extension); + Rgba16fGoldenStore.Write(path, source); + + Assert.That(() => Rgba16fGoldenStore.Read(path, 2, 1), Throws.TypeOf()); + } + + [Test] + public void Write_RejectsNoncanonicalBitmapMetadata() + { + using var unpremultiplied = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Unpremul, + BitmapColorSpace.LinearSrgb); + string path = Path.Combine(_temporaryDirectory, "invalid" + Rgba16fGoldenStore.Extension); + + Assert.That( + () => Rgba16fGoldenStore.Write(path, unpremultiplied), + Throws.ArgumentException.With.Property("ParamName").EqualTo("bitmap")); + Assert.That(File.Exists(path), Is.False); + } + + [TestCase(0, 1)] + [TestCase(1, 0)] + public void Write_RejectsNonpositiveBitmapDimensions(int width, int height) + { + using var empty = new Bitmap( + width, + height, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + string path = Path.Combine(_temporaryDirectory, "empty" + Rgba16fGoldenStore.Extension); + + Assert.That( + () => Rgba16fGoldenStore.Write(path, empty), + Throws.ArgumentException.With.Property("ParamName").EqualTo("bitmap")); + Assert.Multiple(() => + { + Assert.That(File.Exists(path), Is.False); + Assert.That(Directory.GetFiles(_temporaryDirectory), Is.Empty); + }); + } + + private static Bitmap CreateFlat(float value) + { + var bitmap = new Bitmap( + 1, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + bitmap.GetRow(0).Fill(BitConverter.HalfToUInt16Bits((Half)value)); + return bitmap; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShaderMatrixUniformTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShaderMatrixUniformTests.cs new file mode 100644 index 0000000000..c280eb1766 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShaderMatrixUniformTests.cs @@ -0,0 +1,245 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// SkSL reads matrix uniform data column-major. A canonical SKMatrix value that kept Skia's row-major storage +// order would reach the shader transposed, which for an affine matrix silently drops the translation column. +[NonParallelizable] +[TestFixture] +public class ShaderMatrixUniformTests +{ + private const int Width = 200; + private const int Height = 200; + + // Chosen so the shifted rect stays fully inside the frame and no probe lands on an antialiased edge. + private const int TranslationX = 60; + + [Test] + [Category("GpuPassFusionGpu")] + public void SkMatrixUniform_TranslatesSampledCoordinatesByItsTranslationColumn() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var identity = new SampleOffsetNode(SKMatrix.CreateIdentity()); + using var translated = new SampleOffsetNode(SKMatrix.CreateTranslation(TranslationX, 0)); + + using Bitmap unshifted = Render(identity); + using Bitmap shifted = Render(translated); + + (int dx, int dy, double error) = BestIntegerShift(unshifted, shifted, 96); + TestContext.WriteLine( + $"identity coverage={CoveredPixelCount(unshifted)} shifted coverage={CoveredPixelCount(shifted)} " + + $"best shift=({dx},{dy}) error={error:F6} error@0={ShiftedMeanAbsoluteError(unshifted, shifted, 0, 0):F6}"); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("unshifted", unshifted), ("shifted", shifted)), + Is.Null); + + // Non-vacuity: an empty source would match at every candidate shift. + Assert.That( + CoveredPixelCount(unshifted), + Is.GreaterThan(Width * Height / 8), + "the identity-matrix render must contain substantial opaque coverage."); + + // A dropped translation column leaves the best match at (0, 0). + Assert.That(dx, Is.EqualTo(-TranslationX)); + Assert.That(dy, Is.Zero); + + // An integer translation resolves to exact texel fetches; measured 0.000000 on Vulkan. + Assert.That(error, Is.LessThan(0.005)); + }); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void SkMatrixUniform_MatchesAnExplicitColumnMajorFloatSequence() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var matrix = SKMatrix.CreateScaleTranslation(0.5f, 2f, TranslationX, 24); + using var viaMatrix = new SampleOffsetNode(matrix); + using var viaFloats = new SampleOffsetNode( + [ + matrix.ScaleX, matrix.SkewY, matrix.Persp0, + matrix.SkewX, matrix.ScaleY, matrix.Persp1, + matrix.TransX, matrix.TransY, matrix.Persp2, + ]); + + using Bitmap fromMatrix = Render(viaMatrix); + using Bitmap fromFloats = Render(viaFloats); + + GoldenImageHarness.AssertByteIdentical(fromFloats, fromMatrix); + }); + } + + private static Bitmap Render(RenderNode node) + { + using RenderTarget target = RenderTarget.Create(Width, Height) + ?? throw new InvalidOperationException("Could not allocate the matrix-uniform target."); + using (var canvas = new ImmediateCanvas(target, 1, 1, new Size(Width, Height))) + { + canvas.Clear(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, Width, Height), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + /// + /// Returns the integer shift within that best aligns + /// onto , together with the mean absolute error at that shift. + /// + private static (int Dx, int Dy, double Error) BestIntegerShift(Bitmap reference, Bitmap shifted, int radius) + { + int bestDx = 0; + int bestDy = 0; + double bestError = double.PositiveInfinity; + + for (int dy = -radius; dy <= radius; dy++) + { + for (int dx = -radius; dx <= radius; dx++) + { + double error = ShiftedMeanAbsoluteError(reference, shifted, dx, dy); + if (error < bestError) + { + bestError = error; + bestDx = dx; + bestDy = dy; + } + } + } + + return (bestDx, bestDy, bestError); + } + + // Every output pixel contributes, with out-of-frame reference samples read as transparent. Averaging over + // the overlap instead would make a large shift with a near-empty overlap the cheapest match. + private static double ShiftedMeanAbsoluteError(Bitmap reference, Bitmap shifted, int dx, int dy) + { + double sum = 0; + for (int y = 0; y < Height; y++) + { + int sourceY = y - dy; + bool rowInRange = sourceY >= 0 && sourceY < Height; + ReadOnlySpan referenceRow = rowInRange ? reference.GetRow(sourceY) : default; + ReadOnlySpan shiftedRow = shifted.GetRow(y); + for (int x = 0; x < Width; x++) + { + int sourceX = x - dx; + bool inRange = rowInRange && sourceX >= 0 && sourceX < Width; + for (int channel = 0; channel < 4; channel++) + { + float a = inRange + ? (float)BitConverter.UInt16BitsToHalf(referenceRow[(sourceX * 4) + channel]) + : 0f; + float b = (float)BitConverter.UInt16BitsToHalf(shiftedRow[(x * 4) + channel]); + sum += Math.Abs(a - b); + } + } + } + + return sum / ((double)Width * Height * 4); + } + + private static long CoveredPixelCount(Bitmap bitmap) + { + long count = 0; + for (int y = 0; y < Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]) > 0.5f) + count++; + } + } + + return count; + } + + // Samples the upstream source through a float3x3 uniform. The bounds contract keeps the full frame so a + // translated sample stays inside the stage output instead of being cropped away. + private sealed class SampleOffsetNode : RenderNode + { + private const string Source = + """ + uniform shader src; + uniform float3x3 xform; + + half4 main(float2 coord) { + float3 mapped = xform * float3(coord, 1.0); + return src.eval(mapped.xy); + } + """; + + private static readonly RenderBoundsContract s_bounds = RenderBoundsContract.CreateFullInput( + static input => input.Inflate(96)); + + private readonly RectGeometry _geometry; + private readonly Geometry.Resource _geometryResource; + private readonly Brush.Resource _fillResource; + private readonly GeometryRenderNode _source; + private readonly ShaderDescription _description; + + public SampleOffsetNode(SKMatrix matrix) + : this(bindings => bindings.Uniform("xform", matrix)) + { + } + + public SampleOffsetNode(float[] columnMajor) + : this(bindings => bindings.Uniform("xform", columnMajor)) + { + } + + private SampleOffsetNode(Action bindings) + { + _geometry = new RectGeometry + { + Width = { CurrentValue = 96 }, + Height = { CurrentValue = 96 }, + }; + _geometryResource = _geometry.ToResource(CompositionContext.Default); + _fillResource = new SolidColorBrush(new Color(255, 220, 120, 40)) + .ToResource(CompositionContext.Default); + _source = new GeometryRenderNode(_geometryResource, _fillResource, null); + _description = ShaderDescription.WholeSource(Source, s_bounds, bindings); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.RecordNode(_source, [])[0]; + context.Publish(context.Shader(source, _description)); + } + + protected override void OnDispose(bool disposing) + { + _source.Dispose(); + _fillResource.Dispose(); + _geometryResource.Dispose(); + base.OnDispose(disposing); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShearedFilterLayerApronTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShearedFilterLayerApronTests.cs new file mode 100644 index 0000000000..838a93838f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShearedFilterLayerApronTests.cs @@ -0,0 +1,231 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// The apron opens around a filter's content holds one +/// device pixel perpendicular to every edge, whatever basis the canvas transform carries. +/// +/// +/// A drawable's own transform sits outside its filter effect, so a reaches +/// the destination canvas whenever the executor replays a built-in Skia filter directly onto it. Under +/// that basis the logical distance mapping to one device pixel along an axis is no longer the distance +/// an inflated edge travels perpendicular to itself: the two differ by the ratio of the basis area to +/// the product of the basis lengths, which a shear drives towards zero. +/// +[NonParallelizable] +[TestFixture] +public class ShearedFilterLayerApronTests +{ + private static readonly Rect s_content = new(12, 7, 40, 24); + + /// The device transform observed replaying a blur under an 80 degree skew at output scale 2. + private static readonly Matrix s_replayedSkew = new(2f, 0f, 1.1342561f, 0.2f, 0f, 0f); + + /// Skia publishes coverage in 1/255 steps, which accumulates over a whole bar. + private const double InkTolerance = 0.02; + + public static IEnumerable ShearedTransforms() + { + yield return new TestCaseData(Matrix.CreateSkew(MathF.PI / 4, 0f)).SetName("skew x by 45 degrees"); + yield return new TestCaseData(Matrix.CreateSkew(0f, -1.4f)).SetName("steep negative skew in y"); + yield return new TestCaseData(s_replayedSkew).SetName("replayed 80 degree skew at output scale 2"); + yield return new TestCaseData( + Matrix.CreateSkew(1.3f, 0f).Append(Matrix.CreateScale(3f, 0.25f))) + .SetName("skew under an anisotropic scale"); + yield return new TestCaseData( + Matrix.CreateSkew(0.9f, 0.4f).Append(Matrix.CreateRotation(0.7f))) + .SetName("skew under a rotation"); + } + + public static IEnumerable UnshearedTransforms() + { + yield return new TestCaseData(Matrix.Identity).SetName("identity"); + yield return new TestCaseData(Matrix.CreateScale(2f, 2f)).SetName("uniform scale"); + yield return new TestCaseData(Matrix.CreateScale(10f, 0.1f)).SetName("anisotropic scale"); + yield return new TestCaseData(Matrix.CreateScale(0.333f, 0.333f)).SetName("fractional scale"); + yield return new TestCaseData(Matrix.CreateRotation(0.7f)).SetName("rotation"); + yield return new TestCaseData(Matrix.CreateRotation(0.7f).Append(Matrix.CreateScale(3f, 3f))) + .SetName("rotation under a uniform scale"); + yield return new TestCaseData(Matrix.CreateTranslation(31f, -12f).Prepend(Matrix.CreateScale(1.5f, 4f))) + .SetName("translated scale"); + } + + [TestCaseSource(nameof(ShearedTransforms))] + [TestCaseSource(nameof(UnshearedTransforms))] + public void TheApron_HoldsOneDevicePixelPerpendicularToEveryEdge(Matrix transform) + { + Rect inflated = ImmediateCanvas.InflateByOneDevicePixel(s_content, transform); + + Assert.Multiple(() => + { + Assert.That( + EdgeMargin(s_content.TopLeft, s_content.BottomLeft, new Point(inflated.X, s_content.Y), transform), + Is.EqualTo(1d).Within(1e-4), + "left edge"); + Assert.That( + EdgeMargin(s_content.TopRight, s_content.BottomRight, new Point(inflated.Right, s_content.Y), transform), + Is.EqualTo(1d).Within(1e-4), + "right edge"); + Assert.That( + EdgeMargin(s_content.TopLeft, s_content.TopRight, new Point(s_content.X, inflated.Y), transform), + Is.EqualTo(1d).Within(1e-4), + "top edge"); + Assert.That( + EdgeMargin(s_content.BottomLeft, s_content.BottomRight, new Point(s_content.X, inflated.Bottom), transform), + Is.EqualTo(1d).Within(1e-4), + "bottom edge"); + }); + } + + /// + /// The general form divides by the basis area, which rounds differently from the reciprocal of a + /// basis length even where the two agree exactly in arithmetic. Every unsheared transform has to + /// keep the reciprocal form's bits, or an apron landing on a whole device pixel would round out to + /// a different layer. + /// + [TestCaseSource(nameof(UnshearedTransforms))] + public void AnOrthogonalBasis_KeepsTheReciprocalApronExactly(Matrix transform) + { + float devicePerX = MathF.Sqrt((transform.M11 * transform.M11) + (transform.M12 * transform.M12)); + float devicePerY = MathF.Sqrt((transform.M21 * transform.M21) + (transform.M22 * transform.M22)); + + Assert.That( + ImmediateCanvas.InflateByOneDevicePixel(s_content, transform), + Is.EqualTo(s_content.Inflate(new Thickness(1f / devicePerX, 1f / devicePerY)))); + } + + [TestCase(1f, 1f, 1f, 1f, TestName = "collapsed onto a line")] + [TestCase(0f, 0f, 0f, 0f, TestName = "collapsed onto a point")] + [TestCase(1f, float.NaN, 0f, 1f, TestName = "not a number")] + [TestCase(1f, 0f, float.PositiveInfinity, 1f, TestName = "not finite")] + public void ADegenerateBasis_LeavesTheBoundsAlone(float m11, float m12, float m21, float m22) + { + Assert.That( + ImmediateCanvas.InflateByOneDevicePixel(s_content, new Matrix(m11, m12, m21, m22, 0f, 0f)), + Is.EqualTo(s_content)); + } + + /// + /// A blur too small to move a device pixel adds no margin of its own to the save layer, so the apron + /// is all that separates the content's antialiased edge from the layer bound. Summing over a device + /// pixel of sub-pixel phase cancels the rasterization noise a steeply sheared bar carries, leaving + /// only what the layer clipped. + /// + [TestCase(0f, TestName = "unsheared control")] + [TestCase(80f, TestName = "80 degree skew")] + [Category("GpuPassFusionGpu")] + public void ANegligibleBlurOnShearedContent_KeepsTheInkItHasWithoutTheBlur(float skewX) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + double unfiltered = 0; + double filtered = 0; + for (int phase = 0; phase < 20; phase++) + { + float offsetY = 150f + (phase * 0.025f); + unfiltered += MeasureInk(null, skewX, offsetY); + filtered += MeasureInk(NegligibleBlur(), skewX, offsetY); + } + + Assert.That(unfiltered, Is.GreaterThan(0), "the bar has to render, or the comparison proves nothing."); + Assert.That( + filtered / unfiltered, + Is.EqualTo(1d).Within(InkTolerance), + $"a blur of sigma 0.01 at skew {skewX} painted {filtered:F2} of ink where the same content " + + $"without it paints {unfiltered:F2}; a filter that moves no pixel must not cost the " + + "content the coverage its layer failed to make room for."); + }); + } + + /// + /// The perpendicular distance between the transformed edge through and the + /// parallel transformed edge through , in device pixels. + /// + private static double EdgeMargin(Point inside, Point along, Point outside, Matrix transform) + { + Point a = inside * transform; + Point b = along * transform; + Point c = outside * transform; + var direction = new Vector(b.X - a.X, b.Y - a.Y); + var offset = new Vector(c.X - a.X, c.Y - a.Y); + return Math.Abs((offset.X * direction.Y) - (offset.Y * direction.X)) / direction.Length; + } + + private static FilterEffect NegligibleBlur() + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(0.01f, 0.01f); + return blur; + } + + /// + /// Renders a 100 x 6 bar squeezed to 0.6 logical units tall, skewed in x, and sums the alpha it + /// leaves on the frame. + /// + private static double MeasureInk(FilterEffect? effect, float skewX, float offsetY) + { + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = 100f; + rectangle.Height.CurrentValue = 6f; + rectangle.Fill.CurrentValue = new SolidColorBrush(Colors.White); + rectangle.AlignmentX.CurrentValue = AlignmentX.Left; + rectangle.AlignmentY.CurrentValue = AlignmentY.Top; + rectangle.TransformOrigin.CurrentValue = RelativePoint.TopLeft; + if (effect is not null) + rectangle.FilterEffect.CurrentValue = effect; + + var group = new TransformGroup(); + var translate = new TranslateTransform(); + translate.X.CurrentValue = 64; + translate.Y.CurrentValue = offsetY; + // A TransformGroup applies its last child first, so the squeeze runs in the shape's own space. + group.Children.Add(translate); + var skew = new SkewTransform(); + skew.SkewX.CurrentValue = skewX; + group.Children.Add(skew); + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = 100f; + scale.ScaleY.CurrentValue = 10f; + group.Children.Add(scale); + rectangle.Transform.CurrentValue = group; + + var scene = new Scene(640, 360, "sheared-apron") { Uri = new Uri("file:///sheared-apron/scene") }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(4), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///sheared-apron/element"), + }; + element.AddObject(rectangle); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, 2f, false, 4f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); + using Bitmap bitmap = renderer.Snapshot(); + + double ink = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int x = 3; x < row.Length; x += 4) + ink += (float)BitConverter.UInt16BitsToHalf(row[x]); + } + + return ink; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShrinkingTransformKeepsItsFootprintTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShrinkingTransformKeepsItsFootprintTests.cs new file mode 100644 index 0000000000..fea73c7d88 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShrinkingTransformKeepsItsFootprintTests.cs @@ -0,0 +1,136 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A scope bounds its callback to its own footprint, and that bound is stated in the callback's local +/// units. Replayed under a shrinking transform those units cover a sub-pixel span, which a +/// non-antialiased clip snaps to the nearest device pixel — outward it costs the leading partially +/// covered column, and inward it takes the whole picture. What a shrink is authored through must not +/// decide whether it survives. +/// +[NonParallelizable] +[TestFixture] +public class ShrinkingTransformKeepsItsFootprintTests +{ + /// + /// Skia publishes coverage in 1/255 steps, so a per-pixel alpha lands within one quantum of the + /// analytic value; over a whole column that accumulates. + /// + private const double CoverageTolerance = 0.2; + + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(1.5f)] + [TestCase(2f)] + public void AShrinkingTransform_PaintsTheSameFootprintAsTheShapeItProduces(float outputScale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + // The same 0.08 x 60 rectangle at (88, 42), authored twice: once as the shape's own size, + // once as a 1000x shrink of an 80 x 60 shape. + double authored = MeasureInk(Rectangle(0.08f, 60f, scaleX: null), outputScale); + double shrunk = MeasureInk(Rectangle(80f, 60f, scaleX: 0.1f), outputScale); + + Assert.That( + shrunk, + Is.GreaterThan(0), + "a finite shrink with a nonzero determinant keeps a non-empty device footprint, so it " + + "must not render as an empty frame."); + Assert.That( + shrunk, + Is.EqualTo(authored).Within(CoverageTolerance), + "the two scenes describe one rectangle, so the route the shrink is authored through " + + "must not change how much of it is painted."); + }); + } + + [Test] + public void AShrinkingTransform_KeepsItsFootprintAcrossOutputScales() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + // A 0.4 x 0.3 device-pixel dot: below one pixel on both axes at every scale sampled here, + // so nothing but a rounding boundary could make it appear, vanish, and reappear. + foreach (float outputScale in new[] { 0.5f, 1f, 1.5f, 2f }) + { + double ink = MeasureInk(Rectangle(80f, 60f, scaleX: 0.5f, scaleY: 0.5f), outputScale); + double analytic = 0.4 * 0.3 * outputScale * outputScale; + Assert.That( + ink, + Is.EqualTo(analytic).Within(CoverageTolerance), + $"the dot covers {analytic:F4} device pixels at output scale {outputScale}, and " + + "coverage has to follow the input rather than the device grid it lands on."); + } + }); + } + + private static RectShape Rectangle(float width, float height, float? scaleX, float? scaleY = null) + { + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = width; + rectangle.Height.CurrentValue = height; + rectangle.Fill.CurrentValue = new SolidColorBrush(Colors.OrangeRed); + rectangle.AlignmentX.CurrentValue = AlignmentX.Left; + rectangle.AlignmentY.CurrentValue = AlignmentY.Top; + rectangle.TransformOrigin.CurrentValue = RelativePoint.TopLeft; + + var group = new TransformGroup(); + var translate = new TranslateTransform(); + translate.X.CurrentValue = 88; + translate.Y.CurrentValue = 42; + // A TransformGroup applies its last child first, so the shrink runs in the shape's own space. + group.Children.Add(translate); + if (scaleX is { } percentX) + { + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = percentX; + scale.ScaleY.CurrentValue = scaleY ?? 100f; + group.Children.Add(scale); + } + + rectangle.Transform.CurrentValue = group; + return rectangle; + } + + private static double MeasureInk(Drawable drawable, float outputScale) + { + var scene = new Scene(640, 360, "shrink") { Uri = new Uri("file:///shrink/scene") }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(4), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///shrink/element"), + }; + element.AddObject(drawable); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, outputScale, false, outputScale * 2f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); + using Bitmap bitmap = renderer.Snapshot(); + + double ink = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int x = 3; x < row.Length; x += 4) + ink += (float)BitConverter.UInt16BitsToHalf(row[x]); + } + + return ink; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SkiaColorFilterChainTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SkiaColorFilterChainTests.cs new file mode 100644 index 0000000000..93b34b9776 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SkiaColorFilterChainTests.cs @@ -0,0 +1,111 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A Skia colour filter recorded through must reach the output exactly +/// once no matter what follows it in the same Skia segment. +/// +[NonParallelizable] +[TestFixture] +public sealed class SkiaColorFilterChainTests +{ + private static readonly PixelSize s_frame = new(200, 200); + + private const float Brightness = 1.5f; + + [Test] + public void ColorFilter_FollowedByImageFilter_AppliesOnce() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap plain = Render(effect: null); + using Bitmap matrixOnly = Render(new ColorMatrixChainEffect(followWithBlur: false)); + using Bitmap matrixThenBlur = Render(new ColorMatrixChainEffect(followWithBlur: true)); + + double basis = InteriorMean(plain); + double once = InteriorMean(matrixOnly); + double withBlur = InteriorMean(matrixThenBlur); + + TestContext.WriteLine( + $"interior mean: plain={basis:F5} matrix={once:F5} (x{once / basis:F4}) " + + $"matrix+blur={withBlur:F5} (x{withBlur / basis:F4})"); + + Assert.Multiple(() => + { + Assert.That(once / basis, Is.EqualTo(Brightness).Within(0.01), + "the colour matrix alone did not scale the uniform interior by its own factor"); + Assert.That(withBlur / basis, Is.EqualTo(Brightness).Within(0.01), + "a Skia image filter after the colour matrix re-applied the matrix"); + }); + }); + } + + private static Bitmap Render(FilterEffect? effect) + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.Width.CurrentValue = 180; + shape.Height.CurrentValue = 180; + shape.Fill.CurrentValue = new SolidColorBrush(Color.FromArgb(255, 80, 80, 80)); + shape.FilterEffect.CurrentValue = effect; + + using Drawable.Resource resource = shape.ToResource(CompositionContext.Default); + return GoldenImageHarness.RenderAtScale(resource, s_frame, 1f); + } + + // The centre of a uniform rect is far enough from every edge that the blur cannot reach it, so any + // change there is the colour matrix, not the blur. + private static double InteriorMean(Bitmap bitmap) + { + int x0 = (bitmap.Width / 2) - 20; + int x1 = (bitmap.Width / 2) + 20; + int y0 = (bitmap.Height / 2) - 20; + int y1 = (bitmap.Height / 2) + 20; + double sum = 0; + int count = 0; + for (int y = y0; y < y1; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = x0; x < x1; x++) + { + double alpha = (double)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + if (alpha < 0.999) continue; + sum += (double)BitConverter.UInt16BitsToHalf(row[x * 4]) / alpha; + count++; + } + } + + Assert.That(count, Is.GreaterThan(0), "the sampled interior was not opaque"); + return sum / count; + } + + [SuppressResourceClassGeneration] + private sealed partial class ColorMatrixChainEffect(bool followWithBlur) : FilterEffect + { + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.ColorMatrix(ColorMatrix.CreateBrightness(Brightness)); + if (followWithBlur) + context.Blur(new Size(3, 3)); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SplitTransformEffectCombinationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SplitTransformEffectCombinationTests.cs new file mode 100644 index 0000000000..deab18fa94 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SplitTransformEffectCombinationTests.cs @@ -0,0 +1,216 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// Guards SplitEffect combined with TransformEffect(ApplyToTarget=false) in both orders. +// +// SplitEffect declares its own output extent, so a TransformEffect on either side of it resolves one shared +// matrix from concrete bounds; the order still matters, because splitting first tiles the untransformed shape +// while transforming first tiles the transformed one. The order placed after the split also exercises the +// re-anchoring the activator has to apply once a custom effect has re-targeted the buffers. Each order +// therefore gets an oracle rendered in this same process, which keeps the gate free of any checked-in +// baseline, machine-local snapshot directory, or inter-test ordering. +[NonParallelizable] +[TestFixture] +public class SplitTransformEffectCombinationTests +{ + private static readonly PixelSize Frame = new(200, 200); + + private const float ShapeRotation = 21f; + private const float EffectRotation = 45f; + private const float EffectScaleX = 120f; + private const float EffectScaleY = 100f; + + // Each oracle reaches the same geometry through a different blit, so resampling differs at tile edges. + // TransformEffectEquivalenceTests uses the same bound against the same drawable-transform oracle. + // Measured on Vulkan: 0.9965 for the split-first order, 0.9994 for the transform-first order. + private const double MinimumOracleSsim = 0.97; + + // Measured order divergence is 0.2633, two orders of magnitude above this floor. + private const double MinimumOrderDivergence = 0.01; + + // A transformed 140x90 shape split into nine tiles fills well over this share of a 200x200 frame. + private const double MinimumCoverageRatio = 0.05; + + // One matrix is applied to the whole split result here, so its oracle is that same transform applied + // through the drawable's own Transform, a path with no filter-effect target re-anchoring at all. + [Test] + public void SplitThenTransformFilter_SharedMatrixMatchesTheDrawableTransform() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap viaEffect = Render(SplitThenTransform(), ShapeTransform()); + using Bitmap viaDrawableTransform = Render(MakeSplit(), ShapeThenEffectTransform()); + + AssertOracleMatch(viaEffect, viaDrawableTransform, "SplitThenTransform"); + }); + } + + // The transform sees a single target here, so ApplyToTarget=true — which computes the same matrix from + // that one target — is an equivalent independent path. + [Test] + public void TransformFilterThenSplit_SharedMatrixMatchesTheApplyToTargetPath() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap shared = Render(TransformThenSplit(applyToTarget: false), ShapeTransform()); + using Bitmap perTarget = Render(TransformThenSplit(applyToTarget: true), ShapeTransform()); + + AssertOracleMatch(shared, perTarget, "TransformThenSplit"); + }); + } + + [Test] + public void SplitTransformCombination_IsDeterministicAndOrderSensitive() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap splitFirst = Render(SplitThenTransform(), ShapeTransform()); + using Bitmap splitFirstAgain = Render(SplitThenTransform(), ShapeTransform()); + using Bitmap transformFirst = Render(TransformThenSplit(applyToTarget: false), ShapeTransform()); + + double divergence = ImageMetrics.MeanAbsoluteError(splitFirst, transformFirst); + TestContext.WriteLine($"order divergence MAE={divergence:F6}"); + + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("split-first", splitFirst), ("transform-first", transformFirst)), + Is.Null); + GoldenImageHarness.AssertByteIdentical(splitFirst, splitFirstAgain); + Assert.That(CoveredPixelRatio(splitFirst), Is.GreaterThan(MinimumCoverageRatio)); + Assert.That( + divergence, + Is.GreaterThan(MinimumOrderDivergence), + "the two effect orders must not collapse onto one image."); + }); + }); + } + + private static void AssertOracleMatch(Bitmap actual, Bitmap oracle, string label) + { + double ssim = ImageMetrics.Ssim(actual, oracle); + double mae = ImageMetrics.MeanAbsoluteError(actual, oracle); + TestContext.WriteLine($"{label} vs oracle SSIM={ssim:F4} MAE={mae:F6}"); + + Assert.Multiple(() => + { + Assert.That(ImageMetrics.FirstNonFinite((label, actual), ($"{label}-oracle", oracle)), Is.Null); + + // Without this both sides could agree on a blank frame. + Assert.That( + CoveredPixelRatio(actual), + Is.GreaterThan(MinimumCoverageRatio), + $"{label} must produce substantial coverage."); + Assert.That( + ssim, + Is.GreaterThan(MinimumOracleSsim), + $"{label} diverged from its independently rendered oracle"); + }); + } + + private static FilterEffect SplitThenTransform() + { + var group = new FilterEffectGroup(); + group.Children.Add(MakeSplit()); + group.Children.Add(MakeTransform(applyToTarget: false)); + return group; + } + + private static FilterEffect TransformThenSplit(bool applyToTarget) + { + var group = new FilterEffectGroup(); + group.Children.Add(MakeTransform(applyToTarget)); + group.Children.Add(MakeSplit()); + return group; + } + + private static SplitEffect MakeSplit() + { + var effect = new SplitEffect(); + effect.HorizontalDivisions.CurrentValue = 3; + effect.VerticalDivisions.CurrentValue = 3; + effect.HorizontalSpacing.CurrentValue = 12; + effect.VerticalSpacing.CurrentValue = 12; + return effect; + } + + private static TransformEffect MakeTransform(bool applyToTarget) + { + var effect = new TransformEffect(); + effect.Transform.CurrentValue = EffectTransformGroup(); + effect.TransformOrigin.CurrentValue = RelativePoint.Center; + effect.ApplyToTarget.CurrentValue = applyToTarget; + return effect; + } + + private static TransformGroup EffectTransformGroup() + { + var group = new TransformGroup(); + var rotation = new RotationTransform(); + rotation.Rotation.CurrentValue = EffectRotation; + var scale = new ScaleTransform(); + scale.ScaleX.CurrentValue = EffectScaleX; + scale.ScaleY.CurrentValue = EffectScaleY; + group.Children.Add(rotation); + group.Children.Add(scale); + return group; + } + + private static Transform ShapeTransform() + { + var rotation = new RotationTransform(); + rotation.Rotation.CurrentValue = ShapeRotation; + return rotation; + } + + private static Transform ShapeThenEffectTransform() + { + var group = new TransformGroup(); + group.Children.Add(ShapeTransform()); + foreach (Transform child in EffectTransformGroup().Children) + group.Children.Add(child); + + return group; + } + + private static Bitmap Render(FilterEffect effect, Transform transform) + { + var shape = new RectShape(); + shape.AlignmentX.CurrentValue = AlignmentX.Center; + shape.AlignmentY.CurrentValue = AlignmentY.Center; + shape.TransformOrigin.CurrentValue = RelativePoint.Center; + shape.Width.CurrentValue = 140; + shape.Height.CurrentValue = 90; + shape.Fill.CurrentValue = Brushes.White; + shape.Transform.CurrentValue = transform; + shape.FilterEffect.CurrentValue = effect; + + return GoldenImageHarness.RenderAtScale(shape.ToResource(CompositionContext.Default), Frame, 1f); + } + + private static double CoveredPixelRatio(Bitmap bitmap) + { + long covered = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]) > 0.5f) + covered++; + } + } + + return covered / ((double)bitmap.Width * bitmap.Height); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SubPixelParticlesStayVisibleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SubPixelParticlesStayVisibleTests.cs new file mode 100644 index 0000000000..a3f2f3c01e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/SubPixelParticlesStayVisibleTests.cs @@ -0,0 +1,91 @@ +using Beutl.Graphics; +using Beutl.Graphics.Particles; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +/// +/// A particle is a copy of its source scaled by the particle's own size, so below ten units it is a +/// minification. Filling a rectangle with the source's shader made that minification the tile mode's +/// problem: a decal domain narrower than the sample footprint drops out, and the emitter rendered an +/// entirely empty frame while the same emitter drawn larger rendered normally. +/// +[NonParallelizable] +[TestFixture] +public class SubPixelParticlesStayVisibleTests +{ + [Test] + public void ParticleCoverage_GrowsWithParticleSizeThroughTheSubPixelRange() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + // A particle's source is ten units across, so these sizes span 0.04x to 0.2x — every one of + // them lands under a device pixel at output scale 1. + float[] sizes = [0.4f, 0.6f, 0.8f, 1.0f, 1.5f, 2.0f]; + double[] coverage = [.. sizes.Select(MeasureCoverage)]; + + Assert.That( + coverage[0], + Is.GreaterThan(0), + "a sub-pixel particle still covers part of a pixel, so the emitter must not render an " + + "empty frame."); + for (int i = 1; i < coverage.Length; i++) + { + Assert.That( + coverage[i], + Is.GreaterThan(coverage[i - 1]), + $"a particle of size {sizes[i]} covers more than one of size {sizes[i - 1]}, so " + + "coverage has to follow the size rather than fall off a threshold."); + } + }); + } + + private static double MeasureCoverage(float particleSize) + { + var emitter = new ParticleEmitter(); + // The simulator seeds its Random from this, so an unpinned seed would make the measurement noise. + emitter.Seed.CurrentValue = 1234; + emitter.EmissionRate.CurrentValue = 24f; + emitter.Lifetime.CurrentValue = 1.2f; + emitter.MaxParticles.CurrentValue = 400; + emitter.Speed.CurrentValue = 150f; + emitter.Gravity.CurrentValue = 200f; + emitter.Spread.CurrentValue = 40f; + emitter.ParticleSize.CurrentValue = particleSize; + emitter.ParticleColor.CurrentValue = Colors.OrangeRed; + + var scene = new Scene(256, 144, "particles") { Uri = new Uri("file:///particles/scene") }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(60), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///particles/element"), + }; + element.AddObject(emitter); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, 1f, false, 2f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.FromSeconds(1))); + using Bitmap bitmap = renderer.Snapshot(); + + double coverage = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int x = 3; x < row.Length; x += 4) + coverage += (float)BitConverter.UInt16BitsToHalf(row[x]); + } + + return coverage; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TargetCaptureValueWrapperTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TargetCaptureValueWrapperTests.cs new file mode 100644 index 0000000000..29a4e3d133 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TargetCaptureValueWrapperTests.cs @@ -0,0 +1,180 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class TargetCaptureValueWrapperTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 12); + + [Test] + public void MaterializedOpacity_TargetCaptureReadsCallerTargetAndCompositesCapturedValue() + { + using RenderNodeRasterization raster = Render(ValueWrapper.Opacity); + + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(s_bounds)); + Assert.That(AlphaAt(raster.Bitmap!, 8, 6), Is.EqualTo(0.627f).Within(0.03f)); + }); + } + + [Test] + public void MaterializedOpacityMask_TargetCaptureReadsCallerTargetAndCompositesCapturedValue() + { + using RenderNodeRasterization raster = Render(ValueWrapper.OpacityMask); + + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(s_bounds)); + Assert.That(AlphaAt(raster.Bitmap!, 8, 6), Is.EqualTo(0.752f).Within(0.03f)); + }); + } + + [Test] + public void TargetCapture_CropsTheDeclaredRegionWithoutResamplingTheWholeTarget() + { + var captureBounds = new Rect(8, 0, 8, 12); + using Brush.Resource red = Brushes.Red.ToResource(CompositionContext.Default); + using Brush.Resource blue = Brushes.Blue.ToResource(CompositionContext.Default); + using var root = new ContainerRenderNode(); + root.AddChild(new RectangleRenderNode(new Rect(0, 0, 8, 12), red, null)); + root.AddChild(new RectangleRenderNode(captureBounds, blue, null)); + root.AddChild(new ContributingTargetCaptureNode(captureBounds)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + (float leftRed, float leftBlue) = RedBlueAt(raster.Bitmap!, 2, 6); + (float capturedLeftRed, float capturedLeftBlue) = RedBlueAt(raster.Bitmap!, 9, 6); + (float capturedRightRed, float capturedRightBlue) = RedBlueAt(raster.Bitmap!, 14, 6); + + Assert.Multiple(() => + { + Assert.That(leftRed, Is.GreaterThan(leftBlue)); + Assert.That(capturedLeftBlue, Is.GreaterThan(capturedLeftRed), + "the left edge of the capture must come from the declared right-half source region"); + Assert.That(capturedRightBlue, Is.GreaterThan(capturedRightRed)); + }); + } + + private static RenderNodeRasterization Render(ValueWrapper wrapper) + { + using Brush.Resource fill = new SolidColorBrush(new Color(128, 255, 0, 0)) + .ToResource(CompositionContext.Default); + using var root = new ContainerRenderNode(); + root.AddChild(new RectangleRenderNode(s_bounds, fill, null)); + root.AddChild(new TargetCaptureValueWrapperNode(wrapper)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = FusionMode.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + return renderer.Rasterize(); + } + + private static float AlphaAt(Bitmap bitmap, int x, int y) + { + Span row = bitmap.GetRow(y); + return (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + } + + private static (float Red, float Blue) RedBlueAt(Bitmap bitmap, int x, int y) + { + Span row = bitmap.GetRow(y); + int offset = x * 4; + return ( + (float)BitConverter.UInt16BitsToHalf(row[offset]), + (float)BitConverter.UInt16BitsToHalf(row[offset + 2])); + } + + private enum ValueWrapper + { + Opacity, + OpacityMask, + } + + private sealed class TargetCaptureValueWrapperNode(ValueWrapper wrapper) : RenderNode + { + private static readonly GeometryDescription s_identityGeometry = GeometryDescription.Create( + "target-capture-value-wrapper-identity", + static (session, _) => session.Canvas.Use(session.Input.Draw), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(s_bounds), + s_bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle wrapped = wrapper switch + { + ValueWrapper.Opacity => context.Opacity(capture, 0.5f), + ValueWrapper.OpacityMask => context.OpacityMask( + capture, + context.Borrow(Brushes.Resource.White), + s_bounds), + _ => throw new InvalidOperationException("The value-wrapper fixture is invalid."), + }; + RenderFragmentHandle materialized = context.Geometry(wrapped, s_identityGeometry); + context.Publish(context.ContributeValues(materialized)); + } + + } + + private sealed class ContributingTargetCaptureNode(Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + context.Publish(context.ContributeValues(capture)); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TileBrushFillDensityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TileBrushFillDensityTests.cs index 46930ea59e..aa6ddf5d48 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TileBrushFillDensityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TileBrushFillDensityTests.cs @@ -1,5 +1,6 @@ using Beutl.Composition; using Beutl.Graphics; +using Beutl.Graphics.Effects; using Beutl.Graphics.Shapes; using Beutl.Media; using Beutl.Media.Pixel; @@ -16,13 +17,13 @@ public class TileBrushFillDensityTests { private static readonly PixelSize Frame = new(200, 200); - private static EllipseShape MakeEllipse() + private static EllipseShape MakeEllipse(float size = 160) { var e = new EllipseShape(); e.AlignmentX.CurrentValue = AlignmentX.Center; e.AlignmentY.CurrentValue = AlignmentY.Center; - e.Width.CurrentValue = 160; - e.Height.CurrentValue = 160; + e.Width.CurrentValue = size; + e.Height.CurrentValue = size; e.Fill.CurrentValue = Brushes.White; return e; } @@ -109,6 +110,158 @@ public void DrawableBrushTile_NonOriginDest_ConsistentAcrossScale() }); } + // A drawable smaller than the brush destination must stretch to cover it. This only discriminates + // when the drawable's intrinsic bounds differ from the destination box — every other case here + // uses a 160x160 drawable in a 160x160 host, where the two coincide. + [Test] + public void DrawableBrushFill_ContentSmallerThanDestination_CoversTheWholeDestination() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = MakeEllipse(40); + brush.Stretch.CurrentValue = Stretch.Fill; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + + var host = new RectShape(); + host.AlignmentX.CurrentValue = AlignmentX.Center; + host.AlignmentY.CurrentValue = AlignmentY.Center; + host.Width.CurrentValue = 160; + host.Height.CurrentValue = 160; + host.Fill.CurrentValue = brush; + + using Bitmap filled = GoldenImageHarness.RenderAtScale( + host.ToResource(CompositionContext.Default), Frame, 1f); + + int litWidth = LitWidthOnCentreRow(filled); + TestContext.WriteLine($"[DrawableBrush 40->160 fill] lit width on the centre row = {litWidth}px"); + Assert.That(litWidth, Is.GreaterThan(150), + "a Stretch.Fill drawable brush must scale its content to the destination; " + + "a lit width near the drawable's own 40px means the tile calculator was handed " + + "the destination box as the source size"); + }); + } + + // A 60x40 source under a target-rewriting effect, filling a 180x120 host through a Stretch.Uniform + // DrawableBrush. The brush must stretch against what the effect produced, not against the host box. + private static Drawable.Resource MakeEffectedBrushHost(FilterEffect effect) + { + var source = new RectShape(); + source.AlignmentX.CurrentValue = AlignmentX.Center; + source.AlignmentY.CurrentValue = AlignmentY.Center; + source.Width.CurrentValue = 60; + source.Height.CurrentValue = 40; + source.Fill.CurrentValue = Brushes.White; + source.FilterEffect.CurrentValue = effect; + + var brush = new DrawableBrush(); + brush.Drawable.CurrentValue = source; + brush.Stretch.CurrentValue = Stretch.Uniform; + brush.TileMode.CurrentValue = TileMode.None; + brush.DestinationRect.CurrentValue = RelativeRect.Fill; + + var host = new RectShape(); + host.AlignmentX.CurrentValue = AlignmentX.Center; + host.AlignmentY.CurrentValue = AlignmentY.Center; + host.Width.CurrentValue = 180; + host.Height.CurrentValue = 120; + host.Fill.CurrentValue = brush; + return host.ToResource(CompositionContext.Default); + } + + // 2x2 tiles with a 6px gap widen the 60x40 source to 66x46; Uniform into 180x120 scales it by + // min(180/66, 120/46) = 2.6087, so the painted extent must be 172x120, not the source's own 66x46. + [Test] + [Category("GpuPassFusionGpu")] + public void DrawableBrushFill_SourceCarriesSplitEffect_StretchesAgainstTheEffectOutput() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var split = new SplitEffect(); + split.HorizontalDivisions.CurrentValue = 2; + split.VerticalDivisions.CurrentValue = 2; + split.HorizontalSpacing.CurrentValue = 6; + split.VerticalSpacing.CurrentValue = 6; + + using Bitmap filled = GoldenImageHarness.RenderAtScale(MakeEffectedBrushHost(split), Frame, 1f); + PixelRect painted = PaintedBounds(filled); + TestContext.WriteLine($"[DrawableBrush split 66x46 -> 180x120] painted = {painted}"); + Assert.Multiple(() => + { + Assert.That(painted.Width, Is.EqualTo(172).Within(3), + "the split source must stretch to the destination; a width near the source's own 66px " + + "means the brush was handed the host box as its content bounds"); + Assert.That(painted.Height, Is.EqualTo(120).Within(3), + "Stretch.Uniform must cover the constraining axis of the destination"); + }); + }); + } + + // The same defect through an effect that only flattens its targets: 60x40 into 180x120 is a clean 3x. + [Test] + [Category("GpuPassFusionGpu")] + public void DrawableBrushFill_SourceCarriesLayerEffect_StretchesAgainstTheEffectOutput() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap filled = GoldenImageHarness.RenderAtScale( + MakeEffectedBrushHost(new LayerEffect()), Frame, 1f); + PixelRect painted = PaintedBounds(filled); + TestContext.WriteLine($"[DrawableBrush layer 60x40 -> 180x120] painted = {painted}"); + Assert.Multiple(() => + { + Assert.That(painted.Width, Is.EqualTo(180).Within(3), + "a bounds-preserving effect must leave the brush stretching against the 60x40 source"); + Assert.That(painted.Height, Is.EqualTo(120).Within(3), + "a bounds-preserving effect must leave the brush stretching against the 60x40 source"); + }); + }); + } + + // Bounding box of non-black pixels in a black-cleared render. + private static PixelRect PaintedBounds(Bitmap bitmap) + { + int left = int.MaxValue; + int top = int.MaxValue; + int right = int.MinValue; + int bottom = int.MinValue; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + if ((float)BitConverter.UInt16BitsToHalf(row[x * 4]) <= 0.01f) continue; + left = Math.Min(left, x); + top = Math.Min(top, y); + right = Math.Max(right, x); + bottom = Math.Max(bottom, y); + } + } + + return right < left ? default : new PixelRect(left, top, right - left + 1, bottom - top + 1); + } + + // First-to-last extent of non-black pixels on the middle scanline of a black-cleared render. + private static int LitWidthOnCentreRow(Bitmap bitmap) + { + ReadOnlySpan row = bitmap.GetRow(bitmap.Height / 2); + int first = -1; + int last = -1; + for (int x = 0; x < bitmap.Width; x++) + { + float luma = (float)BitConverter.UInt16BitsToHalf(row[x * 4]); + if (luma <= 0.01f) continue; + if (first < 0) first = x; + last = x; + } + + return first < 0 ? 0 : last - first + 1; + } + // Diagonal hard-stop stripes for high-frequency density discrimination. private static RectShape MakeStripes() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectEquivalenceTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectEquivalenceTests.cs index c4c50d178d..a9849ffd08 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectEquivalenceTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectEquivalenceTests.cs @@ -17,6 +17,47 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; public class TransformEffectEquivalenceTests { private static readonly PixelSize Frame = new(240, 240); + private static readonly PixelSize TransformBrightnessFrame = new(256, 144); + + [TestCase(TransformKind.FractionalScale)] + [TestCase(TransformKind.Rotation5)] + [TestCase(TransformKind.NearIdentityRotation)] + [TestCase(TransformKind.QuarterTurnRotation)] + public void FollowedByBrightness_AtScale1_MatchesCommutedOrder(TransformKind transformKind) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Drawable.Resource transformedThenBrightened = MakeTransformBrightnessChain( + transformKind, + transformFirst: true); + using Drawable.Resource brightenedThenTransformed = MakeTransformBrightnessChain( + transformKind, + transformFirst: false); + using Bitmap actual = GoldenImageHarness.RenderAtScale( + transformedThenBrightened, + TransformBrightnessFrame, + 1f); + using Bitmap commuted = GoldenImageHarness.RenderAtScale( + brightenedThenTransformed, + TransformBrightnessFrame, + 1f); + + double ssim = ImageMetrics.Ssim(actual, commuted); + double mae = ImageMetrics.MeanAbsoluteError(actual, commuted); + TestContext.WriteLine($"{transformKind}: commuted-order SSIM={ssim:F4} MAE={mae:F4}"); + Assert.Multiple(() => + { + Assert.That( + ImageMetrics.FirstNonFinite(("actual", actual), ("commuted", commuted)), + Is.Null); + Assert.That(ssim, Is.GreaterThan(0.99), + "TransformEffect followed by a current-pixel color effect must match the commuted rendering order within resampling tolerance."); + Assert.That(mae, Is.LessThan(0.01), + "TransformEffect followed by a current-pixel color effect must not lose or shift the transformed raster."); + }); + }); + } private static TransformGroup MakeGroup() { @@ -62,6 +103,44 @@ private static Drawable.Resource MakeViaEffect() return shape.ToResource(CompositionContext.Default); } + private static Drawable.Resource MakeTransformBrightnessChain( + TransformKind transformKind, + bool transformFirst) + { + var shape = new RectShape + { + AlignmentX = { CurrentValue = AlignmentX.Center }, + AlignmentY = { CurrentValue = AlignmentY.Center }, + Width = { CurrentValue = 160 }, + Height = { CurrentValue = 104 }, + Fill = { CurrentValue = Brushes.OrangeRed }, + }; + var transform = new TransformEffect + { + ApplyToTarget = { CurrentValue = true }, + Transform = + { + CurrentValue = transformKind switch + { + TransformKind.FractionalScale => new ScaleTransform(60, 60), + TransformKind.Rotation5 => new RotationTransform(5), + TransformKind.NearIdentityRotation => new RotationTransform(0.5f), + TransformKind.QuarterTurnRotation => new RotationTransform(90f), + _ => throw new ArgumentOutOfRangeException(nameof(transformKind)), + }, + }, + }; + var brightness = new Brightness + { + Amount = { CurrentValue = 160 }, + }; + var group = new FilterEffectGroup(); + group.Children.Add(transformFirst ? transform : brightness); + group.Children.Add(transformFirst ? brightness : transform); + shape.FilterEffect.CurrentValue = group; + return shape.ToResource(CompositionContext.Default); + } + [Test] public void ApplyToTarget_AtScale1_MatchesDrawableTransform() { @@ -77,4 +156,12 @@ public void ApplyToTarget_AtScale1_MatchesDrawableTransform() "TransformEffect at w==1 diverged from the same transform as the drawable's own Transform — the w==1 blit/origin changed behaviour"); }); } + + public enum TransformKind + { + FractionalScale, + Rotation5, + NearIdentityRotation, + QuarterTurnRotation, + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectFrameAnchorTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectFrameAnchorTests.cs new file mode 100644 index 0000000000..15d7b76ad2 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TransformEffectFrameAnchorTests.cs @@ -0,0 +1,156 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// A TransformEffect with ApplyToTarget=false is the only in-tree filter whose SKImageFilter is not +// translation-invariant: it conjugates the transform by an origin derived from the input bounds. The +// Skia chain executes in a frame anchored at the pending chain's InputBounds, so that anchor has to +// track the bounds the item maps; a stale anchor rotates the content about a point displaced by +// Bounds.Position - OriginalBounds.Position, which a preceding bounds-expanding pass makes non-zero. +// +// The invariant needs no golden baseline: a Gaussian blur is symmetric, so it leaves the input bounds +// centred where they were, and a rotation about the centre of those bounds cannot move the alpha +// centroid. The centroid must stay on the content centre for every sigma. +[NonParallelizable] +[TestFixture] +[Category("GpuPassFusionGpu")] +public class TransformEffectFrameAnchorTests +{ + private const float ContentWidth = 160f; + private const float ContentHeight = 104f; + private static readonly PixelSize s_frame = new(320, 240); + + [TestCase(3f, 3f, 1f)] + [TestCase(6f, 0f, 1f)] + [TestCase(0f, 6f, 1f)] + [TestCase(3f, 3f, 2f)] + public void NoTargetTransform_AfterBlur_RotatesAboutItsOwnBoundsCentre( + float sigmaX, + float sigmaY, + float scale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap rendered = GoldenImageHarness.RenderAtScale( + MakeBlurTransformBrightnessChain(sigmaX, sigmaY), + s_frame, + scale, + clearColor: Colors.Transparent); + + (double x, double y) = AlphaCentroid(rendered, scale); + TestContext.WriteLine( + $"sigma=({sigmaX},{sigmaY}) s={scale}: alpha centroid=({x:F4},{y:F4})"); + Assert.Multiple(() => + { + Assert.That(x, Is.EqualTo(s_frame.Width / 2.0).Within(0.02), + "A rotation about the blurred bounds' own centre must not move the alpha centroid horizontally."); + Assert.That(y, Is.EqualTo(s_frame.Height / 2.0).Within(0.02), + "A rotation about the blurred bounds' own centre must not move the alpha centroid vertically."); + }); + }); + } + + // A split hands the transform several targets, each anchored in its own local frame. One shared + // matrix still has to rotate them about one shared origin, so the centrally symmetric tile + // arrangement must keep its centroid where the unsplit content had it. + [TestCase(1f)] + [TestCase(2f)] + public void NoTargetTransform_AfterSplit_RotatesEveryTileAboutTheSharedOrigin(float scale) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap rendered = GoldenImageHarness.RenderAtScale( + MakeSplitTransformChain(), + s_frame, + scale, + clearColor: Colors.Transparent); + + (double x, double y) = AlphaCentroid(rendered, scale); + TestContext.WriteLine($"split s={scale}: alpha centroid=({x:F4},{y:F4})"); + Assert.Multiple(() => + { + Assert.That(x, Is.EqualTo(s_frame.Width / 2.0).Within(0.02), + "Every split tile must rotate about the same origin horizontally."); + Assert.That(y, Is.EqualTo(s_frame.Height / 2.0).Within(0.02), + "Every split tile must rotate about the same origin vertically."); + }); + }); + } + + private static Drawable.Resource MakeSplitTransformChain() + { + var shape = MakeContentShape(); + var split = new SplitEffect + { + HorizontalDivisions = { CurrentValue = 2 }, + VerticalDivisions = { CurrentValue = 2 }, + HorizontalSpacing = { CurrentValue = 10f }, + VerticalSpacing = { CurrentValue = 10f }, + }; + var group = new FilterEffectGroup(); + group.Children.Add(split); + group.Children.Add(MakeNoTargetRotation()); + shape.FilterEffect.CurrentValue = group; + return shape.ToResource(CompositionContext.Default); + } + + private static RectShape MakeContentShape() + => new() + { + AlignmentX = { CurrentValue = AlignmentX.Center }, + AlignmentY = { CurrentValue = AlignmentY.Center }, + Width = { CurrentValue = ContentWidth }, + Height = { CurrentValue = ContentHeight }, + Fill = { CurrentValue = Brushes.OrangeRed }, + }; + + private static TransformEffect MakeNoTargetRotation() + => new() + { + ApplyToTarget = { CurrentValue = false }, + TransformOrigin = { CurrentValue = RelativePoint.Center }, + Transform = { CurrentValue = new RotationTransform(25f) }, + }; + + private static Drawable.Resource MakeBlurTransformBrightnessChain(float sigmaX, float sigmaY) + { + var shape = MakeContentShape(); + var group = new FilterEffectGroup(); + group.Children.Add(new Blur { Sigma = { CurrentValue = new Size(sigmaX, sigmaY) } }); + group.Children.Add(MakeNoTargetRotation()); + group.Children.Add(new Brightness { Amount = { CurrentValue = 150f } }); + shape.FilterEffect.CurrentValue = group; + return shape.ToResource(CompositionContext.Default); + } + + private static (double X, double Y) AlphaCentroid(Bitmap bitmap, float scale) + { + double mass = 0; + double momentX = 0; + double momentY = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y); + for (int x = 0; x < bitmap.Width; x++) + { + double alpha = (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + if (!(alpha > 0)) + continue; + mass += alpha; + momentX += alpha * (x + 0.5); + momentY += alpha * (y + 0.5); + } + } + + Assert.That(mass, Is.GreaterThan(0), "the chain rendered nothing"); + return (momentX / mass / scale, momentY / mass / scale); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFractionalScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFractionalScaleTests.cs new file mode 100644 index 0000000000..ae3d530d7b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFractionalScaleTests.cs @@ -0,0 +1,108 @@ +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class WholeSourceFractionalScaleTests +{ + private const string OutsideSourceShader = """ + uniform shader src; + uniform float2 iResolution; + half4 main(float2 c) { return src.eval(c + iResolution * 1.5); } + """; + + [Test] + public void OutsideSourceSampling_PreservesCoverageFractionAcrossOutputScales() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + float[] scales = [0.5f, 0.75f, 1f, 1.5f, 2f]; + var coverageByScale = new Dictionary(); + + foreach (float scale in scales) + { + using Bitmap bitmap = Render(scale); + coverageByScale.Add( + scale, + (CountNonTransparentPixels(bitmap), (long)bitmap.Width * bitmap.Height)); + } + + foreach ((float scale, (long coverage, long pixels)) in coverageByScale) + TestContext.WriteLine($"scale {scale}: {coverage} / {pixels}"); + + (long referenceCoverage, long referencePixels) = coverageByScale[1f]; + Assert.That(referenceCoverage, Is.GreaterThan(0), + "the reference scale must exercise the source shader's Clamp edge"); + Assert.Multiple(() => + { + foreach ((float scale, (long coverage, long pixels)) in coverageByScale) + { + Assert.That( + coverage * referencePixels, + Is.EqualTo(referenceCoverage * pixels), + $"scale {scale} must preserve the non-transparent coverage fraction"); + } + }); + }); + } + + private static Bitmap Render(float outputScale) + { + var effect = new SKSLScriptEffect(); + effect.Script.CurrentValue = OutsideSourceShader; + + var rectangle = new RectShape(); + rectangle.Width.CurrentValue = 120; + rectangle.Height.CurrentValue = 80; + rectangle.Fill.CurrentValue = new SolidColorBrush(Colors.OrangeRed); + rectangle.FilterEffect.CurrentValue = effect; + + var scene = new Scene(256, 144, "whole-source-fractional-scale") + { + Uri = new Uri("file:///whole-source-fractional-scale/scene"), + }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(4), + ZIndex = 0, + IsEnabled = true, + Uri = new Uri("file:///whole-source-fractional-scale/element"), + }; + element.AddObject(rectangle); + scene.Children.Add(element); + + using var renderer = new SceneRenderer(scene, RenderIntent.Preview, outputScale, false, outputScale * 2f) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); + return renderer.Snapshot(); + } + + private static long CountNonTransparentPixels(Bitmap bitmap) + { + long count = 0; + for (int y = 0; y < bitmap.Height; y++) + { + ReadOnlySpan row = bitmap.GetRow(y)[..(bitmap.Width * 4)]; + for (int alpha = 3; alpha < row.Length; alpha += 4) + { + if (row[alpha] != 0) + count++; + } + } + + return count; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFragmentOriginTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFragmentOriginTests.cs new file mode 100644 index 0000000000..40fecb0361 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFragmentOriginTests.cs @@ -0,0 +1,253 @@ +using System.Numerics; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +// A whole-source stage is asked for a strict subset of its output whenever the content overhangs the frame. +// Its coord must still span the complete output, otherwise `coord / iResolution` stops being a normalized +// coordinate and every absolute anchor - a mirror axis, a tile origin - moves with the clip. +[NonParallelizable] +[TestFixture] +public sealed class WholeSourceFragmentOriginTests +{ + private const int Overhang = 64; + private const int ContentExtent = 224; + private const int ClippedExtent = ContentExtent - Overhang; + + // Both renders resolve the same texels, so the residual is fp16 storage rounding (~5e-4 relative near 1) + // rather than resampling. + private const double CropInvarianceTolerance = 0.002; + + private const string CoordinateProbeShader = """ + uniform shader src; + uniform float2 iResolution; + + half4 main(float2 coord) { + half alpha = src.eval(coord).a; + float2 uv = coord / iResolution; + return half4(half2(uv), 0.0, 1.0) * alpha; + } + """; + + private const string IdentityShader = """ + uniform shader src; + uniform float2 iResolution; + + half4 main(float2 coord) { + return src.eval(min(coord, iResolution)); + } + """; + + private const string HorizontalFlipShader = """ + uniform shader src; + uniform float2 iResolution; + + half4 main(float2 coord) { + return src.eval(float2(iResolution.x - coord.x, coord.y)); + } + """; + + [Test] + [TestCase(true)] + [TestCase(false)] + [Category("GpuPassFusionGpu")] + public void FragmentCoordinate_SpansTheCompleteOutput_WhenTheRequiredRegionIsAStrictSubset(bool fused) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap complete = RenderComplete(CoordinateProbeShader, fused); + using Bitmap clipped = RenderClipped(CoordinateProbeShader, fused); + + double firstU = Channel(clipped, 0, 0, 0); + double firstV = Channel(clipped, 0, 0, 1); + double lastU = Channel(clipped, ClippedExtent - 1, ClippedExtent - 1, 0); + double lastV = Channel(clipped, ClippedExtent - 1, ClippedExtent - 1, 1); + double cropDeviation = MaximumCropDeviation(complete, clipped); + TestContext.WriteLine( + $"fused={fused} first=({firstU:F6},{firstV:F6}) last=({lastU:F6},{lastV:F6}) " + + $"cropDeviation={cropDeviation:F6}"); + + Assert.Multiple(() => + { + // Nothing is clipped here, so this reading is uncontested and anchors the expected values. + Assert.That(Channel(complete, 0, 0, 0), Is.EqualTo(0.5 / ContentExtent).Within(0.002)); + Assert.That( + Channel(complete, ContentExtent - 1, ContentExtent - 1, 0), + Is.EqualTo((ContentExtent - 0.5) / ContentExtent).Within(0.002)); + + // The first visible fragment sits one overhang into the complete output, not at its own origin. + Assert.That(firstU, Is.EqualTo((Overhang + 0.5) / ContentExtent).Within(0.002)); + Assert.That(firstV, Is.EqualTo((Overhang + 0.5) / ContentExtent).Within(0.002)); + + // The clip leaves the content's far edge inside the frame, where the normalized coordinate must + // still reach 1. + Assert.That(lastU, Is.EqualTo((ContentExtent - 0.5) / ContentExtent).Within(0.002)); + Assert.That(lastV, Is.EqualTo((ContentExtent - 0.5) / ContentExtent).Within(0.002)); + + // Rendering part of a whole-source stage must equal rendering all of it and cropping. + Assert.That(cropDeviation, Is.LessThan(CropInvarianceTolerance)); + }); + }); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + [Category("GpuPassFusionGpu")] + public void HorizontalFlip_MirrorsTheCompleteOutput_WhenTheRequiredRegionIsAStrictSubset(bool fused) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using Bitmap flipped = RenderComplete(HorizontalFlipShader, fused); + using Bitmap clippedFlipped = RenderClipped(HorizontalFlipShader, fused); + using Bitmap unflipped = RenderComplete(IdentityShader, fused); + + double cropDeviation = MaximumCropDeviation(flipped, clippedFlipped); + double mirrorEffect = MaximumAlignedDeviation(unflipped, flipped, ContentExtent); + TestContext.WriteLine( + $"fused={fused} cropDeviation={cropDeviation:F6} mirrorEffect={mirrorEffect:F6}"); + + Assert.Multiple(() => + { + // Non-vacuity: a mirror that moved nothing would satisfy any invariance check. + Assert.That(mirrorEffect, Is.GreaterThan(0.2), "the mirror must actually move the content"); + Assert.That(cropDeviation, Is.LessThan(CropInvarianceTolerance)); + }); + }); + } + + private static Bitmap RenderComplete(string shader, bool fused) + => Render(shader, new Rect(0, 0, ContentExtent, ContentExtent), ContentExtent, fused); + + private static Bitmap RenderClipped(string shader, bool fused) + => Render( + shader, + new Rect(-Overhang, -Overhang, ContentExtent, ContentExtent), + ClippedExtent, + fused); + + private static Bitmap Render(string shader, Rect content, int frameExtent, bool fused) + { + using var node = new WholeSourceProbeNode(content, shader); + using RenderTarget target = RenderTarget.Create(frameExtent, frameExtent) + ?? throw new InvalidOperationException("Could not allocate the whole-source origin target."); + using (var canvas = new ImmediateCanvas(target, 1, 1, new Size(frameExtent, frameExtent))) + { + canvas.Clear(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, frameExtent, frameExtent), + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + FusionMode = fused ? FusionMode.Enabled : FusionMode.Disabled, + }, + }); + renderer.Render(canvas); + } + + return target.Snapshot(); + } + + // Both renders place identical content, one shifted by a whole number of texels, so the window offset is an + // exact integer and no resampling separates the two. + private static double MaximumCropDeviation(Bitmap complete, Bitmap clipped) + { + double worst = 0; + for (int y = 0; y < ClippedExtent; y++) + { + ReadOnlySpan completeRow = complete.GetRow(y + Overhang); + ReadOnlySpan clippedRow = clipped.GetRow(y); + for (int x = 0; x < ClippedExtent; x++) + { + for (int channel = 0; channel < 4; channel++) + { + double deviation = Math.Abs( + Half(completeRow, x + Overhang, channel) - Half(clippedRow, x, channel)); + if (deviation > worst) + worst = deviation; + } + } + } + + return worst; + } + + private static double MaximumAlignedDeviation(Bitmap first, Bitmap second, int extent) + { + double worst = 0; + for (int y = 0; y < extent; y++) + { + ReadOnlySpan firstRow = first.GetRow(y); + ReadOnlySpan secondRow = second.GetRow(y); + for (int x = 0; x < extent * 4; x++) + { + double deviation = Math.Abs( + (float)BitConverter.UInt16BitsToHalf(firstRow[x]) + - (float)BitConverter.UInt16BitsToHalf(secondRow[x])); + if (deviation > worst) + worst = deviation; + } + } + + return worst; + } + + private static double Half(ReadOnlySpan row, int x, int channel) + => (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + channel]); + + private static double Channel(Bitmap bitmap, int x, int y, int channel) + => Half(bitmap.GetRow(y), x, channel); + + private sealed class WholeSourceProbeNode : RenderNode + { + private readonly Brush.Resource _fill; + private readonly RectangleRenderNode _source; + private readonly ShaderDescription _description; + + public WholeSourceProbeNode(Rect content, string shader) + { + var gradient = new LinearGradientBrush(); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(255, 250, 32, 16), 0)); + gradient.GradientStops.Add(new GradientStop(Color.FromArgb(255, 16, 64, 240), 1)); + _fill = (Brush.Resource)gradient.ToResource(CompositionContext.Default); + _source = new RectangleRenderNode(content, _fill, null); + _description = ShaderDescription.WholeSource( + shader, + RenderBoundsContract.FullInput, + static bindings => bindings.Uniform("iResolution", default(Vector2), BindResolution)); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.RecordNode(_source, [])[0]; + context.Publish(context.Shader(source, _description)); + } + + protected override void OnDispose(bool disposing) + { + _source.Dispose(); + _fill.Dispose(); + base.OnDispose(disposing); + } + + private static void BindResolution( + ShaderUniformWriter writer, + Vector2 value, + ShaderExecutionContext context) + => writer.Set(new Vector2( + context.SemanticOutputSize.Width, + context.SemanticOutputSize.Height)); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceHeadFusionParityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceHeadFusionParityTests.cs new file mode 100644 index 0000000000..a3c3df4119 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceHeadFusionParityTests.cs @@ -0,0 +1,227 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Rendering.Baseline; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Golden; + +[NonParallelizable] +[TestFixture] +public sealed class WholeSourceHeadFusionParityTests +{ + private static readonly Rect s_bounds = new(0, 0, 13, 9); + + [Test] + public void ScriptOutputSizeUniforms_MatchAcrossDirectAndMaterializedExecution() + { + var expectedColorByMode = new Dictionary(); + + GpuPassFusionParityResult parity = GpuPassFusionSameProcessParityHarness.AssertParity(mode => + { + Bitmap bitmap = RenderOutputSizeScript(mode, out RenderExecutionStatistics statistics); + SKColor color = bitmap.SKBitmap.GetPixel(8, 6); + expectedColorByMode.Add(mode, color); + if (mode == FusionMode.Enabled) + { + Assert.Multiple(() => + { + Assert.That(statistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(statistics.ShaderStageExecutions, Is.EqualTo(2)); + Assert.That(statistics.FusedShaderRunExecutions, Is.EqualTo(1)); + }); + } + return bitmap; + }); + + TestContext.WriteLine( + $"Output-size uniform parity: SSIM={parity.FullImage.Ssim:R}, " + + $"windowed={parity.FullImage.WindowedSsim:R}, " + + $"RGB MAE={parity.FullImage.LinearRgbMae:R}, alpha MAE={parity.FullImage.AlphaMae:R}"); + Assert.Multiple(() => + { + foreach ((FusionMode mode, SKColor color) in expectedColorByMode) + { + Assert.That(color.Green, Is.GreaterThanOrEqualTo(250), + $"{mode} must expose the 16x12 semantic output size"); + Assert.That(color.Red, Is.LessThanOrEqualTo(5), + $"{mode} must not expose the physical execution backing"); + Assert.That(color.Blue, Is.LessThanOrEqualTo(5), + $"{mode} must not expose the physical execution backing"); + } + }); + } + + [Test] + public void MosaicClampEdge_MatchesStandaloneWholeSourcePass() + { + RenderExecutionStatistics disabledStatistics = default; + RenderExecutionStatistics enabledStatistics = default; + ushort disabledEdgeAlpha = 0; + ushort enabledEdgeAlpha = 0; + + GpuPassFusionParityResult parity = GpuPassFusionSameProcessParityHarness.AssertParity(mode => + { + Bitmap bitmap = Render(mode, out RenderExecutionStatistics statistics); + ushort alpha = bitmap.GetRow(bitmap.Height / 2)[((bitmap.Width - 1) * 4) + 3]; + if (mode == FusionMode.Disabled) + { + disabledStatistics = statistics; + disabledEdgeAlpha = alpha; + } + else + { + enabledStatistics = statistics; + enabledEdgeAlpha = alpha; + } + return bitmap; + }); + + TestContext.WriteLine( + $"Mosaic Clamp parity: SSIM={parity.FullImage.Ssim:R}, " + + $"windowed={parity.FullImage.WindowedSsim:R}, " + + $"RGB MAE={parity.FullImage.LinearRgbMae:R}, alpha MAE={parity.FullImage.AlphaMae:R}"); + Assert.Multiple(() => + { + Assert.That(disabledEdgeAlpha, Is.Not.Zero, + "the standalone Clamp path must extend the semantic edge into the partial Mosaic tile"); + Assert.That(enabledEdgeAlpha, Is.EqualTo(disabledEdgeAlpha), + "the WholeSource-headed run must bind the same Clamp semantic source"); + Assert.That(enabledStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabledStatistics.ShaderStageExecutions, Is.EqualTo(2)); + Assert.That(enabledStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + Assert.That(disabledStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(disabledStatistics.ShaderStageExecutions, Is.EqualTo(1)); + Assert.That(disabledStatistics.FusedShaderRunExecutions, Is.Zero); + }); + } + + private static Bitmap Render( + FusionMode fusionMode, + out RenderExecutionStatistics statistics) + { + var mosaic = new MosaicEffect(); + mosaic.TileSize.CurrentValue = new Size(10, 10); + mosaic.Origin.CurrentValue = new RelativePoint(0, 0, RelativeUnit.Absolute); + var effects = new FilterEffectGroup + { + Children = + { + mosaic, + new Gamma { Amount = { CurrentValue = 180f } }, + }, + }; + + using FilterEffect.Resource resource = effects.ToResource(CompositionContext.Default); + using var root = new FilterEffectRenderNode(resource); + root.AddChild(new RectangleRenderNode(s_bounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + CacheOptions = RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + statistics = renderer.LastExecutionStatistics; + return rasterization.Bitmap?.Clone() + ?? throw new InvalidOperationException("The Mosaic parity render produced no bitmap."); + } + + private static Bitmap RenderOutputSizeScript( + FusionMode fusionMode, + out RenderExecutionStatistics statistics) + { + var script = new SKSLScriptEffect + { + Script = + { + CurrentValue = + """ + uniform shader src; + uniform float width; + uniform float height; + uniform float2 iResolution; + + half4 main(float2 coord) { + bool correct = width == 16.0 && height == 12.0 + && iResolution.x == 16.0 && iResolution.y == 12.0; + return correct + ? half4(0.0, 1.0, 0.0, 1.0) + : half4(1.0, 0.0, 1.0, 1.0); + } + """, + }, + }; + var effects = new FilterEffectGroup + { + Children = + { + script, + new Gamma { Amount = { CurrentValue = 100f } }, + }, + }; + var contentBounds = new Rect(0, 0, 16, 12); + var canvasBounds = new Rect(0, 0, 100, 100); + + using FilterEffect.Resource resource = effects.ToResource(CompositionContext.Default); + using var root = new FilterEffectRenderNode(resource); + root.AddChild(new RectangleRenderNode(contentBounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = canvasBounds, + CacheOptions = RenderCacheOptions.Disabled, + FusionMode = fusionMode, + }, + TargetFactory = new CpuTargetFactory(), + }); + var canvasSize = new PixelSize(100, 100); + SKSurface surface = SKSurface.Create(new SKImageInfo( + canvasSize.Width, + canvasSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU output-size parity surface."); + using RenderTarget destination = new CpuRenderTarget(surface, canvasSize); + using (var canvas = new ImmediateCanvas(destination, logicalSize: canvasBounds.Size)) + { + canvas.Clear(); + renderer.Render(canvas); + } + statistics = renderer.LastExecutionStatistics; + return destination.Snapshot(); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize size = allocation.DeviceSize; + SKSurface surface = SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU WholeSource fusion test surface."); + return new CpuRenderTarget(surface, size); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GpuResourceReclaimQueueTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GpuResourceReclaimQueueTests.cs new file mode 100644 index 0000000000..86ffa48ef2 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GpuResourceReclaimQueueTests.cs @@ -0,0 +1,160 @@ +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Skia records a draw from one render target into another without owning the source image, so a +/// GPU target released between recording and submission would leave the driver reading a destroyed +/// image. These tests pin the deferral that keeps the source alive without a per-draw flush. +/// +[NonParallelizable] +public sealed class GpuResourceReclaimQueueTests +{ + /// + /// Draining flushes the shared context only. A caller about to sample a surface skips its own flush + /// when told a context-wide flush covered it, so claiming that for a target from a caller-supplied + /// factory living on another context would let a snapshot read work that was never submitted. + /// + [Test] + public void Draining_ForASurfaceOnAnotherContext_DoesNotClaimToHaveFlushedIt() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using IGraphicsContext foreign = GraphicsContextFactory.CreateContext(); + using RenderTarget destination = CreateBackendTarget(64, 64); + using var canvas = new ImmediateCanvas(destination); + canvas.Clear(Colors.Black); + + RenderTarget source = CreateBackendTarget(32, 32); + using (var sourceCanvas = new ImmediateCanvas(source)) + { + sourceCanvas.Clear(Colors.Red); + } + + GpuResourceReclaimQueue.FlushAndDrain(); + canvas.DrawRenderTargetPixelsWithoutFlush(source, 0, 0); + source.Dispose(); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0), "precondition"); + + bool claimedForeign = GpuResourceReclaimQueue.FlushAndDrain(foreign.SkiaContext); + + Assert.Multiple(() => + { + Assert.That(claimedForeign, Is.False); + Assert.That( + GpuResourceReclaimQueue.PendingCount, + Is.Zero, + "The queue is drained either way; only the caller's flush is not substituted."); + }); + }); + } + + [Test] + public void Draining_ForASurfaceOnTheSharedContext_ReplacesItsOwnFlush() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget destination = CreateBackendTarget(64, 64); + using var canvas = new ImmediateCanvas(destination); + canvas.Clear(Colors.Black); + + RenderTarget source = CreateBackendTarget(32, 32); + using (var sourceCanvas = new ImmediateCanvas(source)) + { + sourceCanvas.Clear(Colors.Red); + } + + GpuResourceReclaimQueue.FlushAndDrain(); + canvas.DrawRenderTargetPixelsWithoutFlush(source, 0, 0); + source.Dispose(); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0), "precondition"); + + bool claimedShared = GpuResourceReclaimQueue.FlushAndDrain( + GraphicsContextFactory.SharedContext!.SkiaContext); + + Assert.That(claimedShared, Is.True); + }); + } + + [Test] + public void ReleasingATargetReadByUnsubmittedWork_DefersItsDestruction() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget destination = CreateBackendTarget(64, 64); + using var canvas = new ImmediateCanvas(destination); + canvas.Clear(Colors.Black); + + RenderTarget source = CreateBackendTarget(32, 32); + using (var sourceCanvas = new ImmediateCanvas(source)) + { + sourceCanvas.Clear(Colors.Red); + } + + GpuResourceReclaimQueue.FlushAndDrain(); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.Zero, "precondition"); + + canvas.DrawRenderTargetPixelsWithoutFlush(source, 0, 0); + source.Dispose(); + + Assert.That( + GpuResourceReclaimQueue.PendingCount, + Is.GreaterThan(0), + "A target still read by unsubmitted work must outlive its last managed reference."); + + using Bitmap _ = destination.Snapshot(); + + Assert.That( + GpuResourceReclaimQueue.PendingCount, + Is.Zero, + "Reading the destination back submits and synchronizes, so the source can be destroyed."); + }); + } + + [Test] + public void ClosingAFlushingCanvas_DrainsDeferredTargets() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using RenderTarget destination = CreateBackendTarget(64, 64); + RenderTarget source = CreateBackendTarget(32, 32); + using (var sourceCanvas = new ImmediateCanvas(source)) + { + sourceCanvas.Clear(Colors.Red); + } + + GpuResourceReclaimQueue.FlushAndDrain(); + + var canvas = new ImmediateCanvas(destination); + canvas.Clear(Colors.Black); + canvas.DrawRenderTargetPixelsWithoutFlush(source, 0, 0); + source.Dispose(); + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.GreaterThan(0), "precondition"); + + canvas.Dispose(); + + Assert.That(GpuResourceReclaimQueue.PendingCount, Is.Zero); + }); + } + + private static RenderTarget CreateBackendTarget(int width, int height) + { + RenderTarget? target = RenderTarget.Create(width, height); + Assert.That(target, Is.Not.Null); + if (target!.Texture is null) + { + target.Dispose(); + Assert.Ignore("The backend fell back to a raster surface, which needs no deferral."); + } + + return target; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GraphicsContext2DTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GraphicsContext2DTests.cs index c94f435fc9..bd8f5ac308 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GraphicsContext2DTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/GraphicsContext2DTests.cs @@ -67,6 +67,387 @@ public void ShouldTriggerOnUntrackedEvent() Assert.That(untrackedNode, Is.TypeOf()); } + [Test] + public void DrawNodeUpdateFailure_PreservesTheExistingTree() + { + using var root = new ContainerRenderNode(); + var updated = new TrackingRenderNode(); + var trailing = new TrackingRenderNode(); + root.AddChild(updated); + root.AddChild(trailing); + + using (var context = new GraphicsContext2D(root)) + { + Assert.That( + () => context.DrawNode( + 0, + static _ => new TrackingRenderNode(), + static (_, _) => throw new InvalidOperationException("update failed")), + Throws.InvalidOperationException.With.Message.EqualTo("update failed")); + } + + Assert.Multiple(() => + { + Assert.That(root.Children, Is.EqualTo(new[] { updated, trailing })); + Assert.That(updated.IsDisposed, Is.False); + Assert.That(trailing.IsDisposed, Is.False); + }); + } + + [Test] + public void Dispose_DischargesUnvisitedTrailingNodes() + { + using var root = new ContainerRenderNode(); + var retained = new TrackingRenderNode(); + var removed = new TrackingRenderNode(); + root.AddChild(retained); + root.AddChild(removed); + RenderNode? untracked = null; + + using (var context = new GraphicsContext2D(root)) + { + context.OnUntracked = node => untracked = node; + context.DrawNode(retained); + } + + Assert.Multiple(() => + { + Assert.That(root.Children, Is.EqualTo(new[] { retained })); + Assert.That(root.HasChanges, Is.True); + Assert.That(removed.IsDisposed, Is.True); + Assert.That(untracked, Is.SameAs(removed)); + }); + } + + [Test] + public void Dispose_DoesNotReplacePrimaryExceptionWithTrailingCleanupFailure() + { + using var root = new ContainerRenderNode(); + var retained = new TrackingRenderNode(); + var cleanup = new InvalidOperationException("trailing cleanup failed"); + var trailing = new ThrowOnceDisposeRenderNode(cleanup); + root.AddChild(retained); + root.AddChild(trailing); + var primary = new InvalidOperationException("recording failed"); + + InvalidOperationException? failure = Assert.Throws(() => + { + using var context = new GraphicsContext2D(root); + context.DrawNode(retained); + throw primary; + }); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(trailing.DisposeCalls, Is.EqualTo(1)); + Assert.That(root.Children, Is.EqualTo(new[] { retained })); + }); + } + + [Test] + public void DirectRecordingFailure_KeepsTheInstalledReplacementWhenTheOldChildFailsToDispose() + { + using var root = new ContainerRenderNode(); + var replacementFailure = new InvalidOperationException("existing node cleanup failed"); + var existing = new ThrowOnceDisposeRenderNode(replacementFailure); + var trailing = new TrackingRenderNode(); + var replacement = new TrackingRenderNode(); + root.AddChild(existing); + root.AddChild(trailing); + + using (var context = new GraphicsContext2D(root)) + { + InvalidOperationException? failure = Assert.Throws( + () => context.DrawNode(replacement)); + Assert.That(failure, Is.SameAs(replacementFailure)); + } + + Assert.Multiple(() => + { + Assert.That(root.Children, Is.EqualTo(new RenderNode[] { replacement, trailing })); + Assert.That(replacement.IsDisposed, Is.False); + Assert.That(trailing.IsDisposed, Is.False); + Assert.That(existing.DisposeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void DirectRecordingFailure_NeverLeavesAChildWhoseDisposalAlreadyRan() + { + using var root = new ContainerRenderNode(); + var existing = new ReleaseThenThrowRenderNode(); + var replacement = new TrackingRenderNode(); + root.AddChild(existing); + + using (var context = new GraphicsContext2D(root)) + { + Assert.Throws(() => context.DrawNode(replacement)); + } + + Assert.Multiple(() => + { + Assert.That(existing.Released, Is.True); + Assert.That(root.Children, Does.Not.Contain(existing)); + Assert.That(root.Children, Is.EqualTo(new RenderNode[] { replacement })); + Assert.That(replacement.IsDisposed, Is.False); + }); + } + + [Test] + public void NestedDrawableFailure_DiscardsFaultedNodeAndStaleSuffix() + { + using var root = new ContainerRenderNode(); + var retained = new TrackingRenderNode(); + var trailing = new TrackingRenderNode(); + var outerTrailing = new TrackingRenderNode(); + var primary = new InvalidOperationException("nested drawable failed"); + var drawable = new PartialFailureDrawable(retained, primary); + using Drawable.Resource resource = drawable.ToResource(CompositionContext.Default); + var nested = new DrawableRenderNode(resource); + nested.AddChild(retained); + nested.AddChild(trailing); + root.AddChild(nested); + root.AddChild(outerTrailing); + + InvalidOperationException? failure; + using (var context = new GraphicsContext2D(root)) + { + failure = Assert.Throws( + () => context.DrawDrawable(resource)); + } + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(root.Children, Is.Empty); + Assert.That(nested.IsDisposed, Is.True); + Assert.That(retained.IsDisposed, Is.True); + Assert.That(trailing.IsDisposed, Is.True); + Assert.That(outerTrailing.IsDisposed, Is.True); + }); + } + + [Test] + public void Pop_ShouldPropagateAChangeFollowedByAnUnchangedSibling() + { + var size = new Size(1920, 1080); + var fill = Brushes.Resource.White; + var ellipse = new Rect(0, 0, 20, 20); + using var root = new ContainerRenderNode(); + + using (var context = new GraphicsContext2D(root, size)) + { + using (context.Push()) + { + context.DrawRectangle(new Rect(0, 0, 10, 10), fill, null); + context.DrawEllipse(ellipse, fill, null); + } + } + + ClearHasChanges(root); + + using (var context = new GraphicsContext2D(root, size)) + { + using (context.Push()) + { + context.DrawRectangle(new Rect(0, 0, 30, 30), fill, null); + context.DrawEllipse(ellipse, fill, null); + } + } + + Assert.That(root.HasChanges, Is.True); + } + + [Test] + public void Pop_ShouldNotMarkASiblingScopeWhoseSubtreeIsUnchanged() + { + var size = new Size(1920, 1080); + using var root = new ContainerRenderNode(); + + RecordTwoScopes(root, size, new Rect(0, 0, 10, 10)); + ClearHasChanges(root); + RecordTwoScopes(root, size, new Rect(0, 0, 30, 30)); + + var changedScope = (ContainerRenderNode)root.Children[0]; + var unchangedScope = (ContainerRenderNode)root.Children[1]; + Assert.Multiple(() => + { + Assert.That(changedScope.HasChanges, Is.True); + Assert.That(unchangedScope.HasChanges, Is.False); + }); + } + + [Test] + public void Pop_ShouldPropagateAChangeNestedTwoScopesDeepToTheRoot() + { + var size = new Size(1920, 1080); + using var root = new ContainerRenderNode(); + + RecordNestedRectangle(root, size, new Rect(0, 0, 10, 10)); + ClearHasChanges(root); + RecordNestedRectangle(root, size, new Rect(0, 0, 30, 30)); + + var outer = (ContainerRenderNode)root.Children[0]; + var inner = (ContainerRenderNode)outer.Children[0]; + Assert.Multiple(() => + { + Assert.That(inner.HasChanges, Is.True); + Assert.That(outer.HasChanges, Is.True); + Assert.That(root.HasChanges, Is.True); + }); + } + + [Test] + public void Pop_ShouldMarkTheEnclosingContainerOfAStructuralInsertion() + { + var size = new Size(1920, 1080); + var fill = Brushes.Resource.White; + using var root = new ContainerRenderNode(); + + using (var context = new GraphicsContext2D(root, size)) + using (context.Push()) + { + context.DrawRectangle(new Rect(0, 0, 10, 10), fill, null); + } + + ClearHasChanges(root); + + using (var context = new GraphicsContext2D(root, size)) + using (context.Push()) + { + context.DrawRectangle(new Rect(0, 0, 10, 10), fill, null); + context.DrawEllipse(new Rect(0, 0, 20, 20), fill, null); + } + + var scope = (ContainerRenderNode)root.Children[0]; + Assert.That(scope.HasChanges, Is.True); + } + + [Test] + public void Dispose_ShouldMarkTheRootContainerOfABareParameterChange() + { + var size = new Size(1920, 1080); + using var root = new ContainerRenderNode(); + + RecordBareRectangle(root, size, new Rect(0, 0, 10, 10)); + ClearHasChanges(root); + RecordBareRectangle(root, size, new Rect(0, 0, 30, 30)); + + Assert.That(root.HasChanges, Is.True); + } + + [Test] + public void Update_ShouldNotClearALeafMarkFromAnEarlierPassInTheSameFrame() + { + var size = new Size(1920, 1080); + var settled = new Rect(0, 0, 30, 30); + using var root = new ContainerRenderNode(); + + RecordBareEllipse(root, size, new Rect(0, 0, 10, 10)); + ClearHasChanges(root); + RecordBareEllipse(root, size, settled); + RecordBareEllipse(root, size, settled); + + var ellipse = (EllipseRenderNode)root.Children[0]; + Assert.That(ellipse.HasChanges, Is.True); + } + + [Test] + public void Update_ShouldNotClearAStructuralMarkFromAnEarlierPassInTheSameFrame() + { + var size = new Size(1920, 1080); + Matrix matrix = Matrix.CreateRotation(45); + using var root = new ContainerRenderNode(); + + RecordTransformScope(root, size, matrix, withEllipse: true); + ClearHasChanges(root); + RecordTransformScope(root, size, matrix, withEllipse: false); + RecordTransformScope(root, size, matrix, withEllipse: false); + + var scope = (TransformRenderNode)root.Children[0]; + Assert.That(scope.HasChanges, Is.True); + } + + [Test] + public void Reset_ShouldRestartRecordingAtTheRootContainer() + { + var size = new Size(1920, 1080); + using var root = new ContainerRenderNode(); + + using (var context = new GraphicsContext2D(root, size)) + { + context.Push(); + context.Reset(); + context.DrawRectangle(new Rect(0, 0, 10, 10), Brushes.Resource.White, null); + } + + Assert.That(root.Children, Has.Count.EqualTo(1)); + Assert.That(root.Children[0], Is.InstanceOf()); + } + + private static void RecordBareRectangle(ContainerRenderNode root, Size size, Rect rect) + { + using var context = new GraphicsContext2D(root, size); + context.DrawRectangle(rect, Brushes.Resource.White, null); + } + + private static void RecordBareEllipse(ContainerRenderNode root, Size size, Rect rect) + { + using var context = new GraphicsContext2D(root, size); + context.DrawEllipse(rect, Brushes.Resource.White, null); + } + + private static void RecordTransformScope( + ContainerRenderNode root, Size size, Matrix matrix, bool withEllipse) + { + var fill = Brushes.Resource.White; + using var context = new GraphicsContext2D(root, size); + using (context.PushTransform(matrix)) + { + context.DrawRectangle(new Rect(0, 0, 10, 10), fill, null); + if (withEllipse) + context.DrawEllipse(new Rect(0, 0, 20, 20), fill, null); + } + } + + private static void RecordTwoScopes(ContainerRenderNode root, Size size, Rect changing) + { + var fill = Brushes.Resource.White; + using var context = new GraphicsContext2D(root, size); + using (context.Push()) + using (context.Push()) + { + context.DrawRectangle(changing, fill, null); + } + + using (context.Push()) + using (context.Push()) + { + context.DrawEllipse(new Rect(0, 0, 20, 20), fill, null); + } + } + + private static void RecordNestedRectangle(ContainerRenderNode root, Size size, Rect rect) + { + using var context = new GraphicsContext2D(root, size); + using (context.Push()) + using (context.Push()) + { + context.DrawRectangle(rect, Brushes.Resource.White, null); + } + } + + private static void ClearHasChanges(RenderNode node) + { + node.HasChanges = false; + if (node is ContainerRenderNode container) + { + foreach (RenderNode child in container.Children) + ClearHasChanges(child); + } + } + [Test] public void Clear_ShouldCreateClearRenderNode() { @@ -79,6 +460,50 @@ public void Clear_ShouldCreateClearRenderNode() Assert.That(node.Children[0], Is.InstanceOf()); } + private sealed class TrackingRenderNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + context.PassThrough(); + } + } + + private sealed class ReleaseThenThrowRenderNode : RenderNode + { + public bool Released { get; private set; } + + public override void Process(RenderNodeContext context) + { + ObjectDisposedException.ThrowIf(Released, this); + context.PassThrough(); + } + + protected override void OnDispose(bool disposing) + { + Released = true; + // The finalizer calls OnDispose(false) unguarded, and a throwing finalizer kills the test host. + if (disposing) + throw new InvalidOperationException("resource release failed"); + } + } + + private sealed class ThrowOnceDisposeRenderNode(Exception failure) : RenderNode + { + public int DisposeCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.PassThrough(); + } + + protected override void OnDispose(bool disposing) + { + DisposeCalls++; + if (disposing && DisposeCalls == 1) + throw failure; + } + } + [Test] public void ClearWithColor_ShouldCreateClearRenderNode() { @@ -362,3 +787,28 @@ public void PushMatrixTransform_ShouldCreateTransformRenderNode() Assert.That(node.Children[0], Is.InstanceOf()); } } + +internal sealed partial class PartialFailureDrawable : Drawable +{ + private readonly RenderNode _child; + private readonly Exception _failure; + + public PartialFailureDrawable(RenderNode child, Exception failure) + { + _child = child; + _failure = failure; + } + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + context.DrawNode(_child); + throw _failure; + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) + => Size.Empty; + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestDomainAgreementTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestDomainAgreementTests.cs new file mode 100644 index 0000000000..030ce7ddea --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestDomainAgreementTests.cs @@ -0,0 +1,151 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +// Hit testing answers "is there content here", so it has to agree with what the same request would +// actually put on screen. Both a request's TargetDomain and a finite Layer's domain clip the resolved +// output, and these pin that the hit test is clipped with it. +[TestFixture] +public sealed class HitTestDomainAgreementTests +{ + private static readonly Rect s_targetDomain = new(0, 0, 100, 100); + + [Test] + public void ARequestTargetDomainClipsTheHitTestTheWayItClipsTheOutput() + { + using var fill = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(new Rect(200, 0, 100, 80), fill, null); + using var renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds.IsEmpty, Is.True, "the domain excludes the ellipse entirely"); + Assert.That(rasterization.IsEmpty, Is.True, "nothing is rasterized"); + Assert.That(renderer.HitTest(new Point(250, 40)), Is.False, "so nothing can be hit there either"); + }); + } + + [Test] + public void ARequestTargetDomainStillHitsTheContentItKeeps() + { + using var fill = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(new Rect(0, 0, 100, 80), fill, null); + using var renderer = CreateRenderer(node); + + Assert.That(renderer.HitTest(new Point(50, 40)), Is.True); + } + + [Test] + public void AFiniteLayerDoesNotHitWhatItsDomainClipsAway() + { + using var node = new ClippedLayerNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1f, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That( + measurement.OutputBounds, + Is.EqualTo(new Rect(50, 0, 50, 100)), + "the layer bounds stop at the domain"); + Assert.That(renderer.HitTest(new Point(60, 50)), Is.True, "inside both the input and the domain"); + Assert.That(renderer.HitTest(new Point(140, 50)), Is.False, "inside the input, outside the domain"); + }); + } + + [Test] + public void AFiniteTargetLayerScopeDoesNotHitWhatItsRegionClipsAway() + { + using var node = new ScopedCommandNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1f, + TargetDomain = ScopedCommandNode.CommandBounds, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + Assert.Multiple(() => + { + Assert.That(renderer.HitTest(new Point(30, 50)), Is.True, "inside both the command and the region"); + Assert.That( + renderer.HitTest(new Point(150, 50)), + Is.False, + "inside the command, outside the region the scope can write"); + }); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1f, + TargetDomain = s_targetDomain, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + private sealed class ScopedCommandNode : RenderNode + { + internal static readonly Rect CommandBounds = new(0, 0, 200, 100); + private static readonly Rect s_scopeRegion = new(0, 0, 60, 100); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle command = context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + static _ => { }, + TargetRegion.Region(CommandBounds), + CommandBounds, + RenderHitTestContract.OutputBounds)); + context.Publish(context.TargetLayerScope([command], TargetRegion.Region(s_scopeRegion))); + } + } + + private sealed class ClippedLayerNode : RenderNode + { + private static readonly Rect s_inputBounds = new(50, 0, 100, 100); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(OpaqueRenderDescription.CreateEngineSource( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + directReplay: null, + bounds: OpaqueRenderBoundsContract.Source(s_inputBounds), + hitTest: RenderHitTestContract.OutputBounds, + scale: RenderScaleContract.MaterializeAtWorkingScale, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive)); + context.Publish(context.Layer([source], s_targetDomain)); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestParityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestParityTests.cs index 150793a72d..17c211efad 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestParityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestParityTests.cs @@ -10,13 +10,22 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering; [TestFixture] public class HitTestParityTests { - private static RenderNodeOperation BuildEllipseOp(float outputScale) + private static bool HitEllipse(float outputScale, Point point) { var rect = new Rect(0, 0, 100, 80); - var fill = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); - var node = new EllipseRenderNode(rect, fill, null); - var context = new RenderNodeContext([], outputScale); - return node.Process(context)[0]; + using var fill = new SolidColorBrush(Colors.Red).ToResource(CompositionContext.Default); + using var node = new EllipseRenderNode(rect, fill, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = outputScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + return renderer.HitTest(point); } [TestCase(0.25f)] @@ -25,22 +34,20 @@ private static RenderNodeOperation BuildEllipseOp(float outputScale) [TestCase(2f)] public void HitTest_SameLogicalPoint_SameResultAtEveryScale(float outputScale) { - RenderNodeOperation atOne = BuildEllipseOp(1f); - RenderNodeOperation atScale = BuildEllipseOp(outputScale); - // One logical point inside the ellipse, one outside; both must agree across scales. var inside = new Point(50, 40); var outside = new Point(2, 2); + bool insideAtOne = HitEllipse(1, inside); + bool outsideAtOne = HitEllipse(1, outside); + bool insideAtScale = HitEllipse(outputScale, inside); + bool outsideAtScale = HitEllipse(outputScale, outside); Assert.Multiple(() => { - Assert.That(atScale.HitTest(inside), Is.EqualTo(atOne.HitTest(inside)), "inside-point parity"); - Assert.That(atScale.HitTest(outside), Is.EqualTo(atOne.HitTest(outside)), "outside-point parity"); - Assert.That(atScale.HitTest(inside), Is.True, "inside point should hit"); - Assert.That(atScale.HitTest(outside), Is.False, "outside point should miss"); + Assert.That(insideAtScale, Is.EqualTo(insideAtOne), "inside-point parity"); + Assert.That(outsideAtScale, Is.EqualTo(outsideAtOne), "outside-point parity"); + Assert.That(insideAtScale, Is.True, "inside point should hit"); + Assert.That(outsideAtScale, Is.False, "outside point should miss"); }); - - atOne.Dispose(); - atScale.Dispose(); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs index d29a94616d..193b6064ca 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs @@ -67,29 +67,25 @@ public void Update_ShouldReturnTrue_WhenPropertiesDoNotMatch() } [Test] - public void Process_WithoutInput_ShouldReturnEmptyRenderNodeOperation() + public void Measure_WithoutInput_ShouldReportRecordedFragment() { - var context = new RenderNodeContext([]); - ImageSource.Resource source = GetTestImageSourceResource(); - var node = new ImageSourceRenderNode(source, null, null); - var operations = node.Process(context); + using var node = new ImageSourceRenderNode(source, null, null); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); - Assert.That(operations, Is.Not.Empty); + Assert.That(measurement.HasFragments, Is.True); } [Test] - public void Process_WithInput_ShouldReturnExpectedRenderNodeOperation() + public void Measure_WithInput_ShouldReportRecordedFragment() { - var context = new RenderNodeContext([ - RenderNodeOperation.CreateLambda(default, _ => { }) - ]); - ImageSource.Resource source = GetTestImageSourceResource(); - var node = new ImageSourceRenderNode(source, null, null); - var operations = node.Process(context); + using var node = new InputFeedingNode(new ImageSourceRenderNode(source, null, null)); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); - Assert.That(operations, Is.Not.Empty); + Assert.That(measurement.HasFragments, Is.True); } [Test] @@ -100,13 +96,11 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideStroke() pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 50; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new ImageSourceRenderNode(source, null, penResource); - var operations = node.Process(context); + using var node = new ImageSourceRenderNode(source, null, penResource); + using var renderer = CreateRenderer(node); var point = new Point(-10, -10); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } [Test] @@ -117,13 +111,11 @@ public void HitTest_ShouldReturnFalse_WhenPointIsOutsideStroke() pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 50; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new ImageSourceRenderNode(source, null, penResource); - var operations = node.Process(context); + using var node = new ImageSourceRenderNode(source, null, penResource); + using var renderer = CreateRenderer(node); var point = new Point(60, 60); - Assert.That(operations[0].HitTest(point), Is.False); + Assert.That(renderer.HitTest(point), Is.False); } [Test] @@ -131,13 +123,11 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideFill() { ImageSource.Resource source = GetTestImageSourceResource(); Brush.Resource fill = Brushes.Resource.White; - var context = new RenderNodeContext([]); - - var node = new ImageSourceRenderNode(source, fill, null); - var operations = node.Process(context); + using var node = new ImageSourceRenderNode(source, fill, null); + using var renderer = CreateRenderer(node); var point = new Point(50, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } [Test] @@ -145,26 +135,55 @@ public void HitTest_ShouldReturnFalse_WhenPointIsOutsideFill() { ImageSource.Resource source = GetTestImageSourceResource(); Brush.Resource fill = Brushes.Resource.White; - var context = new RenderNodeContext([]); - - var node = new ImageSourceRenderNode(source, fill, null); - var operations = node.Process(context); + using var node = new ImageSourceRenderNode(source, fill, null); + using var renderer = CreateRenderer(node); var point = new Point(150, 150); - Assert.That(operations[0].HitTest(point), Is.False); + Assert.That(renderer.HitTest(point), Is.False); } // A decoded image reports concrete At(1) density, not Unbounded. [Test] - public void Process_OpReportsConcreteNativeDensity_NotUnbounded() + public void Measure_ReportsConcreteNativeDensity_NotUnbounded() { ImageSource.Resource source = GetTestImageSourceResource(); - var node = new ImageSourceRenderNode(source, Brushes.Resource.White, null); - var operations = node.Process(new RenderNodeContext([])); + using var node = new ImageSourceRenderNode(source, Brushes.Resource.White, null); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); - Assert.That(operations[0].EffectiveScale.IsUnbounded, Is.False, + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, "an image source must report a concrete density, not the vector Unbounded sentinel"); - Assert.That(operations[0].EffectiveScale.Value, Is.EqualTo(1f), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(1f), "an image drawn at its native 1:1 size has supply density 1"); } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private sealed class InputFeedingNode(RenderNode child) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle input = context.OpaqueSource( + OpaqueRenderDescription.CreateRequestLocal( + static _ => throw new AssertionException("Metadata recording must not execute opaque callbacks."), + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 1, 1)), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.Vector)); + context.PublishRange(context.RecordNode(child, [input])); + } + + protected override void OnDispose(bool disposing) + { + child.Dispose(); + base.OnDispose(disposing); + } + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterRequiredRegionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterRequiredRegionTests.cs new file mode 100644 index 0000000000..ed6bb19770 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterRequiredRegionTests.cs @@ -0,0 +1,360 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class LegacyFilterRequiredRegionTests +{ + [Test] + public void SubRegionRequest_RestrictsLegacyFilterSourceToBackwardRegion() + { + var sourceBounds = new Rect(0, 0, 400, 400); + var requestedRegion = new Rect(0, 0, 50, 50); + var observed = new List(); + using FilterEffectRenderNode filter = CreateBlurNode(sigma: 2); + filter.AddChild(ScaleRecordingTestHelper.Source( + EffectiveScale.At(1), + sourceBounds, + session => observed.Add(session.RequiredRegion))); + using var renderer = new RenderNodeRenderer( + filter, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new BudgetedCpuTargetFactory(int.MaxValue), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + // Blur declares a 3σ footprint, so the destination's 50×50 region can only be reached by the + // matching 6-unit apron of the source. + Assert.That(observed, Is.EqualTo(new[] { new Rect(0, 0, 56, 56) })); + } + + [Test] + public void SubRegionRequest_RestrictsErodeSourceToItsRadiusNeighbourhood() + { + var sourceBounds = new Rect(0, 0, 400, 400); + var requestedRegion = new Rect(0, 0, 50, 50); + var observed = new List(); + using FilterEffectRenderNode filter = CreateErodeNode(radius: 6); + filter.AddChild(ScaleRecordingTestHelper.Source( + EffectiveScale.At(1), + sourceBounds, + session => observed.Add(session.RequiredRegion))); + using var renderer = new RenderNodeRenderer( + filter, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new BudgetedCpuTargetFactory(int.MaxValue), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + // Erode declares an identity output map yet reads the whole radius neighbourhood, so the + // destination's 50×50 region still needs the source's matching 6-unit apron. + Assert.That(observed, Is.EqualTo(new[] { new Rect(0, 0, 56, 56) })); + } + + [Test] + public void ErodeUnderAPartialRegion_MatchesTheCompleteRenderRestrictedToThatRegion() + { + var sourceBounds = new Rect(0, 0, 100, 100); + var requestedRegion = new Rect(30, 30, 40, 40); + + (Rect completeBounds, float[] completeAlpha, int completeWidth) = + RasterizeErodedSquare(sourceBounds, radius: 6, requestedRegion: null); + (Rect partialBounds, float[] partialAlpha, int partialWidth) = + RasterizeErodedSquare(sourceBounds, radius: 6, requestedRegion); + + Assert.That(partialBounds, Is.EqualTo(requestedRegion)); + Assert.That(completeBounds, Is.EqualTo(sourceBounds)); + + var offsetX = (int)(partialBounds.X - completeBounds.X); + var offsetY = (int)(partialBounds.Y - completeBounds.Y); + var partialHeight = partialAlpha.Length / partialWidth; + var mismatches = 0; + for (int y = 0; y < partialHeight; y++) + { + for (int x = 0; x < partialWidth; x++) + { + float expected = completeAlpha[((y + offsetY) * completeWidth) + x + offsetX]; + float actual = partialAlpha[(y * partialWidth) + x]; + if (MathF.Abs(expected - actual) > 0.01f) + mismatches++; + } + } + + Assert.That(mismatches, Is.Zero, + "a partially requested erode must match the complete render inside the same region"); + } + + [Test] + public void OversizedElementAtHighScale_RendersWithoutExceedingTheDestinationFootprint() + { + const float scale = 4; + var frame = new PixelSize(400, 300); + var requestedSizes = new List(); + using FilterEffectRenderNode filter = CreateBlurNode(sigma: 4); + filter.AddChild(new RectangleRenderNode( + new Rect(0, 0, 4200, 4200), + Brushes.Resource.White, + null)); + // A materialization that ignores the destination's needs asks for the element's complete + // 4200×4200 footprint at density 4, which no backend can satisfy. + var factory = new BudgetedCpuTargetFactory(8192, requestedSizes); + using var destination = new CpuRenderTarget( + (int)(frame.Width * scale), + (int)(frame.Height * scale)); + using var canvas = new ImmediateCanvas(destination, scale, logicalSize: frame.ToSize(1)); + using var renderer = new RenderNodeRenderer( + filter, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = new Rect(default, frame.ToSize(1)), + OutputScale = scale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + renderer.Render(canvas); + + using Bitmap result = destination.Snapshot(); + Assert.Multiple(() => + { + Assert.That(CountCoveredPixels(result), Is.EqualTo(frame.Width * scale * frame.Height * scale), + "the blurred element must still cover the frame"); + Assert.That( + requestedSizes.Select(static size => Math.Max(size.Width, size.Height)), + Is.All.LessThanOrEqualTo(2048), + "no intermediate may exceed what the destination region needs"); + }); + } + + [Test] + public void OutOfDomainMorphologyRadius_RecordsNoStageAndLeavesTheDeclaredBoundsAlone( + [Values(-6f, 0f)] float radius) + { + var bounds = new Rect(0, 0, 120, 80); + using var dilate = new FilterEffectContext(bounds); + using var erode = new FilterEffectContext(bounds); + + dilate.Dilate(radius, radius); + erode.Erode(radius, radius); + + Assert.Multiple(() => + { + Assert.That(dilate.CountItems(), Is.Zero, + "a morphology radius outside the operation's domain must record no stage"); + Assert.That(erode.CountItems(), Is.Zero, + "a morphology radius outside the operation's domain must record no stage"); + Assert.That(dilate.Bounds, Is.EqualTo(bounds), + "a pass-through must not deflate the declared output bounds"); + Assert.That(erode.Bounds, Is.EqualTo(bounds)); + }); + } + + [Test] + public void MixedSignDilateRadius_StillRecordsTheInDomainAxis() + { + var bounds = new Rect(0, 0, 120, 80); + using var context = new FilterEffectContext(bounds); + + context.Dilate(-6, 5); + + Assert.Multiple(() => + { + Assert.That(context.CountItems(), Is.EqualTo(1), + "clamping is per axis, so an in-domain y radius still describes a real dilate"); + Assert.That(context.Bounds, Is.EqualTo(new Rect(0, -5, 120, 90)), + "only the in-domain axis may grow the declared output bounds"); + }); + } + + [Test] + public void NegativeDilateRadius_RasterizesTheCompleteSource( + [Values(RenderIntent.Preview, RenderIntent.Delivery)] RenderIntent intent) + { + var sourceBounds = new Rect(0, 0, 120, 80); + + (Rect passThroughBounds, float[] passThroughAlpha, int passThroughWidth) = + RasterizeDilatedSquare(sourceBounds, radius: 0, intent); + (Rect negativeBounds, float[] negativeAlpha, int negativeWidth) = + RasterizeDilatedSquare(sourceBounds, radius: -6, intent); + + Assert.Multiple(() => + { + Assert.That(negativeBounds, Is.EqualTo(sourceBounds), + "a dilate can never delete source content, so a negative radius must be a pass-through"); + Assert.That(negativeWidth, Is.EqualTo(passThroughWidth)); + Assert.That(passThroughBounds, Is.EqualTo(sourceBounds)); + }); + Assert.That(negativeAlpha, Is.EqualTo(passThroughAlpha).Within(0.01f)); + } + + [Test] + public void NegativeDilateRadiusWiderThanHalfTheSource_DoesNotFailADeliveryRender() + { + var sourceBounds = new Rect(0, 0, 120, 80); + + // A radius past half the shorter side is where an unclamped deflation turns the declared + // output negative-extent, which the flush reports as a non-allocatable delivery failure. + (Rect bounds, float[] alpha, _) = RasterizeDilatedSquare(sourceBounds, radius: -21, RenderIntent.Delivery); + + Assert.Multiple(() => + { + Assert.That(bounds, Is.EqualTo(sourceBounds)); + Assert.That(alpha, Has.Some.GreaterThan(0.01f), "the frame must not be blank"); + }); + } + + private static FilterEffectRenderNode CreateBlurNode(float sigma) + { + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(sigma, sigma); + return new FilterEffectRenderNode(blur.ToResource(CompositionContext.Default)); + } + + private static FilterEffectRenderNode CreateDilateNode(float radius) + { + var dilate = new Dilate(); + dilate.RadiusX.CurrentValue = radius; + dilate.RadiusY.CurrentValue = radius; + return new FilterEffectRenderNode(dilate.ToResource(CompositionContext.Default)); + } + + private static (Rect Bounds, float[] Alpha, int Width) RasterizeDilatedSquare( + Rect sourceBounds, + float radius, + RenderIntent intent) + { + using FilterEffectRenderNode filter = CreateDilateNode(radius); + filter.AddChild(new RectangleRenderNode(sourceBounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + filter, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new BudgetedCpuTargetFactory(int.MaxValue), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new InvalidOperationException("The dilate render produced no bitmap."); + + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + var alpha = new float[bitmap.Width * bitmap.Height]; + for (int index = 0; index < alpha.Length; index++) + alpha[index] = (float)BitConverter.UInt16BitsToHalf(pixels[(index * 4) + 3]); + + return (rasterization.Bounds, alpha, bitmap.Width); + } + + private static FilterEffectRenderNode CreateErodeNode(float radius) + { + var erode = new Erode(); + erode.RadiusX.CurrentValue = radius; + erode.RadiusY.CurrentValue = radius; + return new FilterEffectRenderNode(erode.ToResource(CompositionContext.Default)); + } + + private static (Rect Bounds, float[] Alpha, int Width) RasterizeErodedSquare( + Rect sourceBounds, + float radius, + Rect? requestedRegion) + { + using FilterEffectRenderNode filter = CreateErodeNode(radius); + filter.AddChild(new RectangleRenderNode(sourceBounds, Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + filter, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = requestedRegion, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new BudgetedCpuTargetFactory(int.MaxValue), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new InvalidOperationException("The erode render produced no bitmap."); + Assert.That(bitmap.ColorType, Is.EqualTo(BitmapColorType.RgbaF16)); + + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + var alpha = new float[bitmap.Width * bitmap.Height]; + for (int index = 0; index < alpha.Length; index++) + alpha[index] = (float)BitConverter.UInt16BitsToHalf(pixels[(index * 4) + 3]); + + return (rasterization.Bounds, alpha, bitmap.Width); + } + + private static long CountCoveredPixels(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + long count = 0; + for (int index = 0; index + 3 < pixels.Length; index += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index + 3]); + // The floor rejects NaN/subnormal/negative-zero alpha while accommodating the + // bilinear tail a budget-clamped materialization leaves at buffer edges. + if (float.IsFinite(alpha) && alpha >= 0.01f) + count++; + } + + return count; + } + + private sealed class BudgetedCpuTargetFactory(int maximumDimension, List? requestedSizes = null) + : IRenderTargetFactory + { + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + requestedSizes?.Add(deviceSize); + return deviceSize.Width > maximumDimension || deviceSize.Height > maximumDimension + ? null + : new CpuRenderTarget(deviceSize.Width, deviceSize.Height); + } + } + + private sealed class CpuRenderTarget : RenderTarget + { + public CpuRenderTarget(int width, int height) + : base(CreateSurface(width, height), width, height) + { + } + + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Failed to create a CPU surface."); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterTypedSuffixExecutionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterTypedSuffixExecutionTests.cs new file mode 100644 index 0000000000..1e3d69b09c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterTypedSuffixExecutionTests.cs @@ -0,0 +1,1009 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +[NonParallelizable] +public sealed class LegacyFilterTypedSuffixExecutionTests +{ + private const string BlueShader = + "half4 apply(half4 color) { return half4(0.0, 0.0, color.a, color.a); }"; + + [Test] + public void ShaderAfterUnknownCustomEffect_ExecutesAgainstMaterializedTarget() + { + Rect runtimeBounds = new(14, 25, 8, 6); + Rect observedInput = default; + Rect observedOutput = default; + RenderIntent? observedIntent = null; + RenderRequestPurpose? observedPurpose = null; + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.CustomEffect( + runtimeBounds, + static (bounds, execution) => + { + foreach (EffectTarget target in execution.Targets) + target.Bounds = bounds; + }); + context.Shader(ShaderDescription.CurrentPixel( + "uniform float marker; " + + "half4 apply(half4 color) { return half4(0.0, 0.0, color.a * marker, color.a); }", + bindings => bindings.Uniform( + "marker", + 1f, + (writer, value, execution) => + { + observedInput = execution.InputBounds; + observedOutput = execution.OutputBounds; + observedIntent = execution.Intent; + observedPurpose = execution.Purpose; + writer.Set(value); + }))); + }); + Rect inputBounds = new(10, 20, 8, 6); + + using EffectTargets targets = CreateSolidTargets(inputBounds, Colors.Red); + Apply(effect, inputBounds, targets); + + Assert.That(targets, Has.Count.EqualTo(1)); + SKColor color = ReadCenterPixel(targets[0]); + Assert.Multiple(() => + { + Assert.That(targets[0].Bounds, Is.EqualTo(runtimeBounds)); + Assert.That(observedInput, Is.EqualTo(runtimeBounds)); + Assert.That(observedOutput, Is.EqualTo(runtimeBounds)); + Assert.That(observedIntent, Is.EqualTo(RenderIntent.Preview)); + Assert.That(observedPurpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + Assert.That(color.Red, Is.LessThan(16)); + Assert.That(color.Green, Is.LessThan(16)); + Assert.That(color.Blue, Is.GreaterThan(239)); + Assert.That(color.Alpha, Is.GreaterThan(239)); + }); + } + + [Test] + public void ShaderAfterUnknownCustomEffect_UsesRendererProgramCacheAcrossFrames() + { + var effect = new LegacySuffixCallbackFilterEffect(static (context, _) => + { + context.CustomEffect(0, static (_, _) => { }); + context.Shader(ShaderDescription.CurrentPixel(BlueShader)); + }); + Rect bounds = new(0, 0, 8, 6); + using var root = new FilterEffectRenderNode( + effect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode( + bounds, + Brushes.Resource.Red, + null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization cold = renderer.Rasterize(); + ProgramCacheStatistics coldStatistics = renderer.ProgramCacheStatistics; + using RenderNodeRasterization warm = renderer.Rasterize(); + ProgramCacheStatistics warmStatistics = renderer.ProgramCacheStatistics; + SKColor coldColor = ReadCenterPixel(cold.Bitmap + ?? throw new AssertionException("The cold typed-suffix render produced no bitmap.")); + SKColor warmColor = ReadCenterPixel(warm.Bitmap + ?? throw new AssertionException("The warm typed-suffix render produced no bitmap.")); + + Assert.Multiple(() => + { + Assert.That(coldColor.Red, Is.LessThan(16)); + Assert.That(coldColor.Green, Is.LessThan(16)); + Assert.That(coldColor.Blue, Is.GreaterThan(239)); + Assert.That(coldColor.Alpha, Is.GreaterThan(239)); + Assert.That(warmColor.Red, Is.LessThan(16)); + Assert.That(warmColor.Green, Is.LessThan(16)); + Assert.That(warmColor.Blue, Is.GreaterThan(239)); + Assert.That(warmColor.Alpha, Is.GreaterThan(239)); + Assert.That(coldStatistics.Creations, Is.EqualTo(1)); + Assert.That(coldStatistics.Misses, Is.EqualTo(1)); + Assert.That(coldStatistics.Hits, Is.Zero); + Assert.That(warmStatistics.Creations, Is.EqualTo(1)); + Assert.That(warmStatistics.Misses, Is.EqualTo(1)); + Assert.That(warmStatistics.Hits, Is.EqualTo(1)); + Assert.That(renderer.LastExecutionStatistics.ProgramCacheHits, Is.EqualTo(1)); + }); + } + + // A custom effect lays its targets out in device pixels against its input, which is anchored on the + // whole-pixel part of the ambient translation. The grid it allocates on has to be that same grid: + // keeping the translation's fraction would place every new target half a pixel off the input. + [Test] + public void MaterializedInput_CustomEffect_AllocatesOnTheGridItsInputIsAnchoredOn() + { + var translation = new Vector(2.25f, 3.75f); + Vector observedAmbientGrid = default; + Vector observedInputGrid = default; + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + context.CustomEffect( + 0, + (_, execution) => + { + observedAmbientGrid = execution.DeviceGridOffset; + observedInputGrid = execution.Targets.Single().DeviceGridOffset; + }, + static (_, bounds) => bounds)); + + RenderMaterializedEffect(effect, translation); + + Assert.Multiple(() => + { + Assert.That(observedAmbientGrid, Is.EqualTo(new Vector(2f, 3f))); + Assert.That(observedInputGrid, Is.EqualTo(new Vector(2f, 3f))); + }); + } + + [Test] + public void SourceGridReplacement_FlowsIntoFollowingCustomStage() + { + var translation = new Vector(2.25f, 3.75f); + Vector replacementGrid = new(float.NaN, float.NaN); + Vector followingAmbientGrid = default; + Vector followingInputGrid = new(float.NaN, float.NaN); + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.CustomEffect( + 0, + (_, execution) => + { + EffectTarget source = execution.Targets.Single(); + using RenderTarget replacementBacking = source.RenderTarget!.ShallowCopy(); + EffectTarget replacement = execution.CreateReplacement( + source, + replacementBacking); + source.Dispose(); + execution.Targets[0] = replacement; + replacementGrid = replacement.DeviceGridOffset; + }, + static (_, bounds) => bounds); + context.CustomEffect( + 1, + (_, execution) => + { + followingAmbientGrid = execution.DeviceGridOffset; + followingInputGrid = execution.Targets.Single().DeviceGridOffset; + }, + static (_, bounds) => bounds); + }); + + RenderMaterializedEffect(effect, translation); + + Assert.Multiple(() => + { + Assert.That(replacementGrid, Is.EqualTo(new Vector(2f, 3f))); + Assert.That(followingAmbientGrid, Is.EqualTo(new Vector(2f, 3f))); + Assert.That(followingInputGrid, Is.EqualTo(new Vector(2f, 3f))); + }); + } + + [Test] + public void CompatibilityShader_ProgramAcquirerReceivesExecutionDestination() + { + Rect bounds = new(0, 0, 8, 6); + using EffectTargets targets = CreateSolidTargets(bounds, Colors.Red); + EffectTarget input = targets[0]; + using ProgramCache cache = SkRuntimeEffectProgramCache.Create(); + EffectTarget? observedTarget = null; + + FilterEffectStageFallbackExecutor.ApplyShader( + targets, + ShaderDescription.CurrentPixel(BlueShader), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + (target, source) => + { + observedTarget = target; + return SkRuntimeEffectProgramCache.AcquireForDestination( + cache, + target.RenderTarget!, + source); + }, + leaseSession: null); + + Assert.Multiple(() => + { + Assert.That(observedTarget, Is.SameAs(targets[0])); + Assert.That(observedTarget, Is.Not.SameAs(input)); + Assert.That(observedTarget!.RenderTarget, Is.Not.Null); + Assert.That(input.IsEmpty, Is.True); + }); + } + + // The compatibility executor had the request intent in scope but opened every canvas on the + // RenderIntent.Preview default, so a delivery render degraded there instead of failing. + [TestCase(RenderIntent.Preview)] + [TestCase(RenderIntent.Delivery)] + public void CompatibilityGeometry_CallbackCanvasCarriesTheRequestIntent(RenderIntent intent) + { + Rect bounds = new(0, 0, 8, 6); + using EffectTargets targets = CreateSolidTargets(bounds, Colors.Red); + RenderIntent? observedIntent = null; + + FilterEffectStageFallbackExecutor.ApplyGeometry( + targets, + GeometryDescription.CreateRequestLocal( + session => session.Canvas.Use(canvas => observedIntent = canvas.Intent), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + intent, + RenderRequestPurpose.Auxiliary, + leaseSession: null); + + Assert.That(observedIntent, Is.EqualTo(intent)); + } + + [Test] + public void GeometryAfterUnknownCustomEffect_UsesRuntimeBoundsAndPublishesShrink() + { + Rect runtimeBounds = new(30, 40, 8, 6); + Rect mappedBounds = runtimeBounds.Inflate(new Thickness(2)); + Rect selectedBounds = mappedBounds.Inflate(new Thickness(-1)); + Rect observedInput = default; + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.CustomEffect( + runtimeBounds, + static (bounds, execution) => + { + foreach (EffectTarget target in execution.Targets) + target.Bounds = bounds; + }); + context.Geometry(GeometryDescription.CreateRequestLocal( + session => + { + observedInput = session.Input.Bounds; + session.Canvas.Use(static canvas => canvas.Clear(Colors.Lime)); + session.SetOutputBounds(session.OutputBounds.Inflate(new Thickness(-1))); + }, + RenderBoundsContract.Create( + static bounds => bounds.Inflate(new Thickness(2)), + static bounds => bounds.Inflate(new Thickness(2))), + RenderHitTestContract.AnyInput)); + }); + Rect recordedBounds = new(10, 20, 8, 6); + + using EffectTargets targets = CreateSolidTargets(recordedBounds, Colors.Red); + Apply(effect, recordedBounds, targets); + + Assert.That(targets, Has.Count.EqualTo(1)); + SKColor color = ReadCenterPixel(targets[0]); + Assert.Multiple(() => + { + Assert.That(observedInput, Is.EqualTo(runtimeBounds)); + Assert.That(targets[0].Bounds, Is.EqualTo(selectedBounds)); + Assert.That(color.Red, Is.LessThan(16)); + Assert.That(color.Green, Is.GreaterThan(239)); + Assert.That(color.Blue, Is.LessThan(16)); + Assert.That(color.Alpha, Is.GreaterThan(239)); + }); + } + + [Test] + public void DelayAnimationEffect_ExecutesTypedChildEffect() + { + var child = new LegacySuffixCallbackFilterEffect(static (context, _) => + context.Shader(ShaderDescription.CurrentPixel(BlueShader))); + var delay = new DelayAnimationEffect + { + Delay = { CurrentValue = 0 }, + Effect = { CurrentValue = child }, + }; + Rect bounds = new(4, 7, 8, 6); + + using EffectTargets targets = CreateSolidTargets(bounds, Colors.Red); + Apply(delay, bounds, targets); + + Assert.That(targets, Has.Count.EqualTo(1)); + SKColor color = ReadCenterPixel(targets[0]); + Assert.Multiple(() => + { + Assert.That(color.Red, Is.LessThan(16)); + Assert.That(color.Blue, Is.GreaterThan(239)); + Assert.That(color.Alpha, Is.GreaterThan(239)); + }); + } + + [Test] + public void DelayAnimationEffect_ChildRollbackPreservesPrimaryFailureOverCleanupFailure() + { + var primary = new InvalidOperationException("delay-child-primary"); + var cleanup = new ThrowingDisposable(); + var child = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.Own(cleanup); + context.Shader(ShaderDescription.CurrentPixel(BlueShader)); + throw primary; + }); + var delay = new DelayAnimationEffect + { + Delay = { CurrentValue = 0 }, + Effect = { CurrentValue = child }, + }; + Rect bounds = new(0, 0, 8, 6); + using FilterEffect.Resource resource = delay.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(bounds); + context.ApplyTransactional(delay, resource); + using EffectTargets targets = CreateSolidTargets(bounds, Colors.Red); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + + InvalidOperationException? thrown = Assert.Throws( + () => activator.Apply(context)); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(primary)); + Assert.That(cleanup.DisposeCount, Is.EqualTo(1)); + Assert.That( + primary.Data["FilterEffectResourceRollbackFailure"], + Is.TypeOf()); + }); + } + + [Test] + public void UnknownCustomEffect_FinalValueIsCroppedToOwningDomainAfterInternalAllocation() + { + Rect domain = new(0, 0, 20, 10); + Rect expandedBounds = new(-5, -3, 30, 16); + PixelSize observedInternalAllocation = default; + Rect observedDownstreamInput = default; + var expandingEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.CustomEffect( + 0, + (_, execution) => execution.ForEach((_, _) => + { + EffectTarget expanded = execution.CreateTarget(expandedBounds); + observedInternalAllocation = expanded.DeviceBounds.Size; + using ImmediateCanvas canvas = execution.Open(expanded); + canvas.Clear(Colors.Magenta); + return expanded; + }))); + var downstreamEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.Geometry(GeometryDescription.CreateRequestLocal( + session => + { + observedDownstreamInput = session.Input.Bounds; + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput))); + var inner = new FilterEffectRenderNode(expandingEffect.ToResource(CompositionContext.Default)); + inner.AddChild(new RectangleRenderNode( + new Rect(4, 2, 6, 4), + Brushes.Resource.White, + null)); + using var root = new FilterEffectRenderNode( + downstreamEffect.ToResource(CompositionContext.Default)); + root.AddChild(inner); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + SKColor center = bitmap.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + + Assert.Multiple(() => + { + Assert.That(observedInternalAllocation, Is.EqualTo(new PixelSize(30, 16))); + Assert.That(observedDownstreamInput, Is.EqualTo(domain)); + Assert.That(rasterization.Bounds, Is.EqualTo(domain)); + Assert.That(bitmap.Width, Is.EqualTo(20)); + Assert.That(bitmap.Height, Is.EqualTo(10)); + Assert.That(center.Red, Is.GreaterThan(239)); + Assert.That(center.Blue, Is.GreaterThan(239)); + Assert.That(center.Alpha, Is.GreaterThan(239)); + }); + } + + [Test] + public void UnknownCustomEffect_OwningDomainNarrowingKeepsLegacyRasterPlacement() + { + var domain = new Rect(0, 0, 20, 14); + var expandedBounds = new Rect(-3.5f, -2.25f, 26, 18.5f); + RenderTarget? retainedAllocation = null; + var expandingEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.CustomEffect( + 0, + (_, execution) => execution.ForEach((_, _) => + { + EffectTarget expanded = execution.CreateTarget(expandedBounds); + using (ImmediateCanvas canvas = execution.Open(expanded)) + { + canvas.Clear(Colors.Magenta); + canvas.DrawRectangle( + new Rect(7.5f, 5.25f, 4, 3), + Brushes.Resource.White, + null); + } + + retainedAllocation = expanded.RenderTarget!.ShallowCopy(); + return expanded; + }))); + using var root = new FilterEffectRenderNode( + expandingEffect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode( + new Rect(4, 2, 6, 4), + Brushes.Resource.Red, + null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using var actualTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var actualCanvas = new ImmediateCanvas(actualTarget, logicalSize: domain.Size)) + { + actualCanvas.Clear(); + renderer.Render(actualCanvas); + } + + using RenderTarget allocation = retainedAllocation + ?? throw new AssertionException("The custom effect did not allocate an expanded target."); + using var expectedTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var expectedCanvas = new ImmediateCanvas(expectedTarget, logicalSize: domain.Size)) + { + expectedCanvas.Clear(); + expectedCanvas.DrawRenderTarget(allocation, expandedBounds.Position); + } + + using Bitmap actual = actualTarget.Snapshot(); + using Bitmap expected = expectedTarget.Snapshot(); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "narrowing a legacy raster-placement value to the owning domain must relabel its bounds " + + "instead of re-allocating and re-anchoring its pixels"); + } + + [Test] + public void FirstCustomEffect_MovedSemanticBoundsRetainPreCallbackBacking() + { + var inputBounds = new Rect(0, 0, 12, 10); + var movedBounds = new Rect(4.5f, 3.5f, 4, 3); + Rect observedBounds = default; + Rect observedRasterBounds = default; + PixelRect observedDeviceBounds = default; + var movingEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.CustomEffect( + movedBounds, + static (bounds, execution) => + { + foreach (EffectTarget target in execution.Targets) + target.Bounds = bounds; + }, + static (bounds, _) => bounds)); + var observingEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.Geometry(GeometryDescription.CreateRequestLocal( + session => + { + observedBounds = session.Input.Bounds; + observedDeviceBounds = session.Input.DeviceBounds; + observedRasterBounds = session.Input.RasterBounds; + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput))); + using var source = new CpuRenderTarget((int)inputBounds.Width, (int)inputBounds.Height); + source.Value.Canvas.Clear(SKColors.White); + source.Value.Flush(); + var movingNode = new FilterEffectRenderNode( + movingEffect.ToResource(CompositionContext.Default)); + movingNode.AddChild(new MaterializedInputNode(source, inputBounds)); + using var root = new FilterEffectRenderNode( + observingEffect.ToResource(CompositionContext.Default)); + root.AddChild(movingNode); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 24, 20), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + MaxWorkingScale = 1, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Rect movedInputRaster = inputBounds.Translate(movedBounds.Position - inputBounds.Position); + var expectedDeviceBounds = new PixelRect( + default, + new PixelSize((int)inputBounds.Width, (int)inputBounds.Height)); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(observedBounds, Is.EqualTo(movedBounds)); + Assert.That(observedDeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(observedRasterBounds, Is.EqualTo(movedInputRaster)); + Assert.That(observedDeviceBounds.Size, Is.EqualTo(new PixelSize(12, 10))); + }); + } + + [TestCase(1)] + [TestCase(2)] + public void FractionalLegacyTarget_ReplaysExactlyLikeDirectPointComposite(int customCount) + { + var domain = new Rect(0, 0, 20, 14); + var legacyBounds = new Rect(2.5f, 1.5f, 9.75f, 7.25f); + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.CustomEffect( + legacyBounds, + static (bounds, execution) => execution.ForEach((_, _) => + { + EffectTarget output = execution.CreateTarget(bounds); + using ImmediateCanvas canvas = execution.Open(output); + DrawLegacyPattern(canvas); + return output; + }), + static (bounds, _) => bounds); + if (customCount == 2) + { + context.CustomEffect( + 0, + static (_, _) => { }, + static (_, bounds) => bounds); + } + }); + using var root = new FilterEffectRenderNode( + effect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode( + new Rect(0, 0, 1, 1), + Brushes.Resource.White, + null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var actualTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var actualCanvas = new ImmediateCanvas(actualTarget, logicalSize: domain.Size)) + { + actualCanvas.Clear(); + renderer.Render(actualCanvas); + } + + using var localTarget = new CpuRenderTarget(9, 7); + using (var localCanvas = new ImmediateCanvas(localTarget, logicalSize: legacyBounds.Size)) + { + DrawLegacyPattern(localCanvas); + } + + using var expectedTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var expectedCanvas = new ImmediateCanvas(expectedTarget, logicalSize: domain.Size)) + { + expectedCanvas.Clear(); + expectedCanvas.DrawRenderTarget(localTarget, legacyBounds.Position); + } + + using Bitmap actual = actualTarget.Snapshot(); + using Bitmap expected = expectedTarget.Snapshot(); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + $"{customCount} legacy CustomEffect boundary/boundaries changed direct point-blit pixels"); + } + + [Test] + public void FractionalLegacyInput_RetainsDirectPlacementWhenCallbackMovesBounds() + { + var domain = new Rect(0, 0, 20, 14); + var sourceBounds = new Rect(2.5f, 1.5f, 9.75f, 7.25f); + var movedBounds = new Rect(5.25f, 3.75f, sourceBounds.Width, sourceBounds.Height); + RenderTarget? retainedInput = null; + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + context.CustomEffect( + movedBounds, + (bounds, execution) => execution.ForEach((_, target) => + { + retainedInput = target.RenderTarget!.ShallowCopy(); + target.Bounds = bounds; + return target; + }), + static (bounds, _) => bounds)); + using var root = new FilterEffectRenderNode( + effect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode( + sourceBounds, + Brushes.Resource.OrangeRed, + null)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var actualTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var actualCanvas = new ImmediateCanvas(actualTarget, logicalSize: domain.Size)) + { + actualCanvas.Clear(); + renderer.Render(actualCanvas); + } + + using RenderTarget localTarget = retainedInput + ?? throw new AssertionException("The legacy callback did not receive a materialized input."); + Assert.That(localTarget.Width, Is.EqualTo(9)); + Assert.That(localTarget.Height, Is.EqualTo(7)); + + using var expectedTarget = new CpuRenderTarget((int)domain.Width, (int)domain.Height); + using (var expectedCanvas = new ImmediateCanvas(expectedTarget, logicalSize: domain.Size)) + { + expectedCanvas.Clear(); + expectedCanvas.DrawRenderTarget(localTarget, movedBounds.Position); + } + + using Bitmap actual = actualTarget.Snapshot(); + using Bitmap expected = expectedTarget.Snapshot(); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "moving retained legacy input pixels must not insert a canonical normalization pass"); + } + + [Test] + public void PolicyBearingNoOpSkiaItem_MaterializesAtResolvedWorkingScale() + { + const float inputDensity = 0.5f; + var bounds = new Rect(0, 0, 12, 10); + EffectiveScale observedScale = default; + PixelRect observedDeviceBounds = default; + var noOpSkiaEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.AppendSkiaFilter( + 0, + static (_, _, _) => null, + static (_, current) => current)); + var observingEffect = new LegacySuffixCallbackFilterEffect((context, _) => + context.Geometry(GeometryDescription.CreateRequestLocal( + session => + { + observedScale = session.Input.EffectiveScale; + observedDeviceBounds = session.Input.DeviceBounds; + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput))); + PixelRect inputDeviceBounds = PixelRect.FromRect(bounds, inputDensity); + using var source = new CpuRenderTarget(inputDeviceBounds.Width, inputDeviceBounds.Height); + source.Value.Canvas.Clear(SKColors.White); + source.Value.Flush(); + var noOpNode = new FilterEffectRenderNode( + noOpSkiaEffect.ToResource(CompositionContext.Default)); + noOpNode.AddChild(new MaterializedInputNode(source, bounds, inputDensity)); + using var root = new FilterEffectRenderNode( + observingEffect.ToResource(CompositionContext.Default)); + root.AddChild(noOpNode); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + MaxWorkingScale = 1, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(observedScale, Is.EqualTo(EffectiveScale.At(1))); + Assert.That(observedDeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 1))); + }); + } + + // A Skia chain executes in a frame anchored at the displacement its items give the chain-start + // Bounds.Position. A target the fallback executor allocated - or one handed in through the public + // EffectTarget(RenderTarget, Rect, EffectiveScale) constructor - starts that chain with + // OriginalBounds == Bounds, so anything reading the anchor off those two rects reads zero and the + // flush composites the content at -Bounds.Position, dropping all but a corner of the buffer. + [Test] + public void SkiaFilterOnAnOffsetOriginalBoundsTarget_FlushesTheWholeBuffer() + { + var bounds = new Rect(8, 6, 12, 10); + var effect = new LegacySuffixCallbackFilterEffect((context, _) => AppendOpaqueSkiaFilter(context)); + + using EffectTargets targets = CreateSolidTargets(bounds, Colors.White); + Apply(effect, bounds, targets); + + AssertFlushedBufferIsFullyCovered(targets); + } + + // The production stream: an unknown CustomEffect (no bounds function - Clipping with AutoClip, or + // any out-of-tree effect) sends the tail render-time, the shader takes the fallback executor, and + // the Skia item behind it begins its chain on the stage output that executor allocated. + [Test] + public void SkiaFilterAfterAFallbackShaderStage_FlushesTheWholeBuffer() + { + var bounds = new Rect(8, 6, 12, 10); + var effect = new LegacySuffixCallbackFilterEffect((context, _) => + { + context.CustomEffect(0, static (_, _) => { }); + context.Shader(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + AppendOpaqueSkiaFilter(context); + }); + + using EffectTargets targets = CreateSolidTargets(bounds, Colors.White); + Apply(effect, bounds, targets); + + AssertFlushedBufferIsFullyCovered(targets); + } + + private static void AppendOpaqueSkiaFilter(FilterEffectContext context) + => context.AppendSkiaFilter( + 0, + static (_, input, _) => SKImageFilter.CreateColorFilter( + SKColorFilter.CreateBlendMode(SKColors.Red, SKBlendMode.SrcIn), + input), + static (_, current) => current); + + private static void AssertFlushedBufferIsFullyCovered(EffectTargets targets) + { + Assert.That(targets, Has.Count.EqualTo(1)); + using Bitmap bitmap = targets[0].RenderTarget!.Snapshot(); + Assert.That( + OpaqueBounds(bitmap), + Is.EqualTo(new PixelRect(0, 0, bitmap.Width, bitmap.Height)), + "a filtered opaque source must still cover the buffer it was flushed into"); + } + + private static PixelRect OpaqueBounds(Bitmap bitmap) + { + int left = bitmap.Width; + int top = bitmap.Height; + int right = 0; + int bottom = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.SKBitmap.GetPixel(x, y).Alpha == 0) + continue; + + left = Math.Min(left, x); + top = Math.Min(top, y); + right = Math.Max(right, x + 1); + bottom = Math.Max(bottom, y + 1); + } + } + + return right <= left || bottom <= top + ? default + : new PixelRect(left, top, right - left, bottom - top); + } + + private static void Apply(FilterEffect effect, Rect bounds, EffectTargets targets) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(bounds); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1); + activator.Apply(context); + activator.Flush(false); + } + + private static EffectTargets CreateSolidTargets(Rect bounds, Color color) + { + using RenderTarget renderTarget = RenderTarget.Create((int)bounds.Width, (int)bounds.Height) + ?? throw new InvalidOperationException("A CPU render target is required for this test."); + using (var canvas = new ImmediateCanvas( + renderTarget, + density: 1, + maxWorkingScale: 1, + logicalSize: bounds.Size)) + { + canvas.Clear(color); + } + + return new EffectTargets + { + new EffectTarget(renderTarget, bounds, EffectiveScale.At(1)), + }; + } + + private static SKColor ReadCenterPixel(EffectTarget target) + { + using Bitmap bitmap = target.RenderTarget!.Snapshot(); + return bitmap.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + } + + private static SKColor ReadCenterPixel(Bitmap bitmap) + => bitmap.SKBitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + + private static void DrawLegacyPattern(ImmediateCanvas canvas) + { + canvas.Clear(); + canvas.DrawRectangle( + new Rect(0.25f, 0.25f, 8.25f, 6.25f), + Brushes.Resource.OrangeRed, + null); + canvas.DrawRectangle( + new Rect(2.25f, 1.75f, 3.5f, 2.5f), + Brushes.Resource.White, + null); + } + + private static void RenderMaterializedEffect(FilterEffect effect, Vector translation) + { + var bounds = new Rect(8, 6, 12, 10); + using var source = new CpuRenderTarget(12, 10); + source.Value.Canvas.Clear(SKColors.White); + source.Value.Flush(); + using var root = new FilterEffectRenderNode( + effect.ToResource(CompositionContext.Default)); + root.AddChild(new MaterializedInputNode(source, bounds)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 32, 24), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + MaxWorkingScale = 1, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var destination = new CpuRenderTarget(32, 24); + using var canvas = new ImmediateCanvas( + destination, + logicalSize: new Size(32, 24)); + canvas.Clear(); + using (canvas.PushTransform(Matrix.CreateTranslation(translation))) + { + renderer.Render(canvas); + } + } + + private sealed class ThrowingDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + throw new InvalidOperationException("delay-child-cleanup"); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private sealed class MaterializedInputNode( + RenderTarget source, + Rect bounds, + float density = 1) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource target = context.Borrow(source); + context.Publish(context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + target, + bounds, + EffectiveScale.At(density), + PixelRect.FromRect(bounds, density), + default, + RenderHitTestContract.OutputBounds))); + } + } +} + +[SuppressResourceClassGeneration] +internal sealed partial class LegacySuffixCallbackFilterEffect( + Action apply) : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => apply(context, resource); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MappedInputReadbackIntentTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MappedInputReadbackIntentTests.cs new file mode 100644 index 0000000000..90053ece34 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MappedInputReadbackIntentTests.cs @@ -0,0 +1,148 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// A mapped-input readback failure must decide degrade-vs-fail from the explicit +/// , not from the working-scale ceiling that happens to accompany it, and a +/// replacement allocation failure must keep Preview sources and fail Delivery renders. +/// +[TestFixture] +public sealed class MappedInputReadbackIntentTests +{ + [TestCase(float.PositiveInfinity)] + [TestCase(2f)] + public void DeliveryReadbackFailureFailsRegardlessOfTheWorkingScaleCeiling(float maxWorkingScale) + { + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); + + Assert.That( + () => CustomFilterEffectContext.ThrowIfDeliveryReadbackFailure( + context.Intent, + new PixelRect(0, 0, 4, 3)), + Throws.TypeOf() + .With.Message.Contains("4x3 px") + .And.Message.Contains("delivery render fails")); + } + + [TestCase(float.PositiveInfinity)] + [TestCase(2f)] + public void PreviewReadbackFailureDegradesRegardlessOfTheWorkingScaleCeiling(float maxWorkingScale) + { + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); + + Assert.That( + () => CustomFilterEffectContext.ThrowIfDeliveryReadbackFailure( + context.Intent, + new PixelRect(0, 0, 4, 3)), + Throws.Nothing); + } + + [Test] + public void PreviewReplacementAllocationFailureKeepsTheSource() + { + using EffectTarget source = CreateUnallocatableSourceTarget(); + using var targets = new EffectTargets { source.Clone() }; + var context = CreateContext(targets, RenderIntent.Preview); + EffectTarget original = targets[0]; + using EffectTarget replacement = context.CreateTargetLike(original); + + Assert.That(replacement.IsEmpty, Is.True); + Assert.That(targets[0], Is.SameAs(original)); + Assert.That(original.RenderTarget, Is.Not.Null); + } + + [Test] + public void DeliveryReplacementAllocationFailureIncludesTheDeviceFootprint() + { + using EffectTarget source = CreateUnallocatableSourceTarget(); + using var targets = new EffectTargets { source.Clone() }; + var context = CreateContext(targets, RenderIntent.Delivery); + EffectTarget original = targets[0]; + + Assert.That( + () => context.CreateTargetLike(original), + Throws.TypeOf() + .With.Message.Contains($"{int.MaxValue}x3 px") + .And.Message.Contains("delivery render fails")); + Assert.That(targets[0], Is.SameAs(original)); + Assert.That(original.RenderTarget, Is.Not.Null); + } + + [Test] + public void DeliveryUnmaterializedSourceIsALegitimateSkip() + { + using var targets = new EffectTargets { new EffectTarget() }; + var context = CreateContext(targets, RenderIntent.Delivery); + using EffectTarget replacement = context.CreateTargetLike(targets[0]); + + Assert.That(replacement.IsEmpty, Is.True); + } + + [Test] + public void LayerEffectPreviewAllocationFailureKeepsTheSource() + { + using var source = new CpuRenderTarget(new PixelSize(1, 1)); + using var targets = new EffectTargets { new EffectTarget(source, Rect.Empty) }; + EffectTarget original = targets[0]; + + Assert.That( + () => ApplyCustomDirect(new LayerEffect(), Rect.Empty, targets, RenderIntent.Preview), + Throws.Nothing); + Assert.That(targets[0], Is.SameAs(original)); + Assert.That(original.RenderTarget, Is.Not.Null); + } + + private static void ApplyCustomDirect( + FilterEffect effect, + Rect bounds, + EffectTargets targets, + RenderIntent intent) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var recording = new FilterEffectContext(bounds, outputScale: 1f, workingScale: 1f); + recording.ApplyTransactional(effect, resource); + IFEItem_Custom item = recording.GetOrderedItems().OfType().Single(); + item.Accepts(CreateContext(targets, intent)); + } + + private static CustomFilterEffectContext CreateContext(EffectTargets targets, RenderIntent intent) + => new( + targets, + intent, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: 1f); + + private static EffectTarget CreateUnallocatableSourceTarget() + { + using var target = new CpuRenderTarget(new PixelSize(int.MaxValue, 3)); + return new EffectTarget(target, new Rect(0, 0, 4, 3)); + } + + private sealed class CpuRenderTarget(PixelSize size) + : RenderTarget( + SkiaSharp.SKSurface.Create(new SkiaSharp.SKImageInfo(1, 1)) + ?? throw new InvalidOperationException("Could not create a test surface."), + size.Width, + size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs index ecad0940c9..3f81aae003 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/MaxWorkingScaleSanitizationTests.cs @@ -21,7 +21,7 @@ private static IEnumerable DegenerateCeilings() [TestCaseSource(nameof(DegenerateCeilings))] public void SanitizeMaxWorkingScale_DegenerateValue_BecomesPositiveInfinity(float value) { - Assert.That(RenderNodeContext.SanitizeMaxWorkingScale(value), Is.EqualTo(float.PositiveInfinity)); + Assert.That(RenderScaleUtilities.SanitizeMaxWorkingScale(value), Is.EqualTo(float.PositiveInfinity)); } [TestCase(1f)] @@ -29,14 +29,14 @@ public void SanitizeMaxWorkingScale_DegenerateValue_BecomesPositiveInfinity(floa [TestCase(float.PositiveInfinity)] public void SanitizeMaxWorkingScale_FiniteOrInfinitePositive_PassesThrough(float value) { - Assert.That(RenderNodeContext.SanitizeMaxWorkingScale(value), Is.EqualTo(value)); + Assert.That(RenderScaleUtilities.SanitizeMaxWorkingScale(value), Is.EqualTo(value)); } [TestCaseSource(nameof(DegenerateCeilings))] public void ResolveWorkingScale_DegenerateCeiling_DoesNotPropagate(float maxWorkingScale) { // Supply equals outputScale (2); a degenerate ceiling must not drag it to NaN/0 via MathF.Min. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( ReadOnlySpan.Empty, outputScale: 2f, maxWorkingScale: maxWorkingScale); Assert.That(w, Is.EqualTo(2f)); @@ -45,7 +45,7 @@ public void ResolveWorkingScale_DegenerateCeiling_DoesNotPropagate(float maxWork [Test] public void ResolveWorkingScale_FiniteCeiling_CapsSupply() { - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( ReadOnlySpan.Empty, outputScale: 4f, maxWorkingScale: 3f); Assert.That(w, Is.EqualTo(3f)); @@ -55,7 +55,7 @@ public void ResolveWorkingScale_FiniteCeiling_CapsSupply() public void ResolveWorkingScale_UnboundedInput_DoesNotRaiseSupply() { // Unbounded (vector) inputs are excluded from the supply max (FR-019). - float w = RenderNodeContext.ResolveWorkingScale([EffectiveScale.Unbounded], outputScale: 2f); + float w = RenderScaleUtilities.ResolveWorkingScale([EffectiveScale.Unbounded], outputScale: 2f); Assert.That(w, Is.EqualTo(2f)); } @@ -64,7 +64,7 @@ public void ResolveWorkingScale_UnboundedInput_DoesNotRaiseSupply() public void ResolveWorkingScale_ConcreteSupplyAboveOutput_IgnoresUnboundedAndTracksSupply() { // The Unbounded sentinel is skipped; the concrete supply (10) drives the result. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.Unbounded, EffectiveScale.At(10f)], outputScale: 2f); Assert.That(w, Is.EqualTo(10f)); @@ -74,7 +74,7 @@ public void ResolveWorkingScale_ConcreteSupplyAboveOutput_IgnoresUnboundedAndTra public void ResolveWorkingScale_ConcreteSupplyExceedsFiniteCeiling_CapsToCeiling() { // A finite ceiling caps a concrete supply that exceeds it. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(8f)], outputScale: 2f, maxWorkingScale: 5f); Assert.That(w, Is.EqualTo(5f)); @@ -83,39 +83,49 @@ public void ResolveWorkingScale_ConcreteSupplyExceedsFiniteCeiling_CapsToCeiling [Test] public void ResolveWorkingScale_ConcreteSupplyUnderInfiniteCeiling_TracksSupply() { - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(8f)], outputScale: 2f, maxWorkingScale: float.PositiveInfinity); Assert.That(w, Is.EqualTo(8f)); } // These guard tests pin the Sanitize call at each public entry point so dropping it fails loudly. - // Renderer is omitted: its constructor allocates a GPU RenderTarget on the render thread. - - [TestCaseSource(nameof(DegenerateCeilings))] - public void RenderNodeContext_DegenerateCeiling_StoredAsPositiveInfinity(float maxWorkingScale) - { - var context = new RenderNodeContext([], outputScale: 1f, maxWorkingScale: maxWorkingScale); - - Assert.That(context.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); - } - [TestCaseSource(nameof(DegenerateCeilings))] - public void RenderNodeProcessor_DegenerateCeiling_StoredAsPositiveInfinity(float maxWorkingScale) + public void RenderNodeRenderer_DegenerateCeiling_StoredAsPositiveInfinity(float maxWorkingScale) { - var processor = new RenderNodeProcessor( - new ContainerRenderNode(), useRenderCache: false, outputScale: 1f, maxWorkingScale: maxWorkingScale); + using var node = new ContainerRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = maxWorkingScale, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); - Assert.That(processor.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); } [Test] - public void RenderNodeProcessor_FinitePositiveCeiling_PassesThrough() + public void RenderNodeRenderer_FinitePositiveCeiling_PassesThrough() { - var processor = new RenderNodeProcessor( - new ContainerRenderNode(), useRenderCache: false, outputScale: 1f, maxWorkingScale: 3f); + using var node = new ContainerRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = 3, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); - Assert.That(processor.MaxWorkingScale, Is.EqualTo(3f)); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(3)); } [TestCaseSource(nameof(DegenerateCeilings))] @@ -140,7 +150,8 @@ public void ImmediateCanvas_FinitePositiveCeiling_PassesThrough() public void BrushConstructor_DegenerateCeiling_StoredAsPositiveInfinity(float maxWorkingScale) { var ctor = new BrushConstructor( - default, brush: null, BlendMode.SrcOver, scale: 1f, maxWorkingScale: maxWorkingScale); + default, brush: null, BlendMode.SrcOver, RenderIntent.Preview, + drawableBrushMaterializer: null, scale: 1f, maxWorkingScale: maxWorkingScale); Assert.That(ctor.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); } @@ -148,7 +159,9 @@ public void BrushConstructor_DegenerateCeiling_StoredAsPositiveInfinity(float ma [Test] public void BrushConstructor_FinitePositiveCeiling_PassesThrough() { - var ctor = new BrushConstructor(default, brush: null, BlendMode.SrcOver, scale: 1f, maxWorkingScale: 3f); + var ctor = new BrushConstructor( + default, brush: null, BlendMode.SrcOver, RenderIntent.Preview, + drawableBrushMaterializer: null, scale: 1f, maxWorkingScale: 3f); Assert.That(ctor.MaxWorkingScale, Is.EqualTo(3f)); } @@ -158,9 +171,19 @@ public void CustomFilterEffectContext_DegenerateCeiling_StoredAsPositiveInfinity { using var targets = new EffectTargets(); var context = new CustomFilterEffectContext( - targets, outputScale: 1f, workingScale: 1f, maxWorkingScale: maxWorkingScale); + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); - Assert.That(context.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + Assert.Multiple(() => + { + Assert.That(context.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + Assert.That(context.Intent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That(context.Purpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + }); } // SanitizeCeiling also logs a warning on substitution, but only the stored value is pinned here; @@ -171,7 +194,13 @@ public void FilterEffectActivator_DegenerateCeiling_StoredAsPositiveInfinity(flo using var targets = new EffectTargets(); using var builder = new SKImageFilterBuilder(); using var activator = new FilterEffectActivator( - targets, builder, outputScale: 1f, workingScale: 1f, maxWorkingScale: maxWorkingScale); + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: maxWorkingScale); Assert.That(activator.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); } @@ -182,8 +211,15 @@ public void FilterEffectActivator_FinitePositiveCeiling_PassesThrough() using var targets = new EffectTargets(); using var builder = new SKImageFilterBuilder(); using var activator = new FilterEffectActivator( - targets, builder, outputScale: 1f, workingScale: 1f, maxWorkingScale: 3f); + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: 3f); Assert.That(activator.MaxWorkingScale, Is.EqualTo(3f)); } + } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs index fbea249457..1ef191e20f 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs @@ -1,243 +1,339 @@ using Beutl.Graphics; -using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; using Beutl.Graphics.Rendering.Cache; using Beutl.Media; -using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; -// Node cache scale tests: cache rasterizes at the renderer's density, replays tiles as At(density). [NonParallelizable] [TestFixture] public class NodeCacheScaleTests { + private static readonly Rect s_bounds = new(0, 0, 100, 100); + private static EllipseRenderNode CacheableEllipse() { - var node = new EllipseRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null); - node.Cache.ReportRenderCount(RenderNodeCache.Count); + var node = new EllipseRenderNode(s_bounds, Brushes.Resource.White, null); + WarmForCapture(node); return node; } - // A leaf node emitting a concrete At(density) supply. - private sealed class ConcreteSourceNode(float density) : RenderNode - { - public override RenderNodeOperation[] Process(RenderNodeContext context) - => [RenderNodeOperation.CreateLambda( - new Rect(0, 0, 100, 100), - canvas => canvas.DrawRectangle(new Rect(0, 0, 100, 100), Brushes.Resource.White, null), - hitTest: _ => false, - effectiveScale: EffectiveScale.At(density))]; - } - - private static float PullSingleDensity(RenderNode node, bool useRenderCache, float outputScale) + private static void WarmForCapture(RenderNode node) { - var processor = new RenderNodeProcessor(node, useRenderCache, outputScale, maxWorkingScale: 8f); - RenderNodeOperation[] ops = processor.PullToRoot(); - try - { - Assert.That(ops, Is.Not.Empty); - return ops[0].EffectiveScale.Value; - } - finally + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) { - foreach (RenderNodeOperation op in ops) op.Dispose(); + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); } } [TestCase(0.5f)] [TestCase(1.0f)] - public void CreateDefaultCache_RecordsCreationDensity_AndReplayReportsIt(float outputScale) + public void FrameCache_RecordsResolvedDensity_WhileMetadataRemainsCacheIndependent(float outputScale) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - EllipseRenderNode node = CacheableEllipse(); - try - { - RenderNodeCacheHelper.MakeCache( - node, RenderCacheOptions.Default, outputScale, maxWorkingScale: 2f * outputScale); + using EllipseRenderNode node = CacheableEllipse(); + using var renderer = CreateFrameRenderer( + node, + outputScale, + maxWorkingScale: 2f * outputScale); - Assert.That(node.Cache.IsCached, Is.True); - Assert.That(node.Cache.Density, Is.EqualTo(outputScale)); + using (renderer.Rasterize()) + { + } + RenderNodeMeasurement measurement = renderer.Measure(); - var processor = new RenderNodeProcessor( - node, useRenderCache: true, outputScale, maxWorkingScale: 2f * outputScale); - RenderNodeOperation[] ops = processor.PullToRoot(); - try - { - Assert.That(ops, Is.Not.Empty); - foreach (RenderNodeOperation op in ops) - { - Assert.That(op.EffectiveScale.IsUnbounded, Is.False, - "a cached tile is a concrete bitmap and must not replay as Unbounded"); - Assert.That(op.EffectiveScale.Value, Is.EqualTo(outputScale)); - } - } - finally - { - foreach (RenderNodeOperation op in ops) op.Dispose(); - } - } - finally - { - RenderNodeCacheHelper.ClearCache(node); - node.Dispose(); - } + Assert.Multiple(() => + { + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.IdentityDensity, Is.EqualTo(outputScale)); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.True, + "metadata must retain the original graph instead of substituting a pixel cache"); }); } - // A subtree whose supply density exceeds outputScale must not be cached (would collapse working scale). [Test] - public void CreateDefaultCache_RefusesToCache_WhenSupplyDensityExceedsOutputScale() + public void HighDensitySource_IsCachedAtItsResolvedSupplyDensity() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + using var node = new ConcreteSourceNode(); + WarmForCapture(node); + using var renderer = CreateFrameRenderer(node, outputScale: 1f, maxWorkingScale: 8f); + + using (renderer.Rasterize()) + using (renderer.Rasterize()) { - var node = new ConcreteSourceNode(4f); - node.Cache.ReportRenderCount(RenderNodeCache.Count); - try - { - RenderNodeCacheHelper.CreateDefaultCache( - node, RenderCacheOptions.Default, outputScale: 1f, maxWorkingScale: 8f); + } - Assert.That(node.Cache.IsCached, Is.False, - "a subtree whose supply density exceeds outputScale must not be cached"); - } - finally - { - RenderNodeCacheHelper.ClearCache(node); - node.Dispose(); - } + Assert.Multiple(() => + { + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.IdentityDensity, Is.EqualTo(4f)); + Assert.That(node.ExecuteCount, Is.EqualTo(1), "the warm frame must use the cached producer output"); + Assert.That(renderer.Measure().EffectiveScale.Value, Is.EqualTo(4f)); }); } - // Caching must be behaviour-transparent: working scale must match cached or uncached. [Test] - public void HighDensitySubtree_CacheReplay_MatchesUncachedWorkingScale() + public void FrameCache_TargetSizeMatchesResolvedDensity() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + using EllipseRenderNode node = CacheableEllipse(); + using var renderer = CreateFrameRenderer(node, outputScale: 0.5f, maxWorkingScale: 1f); + + using (renderer.Rasterize()) { - var node = new ConcreteSourceNode(4f); - node.Cache.ReportRenderCount(RenderNodeCache.Count); - try - { - float uncached = PullSingleDensity(node, useRenderCache: false, outputScale: 1f); - RenderNodeCacheHelper.MakeCache(node, RenderCacheOptions.Default, outputScale: 1f, maxWorkingScale: 8f); - float cached = PullSingleDensity(node, useRenderCache: true, outputScale: 1f); + } - Assert.That(uncached, Is.EqualTo(4f), "the uncached supply density must be the source's At(4)"); - Assert.That(cached, Is.EqualTo(uncached), - "enabling the cache changed the resolved working scale"); - } - finally + Assert.That(node.Cache.IsCached, Is.True); + foreach ((RenderTarget target, Rect bounds) in node.Cache.UseCache()) + { + using (target) { - RenderNodeCacheHelper.ClearCache(node); - node.Dispose(); + PixelRect expectedDeviceBounds = RenderScaleUtilities.AddRasterApron( + PixelRect.FromRect(bounds, 0.5f)); + Assert.That(target.Width, Is.EqualTo(expectedDeviceBounds.Width)); + Assert.That(target.Height, Is.EqualTo(expectedDeviceBounds.Height)); } - }); + } } [Test] - public void CreateDefaultCache_TileSize_MatchesCreationDensity() + public void CacheRuleBypass_IsRequestPolicyAndDoesNotPoisonLaterEligibleFrames() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + using EllipseRenderNode node = CacheableEllipse(); + using (RenderNodeRenderer excluded = CreateFrameRenderer( + node, + outputScale: 1f, + maxWorkingScale: 1f, + cacheRules: new RenderCacheRules(9_999, 1))) + using (excluded.Rasterize()) { - EllipseRenderNode node = CacheableEllipse(); - try - { - RenderNodeCacheHelper.CreateDefaultCache( - node, RenderCacheOptions.Default, outputScale: 0.5f, maxWorkingScale: 1f); + } - Assert.That(node.Cache.IsCached, Is.True); - foreach ((RenderTarget rt, Rect bounds) in node.Cache.UseCache()) - { - using (rt) - { - // 100x100 logical at density 0.5 = 50x50 px tile. - Assert.That(rt.Width, Is.EqualTo((int)Math.Ceiling(bounds.Width * 0.5f))); - Assert.That(rt.Height, Is.EqualTo((int)Math.Ceiling(bounds.Height * 0.5f))); - } - } - } - finally - { - RenderNodeCacheHelper.ClearCache(node); - node.Dispose(); - } - }); + Assert.That(node.Cache.IsCached, Is.False); + + using (RenderNodeRenderer eligible = CreateFrameRenderer( + node, + outputScale: 1f, + maxWorkingScale: 1f, + cacheRules: RenderCacheRules.Default)) + using (eligible.Rasterize()) + { + } + + Assert.That(node.Cache.IsCached, Is.True); } - // Cache rejection is memoized; clears when the node changes. CPU-only. [Test] - public void RejectedCache_IsMemoized_AndClearsWhenNodeChanges() + public void ApronedDirectReplayCache_ColdAndWarmUsePlannedClampedDensity() { - var node = new ConcreteSourceNode(4f); - try - { - RenderNodeCache cache = node.Cache; - cache.ReportRenderCount(RenderNodeCache.Count); - Assert.That(cache.CanCache(), Is.True); - Assert.That(cache.IsCacheRejected, Is.False); - - cache.RejectCache(); - Assert.That(cache.IsCacheRejected, Is.True, - "a refused subtree must stay marked so MakeCache does not re-attempt it every frame"); - - node.HasChanges = true; - cache.IncrementRenderCount(); - Assert.That(cache.IsCacheRejected, Is.False, "a node change must clear the rejection"); - Assert.That(cache.CanCache(), Is.False, "a node change must reset the render count"); - } - finally + var bounds = new Rect(0, 0, RenderScaleUtilities.MaxBufferDimension, 1); + float expectedDensity = + RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, 1); + using var node = new RasterApronSourceNode(bounds); + WarmForCapture(node); + using var renderer = CreateFrameRenderer( + node, + outputScale: 1, + maxWorkingScale: 1, + targetDomain: bounds); + + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); + + Assert.Multiple(() => { - node.Dispose(); - } + Assert.That(expectedDensity, Is.LessThan(1)); + Assert.That(cold.IsEmpty, Is.False); + Assert.That(warm.IsEmpty, Is.False); + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.IdentityDensity, Is.EqualTo(expectedDensity)); + Assert.That(node.ExecuteCount, Is.EqualTo(1)); + }); } - // Invalidate also clears the rejection. [Test] - public void RejectedCache_ClearsOnInvalidate() + public void BoundedValueReplayCache_PartialRoiUsesCompleteApronedDensity() { - var node = new ConcreteSourceNode(4f); - try + var bounds = new Rect(0, 0, RenderScaleUtilities.MaxBufferDimension, 1); + var requestedRegion = new Rect( + 0, + 0, + RenderScaleUtilities.MaxBufferDimension / 2, + 1); + float expectedDensity = + RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, 1); + using var node = new BoundedValueReplayNode(bounds); + WarmForCapture(node); + using var renderer = CreateFrameRenderer( + node, + outputScale: 1, + maxWorkingScale: 1, + targetDomain: bounds, + requestedRegion: requestedRegion); + + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); + + Assert.Multiple(() => { - node.Cache.RejectCache(); - Assert.That(node.Cache.IsCacheRejected, Is.True); + Assert.That(expectedDensity, Is.LessThan(1)); + Assert.That(cold.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(warm.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.IdentityDensity, Is.EqualTo(expectedDensity)); + Assert.That(node.ExecuteCount, Is.EqualTo(1)); + }); + } + + private static RenderNodeRenderer CreateFrameRenderer( + RenderNode node, + float outputScale, + float maxWorkingScale, + RenderCacheRules? cacheRules = null, + Rect? targetDomain = null, + Rect? requestedRegion = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain ?? s_bounds, + RequestedRegion = requestedRegion, + OutputScale = outputScale, + MaxWorkingScale = maxWorkingScale, + CacheOptions = new RenderCacheOptions( + true, + cacheRules ?? RenderCacheRules.Default), + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private sealed class ConcreteSourceNode : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + private static readonly RenderResourceSlot s_probeSlot = new(); + private static readonly OpaqueRenderDefinition s_definition = + OpaqueRenderDefinition.Create( + static (session, bounds) => + session.UseResource(s_probeSlot, probe => + { + probe.Record(); + session.UseResource(s_fillSlot, fill => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(canvas => canvas.DrawRectangle(bounds, fill, null)); + session.Publish(output); + }); + }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 4f), + resources: [s_fillSlot, s_probeSlot]); + + private readonly ExecutionProbe _probe = new(); - node.Cache.Invalidate(); - Assert.That(node.Cache.IsCacheRejected, Is.False); + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) + { + Brush.Resource fill = Brushes.Resource.White; + RenderResource fillResource = context.Borrow(fill); + RenderResource probeResource = context.Borrow(_probe); + context.Publish(context.OpaqueSource(s_definition.Call( + s_bounds, + [s_fillSlot.Bind(fillResource), s_probeSlot.Bind(probeResource)]))); } - finally + } + + private sealed class RasterApronSourceNode(Rect bounds) : RenderNode + { + private readonly ExecutionProbe _probe = new(); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) { - node.Dispose(); + OpaqueRenderDescription description = OpaqueRenderDescription.CreateEngineSource( + execute: session => + { + _probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(static canvas => canvas.Clear()); + session.Publish(output); + }, + directReplay: static session => session.Canvas.Clear(), + bounds: OpaqueRenderBoundsContract.Source(bounds), + hitTest: RenderHitTestContract.OutputBounds, + scale: RenderScaleContract.Vector, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive); + context.Publish(context.OpaqueSource(description)); } } - // MakeCache must mark the high-density subtree rejected so subsequent frames skip it. - [Test] - public void MakeCache_HighDensitySubtree_MarksCacheRejected() + private sealed class BoundedValueReplayNode(Rect bounds) : RenderNode { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + private static readonly RenderResourceSlot s_probeSlot = new(); + private readonly ExecutionProbe _probe = new(); + private readonly OpaqueRenderDefinition _sourceDefinition = + OpaqueRenderDefinition.Create( + static (session, currentBounds) => + session.UseResource(s_probeSlot, probe => + { + probe.Record(); + using OpaqueRenderOutput output = session.CreateOutput(currentBounds); + output.Canvas.Use(static canvas => canvas.Clear()); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Custom(static _ => 1), + resources: [s_probeSlot]); + + public int ExecuteCount => _probe.Count; + + public override void Process(RenderNodeContext context) { - var node = new ConcreteSourceNode(4f); - node.Cache.ReportRenderCount(RenderNodeCache.Count); - try - { - RenderNodeCacheHelper.MakeCache(node, RenderCacheOptions.Default, outputScale: 1f, maxWorkingScale: 8f); + RenderResource probeResource = context.Borrow(_probe); + RenderFragmentHandle source = context.OpaqueSource(_sourceDefinition.Call( + bounds, + [s_probeSlot.Bind(probeResource)])); + TargetScopeDescription replayDescription = TargetScopeDescription.CreateValueReplayMap( + static session => session.Canvas.Use(_ => session.ReplayInput()), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + RenderDeviceGridSensitivity.Insensitive, + RenderDeviceGridMapping.Preserved); + context.Publish(context.TargetScope(source, replayDescription)); + } + } - Assert.That(node.Cache.IsCached, Is.False); - Assert.That(node.Cache.IsCacheRejected, Is.True, - "the high-density rejection must be memoized so MakeCache stops re-pulling the subtree every frame"); - } - finally + private sealed class CpuTargetFactory : IRenderTargetFactory + { + private static readonly SKColorSpace s_colorSpace = SKColorSpace.CreateSrgbLinear(); + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + + private sealed class CpuRenderTarget : RenderTarget + { + public CpuRenderTarget(PixelSize size) + : base( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + s_colorSpace)) + ?? throw new InvalidOperationException("Could not create a CPU cache-scale test target."), + size.Width, + size.Height) { - RenderNodeCacheHelper.ClearCache(node); - node.Dispose(); } - }); + } } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/OffFrameFilterEffectExecutionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/OffFrameFilterEffectExecutionTests.cs new file mode 100644 index 0000000000..4e23e1dd03 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/OffFrameFilterEffectExecutionTests.cs @@ -0,0 +1,246 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class OffFrameFilterEffectExecutionTests +{ + private static readonly Rect s_frame = new(0, 0, 320, 180); + + [Test] + public void FullyOffFrameBlur_IsEquivalentToOmittingTheElement() + { + using SceneGraph control = CreateScene(visibleCount: 1, includeOffFrameEffect: false); + using SceneGraph actual = CreateScene(visibleCount: 1, includeOffFrameEffect: true); + + byte[] expected = Render(control.Root); + byte[] rendered = Render(actual.Root); + Assert.Multiple(() => + { + Assert.That(expected, Has.Some.Not.Zero, + "The single visible control drawable must contribute pixels before off-frame parity is compared."); + Assert.That(rendered, Is.EqualTo(expected)); + }); + } + + [Test] + public void FullyOffFrameBlur_DoesNotSuppressFiveVisibleElements() + { + using SceneGraph control = CreateScene(visibleCount: 5, includeOffFrameEffect: false); + using SceneGraph actual = CreateScene(visibleCount: 5, includeOffFrameEffect: true); + + byte[] expected = Render(control.Root); + byte[] rendered = Render(actual.Root); + Assert.Multiple(() => + { + Assert.That(rendered, Has.Some.Not.Zero); + Assert.That(rendered, Is.EqualTo(expected)); + }); + } + + [Test] + public void StraddlingBlur_StillRendersItsVisibleFootprint() + { + const float sigma = 4; + const int sampleX = 3; + using SceneGraph scene = CreateScene(visibleCount: 0, includeOffFrameEffect: true, offFrameX: -78); + + byte[] rendered = Render(scene.Root); + + // The source body occupies x=-78..2. Column 3 is an on-frame tail sample less than + // one sigma from the frame edge and contains no unblurred source coverage. + float actualTail = MaximumAlphaInColumn(rendered, sampleX, yStart: 56, yEnd: 88); + double sampleCenter = sampleX + 0.5; + double expectedTail = GaussianIntervalCoverage( + sourceStart: -78, + sourceEnd: 2, + sampleCenter, + sigma); + Assert.Multiple(() => + { + Assert.That( + actualTail, + Is.GreaterThan(0.05f), + "The visible blur tail inside one sigma of the frame edge was dropped."); + Assert.That( + actualTail, + Is.EqualTo(expectedTail).Within(0.04), + "The clipped on-frame tail must follow the Gaussian interval profile."); + }); + } + + private static SceneGraph CreateScene( + int visibleCount, + bool includeOffFrameEffect, + float offFrameX = -1_500) + { + var drawables = new List(); + for (int index = 0; index < visibleCount; index++) + { + float x = 12 + (index * 58); + float y = 18 + ((index % 2) * 66); + var visible = new RectShape + { + Width = { CurrentValue = 44 }, + Height = { CurrentValue = 52 }, + Fill = + { + CurrentValue = index % 2 == 0 + ? Brushes.White + : Brushes.OrangeRed, + }, + Transform = { CurrentValue = new TranslateTransform(x, y) }, + }; + drawables.Add(visible.ToResource(CompositionContext.Default)); + } + + if (includeOffFrameEffect) + { + var offFrame = new RectShape + { + Width = { CurrentValue = 80 }, + Height = { CurrentValue = 64 }, + Fill = { CurrentValue = Brushes.CornflowerBlue }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(offFrameX, 40) }, + FilterEffect = + { + CurrentValue = new Blur + { + Sigma = { CurrentValue = new Size(4, 4) }, + }, + }, + }; + drawables.Add(offFrame.ToResource(CompositionContext.Default)); + } + + if (drawables.Count == 0) + throw new InvalidOperationException("The scene fixture must contain at least one drawable."); + + var root = new DrawableRenderNode(drawables[0]); + using (var context = new GraphicsContext2D(root, s_frame.Size)) + { + context.Clear(); + foreach (Drawable.Resource drawable in drawables) + { + context.DrawDrawable(drawable); + } + } + + return new SceneGraph(root, drawables); + } + + private static byte[] Render(RenderNode root) + { + using var target = new CpuRenderTarget((int)s_frame.Width, (int)s_frame.Height); + using var destination = new ImmediateCanvas(target, logicalSize: s_frame.Size); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_frame, + RequestedRegion = s_frame, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + renderer.Render(destination); + using Bitmap result = target.Snapshot(); + return result.GetPixelSpan().ToArray(); + } + + private static float MaximumAlphaInColumn(byte[] pixels, int x, int yStart, int yEnd) + { + float maximum = 0; + for (int y = yStart; y < yEnd; y++) + { + maximum = Math.Max(maximum, ReadAlpha(pixels, x, y)); + } + + return maximum; + } + + private static double GaussianIntervalCoverage( + double sourceStart, + double sourceEnd, + double sampleCenter, + double sigma) + { + static double NormalCdf(double value) + => 0.5 * (1 + ErrorFunction(value / Math.Sqrt(2))); + + return NormalCdf((sourceEnd - sampleCenter) / sigma) + - NormalCdf((sourceStart - sampleCenter) / sigma); + } + + private static double ErrorFunction(double value) + { + const double p = 0.3275911; + const double a1 = 0.254829592; + const double a2 = -0.284496736; + const double a3 = 1.421413741; + const double a4 = -1.453152027; + const double a5 = 1.061405429; + + double sign = Math.Sign(value); + double x = Math.Abs(value); + double t = 1 / (1 + p * x); + double approximation = 1 + - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) + * t + * Math.Exp(-x * x); + return sign * approximation; + } + + private static float ReadAlpha(byte[] pixels, int x, int y) + { + int offset = ((y * (int)s_frame.Width) + x) * 8; + ushort bits = BitConverter.ToUInt16(pixels, offset + 6); + return (float)BitConverter.UInt16BitsToHalf(bits); + } + + private sealed class SceneGraph( + DrawableRenderNode root, + IReadOnlyList drawables) : IDisposable + { + public DrawableRenderNode Root { get; } = root; + + public void Dispose() + { + Root.Dispose(); + foreach (Drawable.Resource drawable in drawables) + { + drawable.Dispose(); + } + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs index dee9599bd7..5d0270514f 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ParticleRenderNodeScaleTests.cs @@ -32,11 +32,32 @@ private static ParticleEmitter.Resource BuildResourceWithLargeParticleDrawable() var emitter = new ParticleEmitter(); emitter.ParticleDrawable.CurrentValue = particle; + emitter.MaxParticles.CurrentValue = 1; + emitter.Speed.CurrentValue = 0; + emitter.Gravity.CurrentValue = 0; var ctx = new CompositionContext(TimeSpan.FromSeconds(1.0)); return (ParticleEmitter.Resource)emitter.ToResource(ctx); } + private static ParticleEmitter.Resource BuildResourceWithGroupedParticleDrawable() + { + var particle = new RectShape(); + particle.Width.CurrentValue = 20; + particle.Height.CurrentValue = 12; + particle.Fill.CurrentValue = Brushes.White; + var group = new DrawableGroup(); + group.Children.Add(particle); + + var emitter = new ParticleEmitter(); + emitter.ParticleDrawable.CurrentValue = group; + emitter.MaxParticles.CurrentValue = 1; + emitter.Speed.CurrentValue = 0; + emitter.Gravity.CurrentValue = 0; + return (ParticleEmitter.Resource)emitter.ToResource( + new CompositionContext(TimeSpan.FromSeconds(1))); + } + [Test] public void Resource_AfterOneSecond_HasAliveParticles() { @@ -48,61 +69,63 @@ public void Resource_AfterOneSecond_HasAliveParticles() // Particle composite reports At(w) concretely, even at w == 1. [TestCase(1.0f)] [TestCase(2.0f)] - public void Process_EmitsOpTaggedAtOutputScale_ConcreteNotUnbounded(float outputScale) + public void MaterializedOutput_IsTaggedAtOutputScale_ConcreteNotUnbounded(float outputScale) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - ParticleEmitter.Resource resource = BuildResourceWithAliveParticles(); - Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1), - "precondition: at least one alive particle is required for Process to emit an op"); - - using var node = new ParticleRenderNode(resource); - var context = new RenderNodeContext([], outputScale: outputScale); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty, "ParticleRenderNode emitted no op despite alive particles"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(outputScale).Within(1e-4), - $"the particle composite was not tagged At(w) with w == s_out ({outputScale})"); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, - "the particle composite was over-reported as re-rasterizable vector (Unbounded)"); - - DisposeAll(ops); - }); + using ParticleEmitter.Resource resource = BuildResourceWithAliveParticles(); + Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1), + "precondition: at least one alive particle is required for recording to emit a fragment"); + + using var pipeline = ScaleRecordingTestHelper.Pipeline( + new ParticleRenderNode(resource), + ScaleRecordingTestHelper.Materialize()); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure(pipeline, outputScale); + + Assert.That(measurement.HasFragments, Is.True, + "ParticleRenderNode emitted no fragment despite alive particles"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(outputScale).Within(1e-4), + $"the materialized particle composite was not tagged At(w) with w == s_out ({outputScale})"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "a materialized particle composite was reported as re-rasterizable vector (Unbounded)"); } [Test] - public void Process_WhenParticleDrawableBufferClamps_TagsActualDensity() + public void MaterializedOutput_WhenParticleDrawableBufferClamps_TagsActualDensity() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - using ParticleEmitter.Resource resource = BuildResourceWithLargeParticleDrawable(); - Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1), - "precondition: at least one alive particle is required for Process to emit an op"); - - using var node = new ParticleRenderNode(resource); - var context = new RenderNodeContext([], outputScale: 8f); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty, "ParticleRenderNode emitted no op despite alive particles"); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False); - Assert.That(ops[0].EffectiveScale.Value, Is.LessThan(8f), - "the particle op must report the clamped buffer density, not the nominal output scale"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo( - RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 4000, 10), 8f)).Within(1e-3f)); - - DisposeAll(ops); - }); + using ParticleEmitter.Resource resource = BuildResourceWithLargeParticleDrawable(); + Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1), + "precondition: at least one alive particle is required for recording to emit a fragment"); + + using var pipeline = ScaleRecordingTestHelper.Pipeline( + new ParticleRenderNode(resource), + ScaleRecordingTestHelper.Materialize()); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure(pipeline, outputScale: 8); + + Assert.That(measurement.HasFragments, Is.True, + "ParticleRenderNode emitted no fragment despite alive particles"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(measurement.EffectiveScale.Value, Is.LessThan(8), + "the materialized particle output must report the clamped buffer density, not the nominal output scale"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo( + RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 4000, 10), 8)).Within(1e-3)); } - private static void DisposeAll(RenderNodeOperation[] ops) + [Test] + public void GroupedParticleDrawable_LocalizesItsOwningDomainBeforeMeasurement() { - foreach (RenderNodeOperation op in ops) + using ParticleEmitter.Resource resource = BuildResourceWithGroupedParticleDrawable(); + Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + new ParticleRenderNode(resource), + ScaleRecordingTestHelper.Materialize()); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure(pipeline, outputScale: 1); + + Assert.Multiple(() => { - op.Dispose(); - } + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.OutputBounds.IsInvalid, Is.False); + Assert.That(measurement.OutputBounds.Width, Is.GreaterThan(0)); + Assert.That(measurement.OutputBounds.Height, Is.GreaterThan(0)); + }); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs new file mode 100644 index 0000000000..cbf57ded2d --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs @@ -0,0 +1,301 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class BackdropOrderingTests +{ + private static readonly Rect s_domain = new(0, 0, 160, 90); + private static readonly Rect s_drawBounds = new(12, 8, 80, 48); + + [TestCase(BackdropScope.Root)] + [TestCase(BackdropScope.Blend)] + [TestCase(BackdropScope.Transform)] + [TestCase(BackdropScope.Filter)] + public void SnapshotClearDraw_PreservesOneCaptureAndItsTargetTokenOrder(BackdropScope scope) + { + using ContainerRenderNode root = CreateTree(scope); + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_domain, + owner: owner); + var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + + IReadOnlyDictionary references = graph.Fragments + .ToDictionary( + static fragment => fragment.Id, + static fragment => (RenderFragmentReference)fragment.Payload!); + TargetDependencyStep[] steps = compiled.TargetDependencies.Steps.ToArray(); + + RenderFragmentReference[] captures = references.Values + .Where(static reference => reference.Kind == RenderFragmentKind.BuiltInBackdropCapture) + .ToArray(); + Assert.That(captures, Has.Length.EqualTo(1), + "SnapshotBackdrop must create one request-local target capture."); + + RenderFragmentReference capture = captures[0]; + TargetDependencyStep captureStep = steps.Single(step => + references[step.FragmentId].Kind == RenderFragmentKind.BuiltInBackdropCapture); + TargetDependencyStep[] commandSteps = steps.Where(step => + references[step.FragmentId].Kind == RenderFragmentKind.TargetCommand).ToArray(); + TargetDependencyStep drawStep = commandSteps.Single(step => + step.TargetReadValueId == captureStep.TargetReadValueId); + TargetDependencyStep clearStep = commandSteps.Single(step => + step.TargetReadValueId is null); + RenderFragmentReference draw = references[drawStep.FragmentId]; + + int captureIndex = Array.IndexOf(steps, captureStep); + int clearIndex = Array.IndexOf(steps, clearStep); + int drawIndex = Array.IndexOf(steps, drawStep); + int captureUses = draw.Inputs.Count(input => ReferenceEquals(input, capture)); + int implicitContributions = references.Values.Count(reference => + reference.Kind == RenderFragmentKind.ContributeValues + && reference.Inputs.Any(input => ReferenceEquals(input, capture))); + + Assert.Multiple(() => + { + Assert.That(captureStep.TargetReadValueId, Is.Not.Null, + "The capture must name the request-owned value read by DrawBackdrop."); + Assert.That(capture.ContributesValuesToTarget, Is.False, + "A target capture anchors pixels but must not redraw them implicitly."); + Assert.That(implicitContributions, Is.Zero); + Assert.That(captureIndex, Is.LessThan(clearIndex)); + Assert.That(clearIndex, Is.LessThan(drawIndex)); + Assert.That(captureUses, Is.EqualTo(1), + "DrawBackdrop must consume exactly the value produced by this capture."); + Assert.That(drawStep.TargetReadValueId, Is.EqualTo(captureStep.TargetReadValueId)); + Assert.That(clearStep.ScopeId, Is.EqualTo(captureStep.ScopeId)); + }); + + if (scope == BackdropScope.Root) + { + Assert.Multiple(() => + { + Assert.That(drawStep.ScopeId, Is.EqualTo(captureStep.ScopeId)); + Assert.That(clearStep.InputToken, Is.EqualTo(captureStep.OutputToken)); + Assert.That(drawStep.InputToken, Is.EqualTo(clearStep.OutputToken)); + }); + } + else + { + Assert.That(drawStep.ScopeId, Is.Not.EqualTo(captureStep.ScopeId), + "The nested draw must consume the capture from inside its own authored scope."); + + if (scope == BackdropScope.Filter) + { + RenderFragmentReference isolation = references.Values + .Single(static reference => reference.Kind == RenderFragmentKind.Layer); + var payload = (LayerRenderFragmentPayload)isolation.Payload!; + Assert.Multiple(() => + { + Assert.That(payload.Domain, Is.EqualTo(s_drawBounds), + "A finite target write must use its affected region rather than a symbolic capture hint."); + Assert.That(isolation.BoundsRequirement, + Is.EqualTo(RenderFragmentBoundsRequirement.Finite)); + Assert.That(references.Values.Any(static reference => + reference.Kind == RenderFragmentKind.Shader + && reference.Payload is ShaderRenderFragmentPayload + { + Description.Kind: ShaderDescriptionKind.WholeSource, + }), + Is.True, + "The filter must remain present after target-dependent input isolation."); + }); + } + } + } + + [Test] + public void SnapshotDraw_WithoutCurrentCapture_UsesPersistedFallbackPath() + { + using var snapshot = new SnapshotBackdropRenderNode(); + var fallback = new Bitmap((int)s_domain.Width, (int)s_domain.Height); + fallback.GetPixelSpan().Fill(byte.MaxValue); + ((IBuiltInBackdropCaptureSink)snapshot).CommitBackdropCapture(fallback, density: 1f); + using var draw = new DrawBackdropRenderNode(snapshot, s_drawBounds); + using var renderer = new RenderNodeRenderer( + draw, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The persisted fallback produced no bitmap."); + int sampleX = (int)(s_drawBounds.Center.X - rasterization.Bounds.X); + int sampleY = (int)(s_drawBounds.Center.Y - rasterization.Bounds.Y); + var sample = bitmap.SKBitmap.GetPixel(sampleX, sampleY); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds.Contains(s_drawBounds), Is.True, + "The raw fallback may conservatively retain the full target domain."); + Assert.That(sample.Red, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Green, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Blue, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Alpha, Is.EqualTo(byte.MaxValue), + "A point inside the draw bounds must contain the committed opaque-white fallback."); + }); + } + + [Test] + public void SnapshotSubclass_UsesTheSameRequestCapture() + { + using var root = new ContainerRenderNode(); + var snapshot = new DerivedSnapshotBackdropRenderNode(); + root.AddChild(snapshot); + root.AddChild(new DrawBackdropRenderNode(snapshot, s_drawBounds)); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_domain)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + RenderFragmentReference[] references = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(references.Count(static item => item.Kind == RenderFragmentKind.BuiltInBackdropCapture), + Is.EqualTo(1)); + Assert.That(references.Count(static item => item.Kind == RenderFragmentKind.TargetCommand), + Is.EqualTo(1)); + Assert.That(references, Has.None.Matches( + static item => item.Kind == RenderFragmentKind.RawTargetCommand)); + }); + } + + [Test] + public void DisposedSnapshot_RejectsPersistedFallbackWithoutTakingOwnership() + { + var snapshot = new SnapshotBackdropRenderNode(); + snapshot.Dispose(); + using var fallback = new Bitmap((int)s_domain.Width, (int)s_domain.Height); + + bool accepted = ((IBuiltInBackdropCaptureSink)snapshot) + .TryCommitBackdropCapture(fallback, density: 1f); + + Assert.Multiple(() => + { + Assert.That(accepted, Is.False); + Assert.That(fallback.IsDisposed, Is.False); + }); + } + + [Test] + public void TemporaryBackdropSubtree_CanBeDisposedAfterRecording() + { + using var root = new TemporaryBackdropSubtreeNode(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The recorded temporary backdrop produced no bitmap."); + Assert.That(rasterization.Bounds.Contains(s_drawBounds.Center), Is.True, + "The temporary backdrop rasterization must contain the requested draw sample."); + int sampleX = (int)(s_drawBounds.Center.X - rasterization.Bounds.X); + int sampleY = (int)(s_drawBounds.Center.Y - rasterization.Bounds.Y); + var sample = bitmap.SKBitmap.GetPixel(sampleX, sampleY); + + Assert.Multiple(() => + { + Assert.That(sample.Red, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Green, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Blue, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Alpha, Is.EqualTo(byte.MaxValue), + "The draw must retain the opaque-white backdrop captured before the contrasting clear."); + }); + } + + private static ContainerRenderNode CreateTree(BackdropScope scope) + { + var root = new ContainerRenderNode(); + var snapshot = new SnapshotBackdropRenderNode(); + var clear = new ClearRenderNode(Colors.Transparent); + var draw = new DrawBackdropRenderNode(snapshot, s_drawBounds); + + root.AddChild(snapshot); + root.AddChild(clear); + root.AddChild(WrapDraw(draw, scope)); + return root; + } + + private static RenderNode WrapDraw(DrawBackdropRenderNode draw, BackdropScope scope) + { + ContainerRenderNode? wrapper = scope switch + { + BackdropScope.Root => null, + BackdropScope.Blend => new BlendModeRenderNode(BlendMode.Multiply), + BackdropScope.Transform => new TransformRenderNode( + Matrix.CreateTranslation(7, 11), + TransformOperator.Prepend), + BackdropScope.Filter => CreateFilterScope(), + _ => throw new ArgumentOutOfRangeException(nameof(scope), scope, null), + }; + + if (wrapper is null) + return draw; + + wrapper.AddChild(draw); + return wrapper; + } + + private static FilterEffectRenderNode CreateFilterScope() + { + var effect = new MosaicEffect(); + effect.TileSize.CurrentValue = new Size(8, 8); + return new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + } + + private sealed class TemporaryBackdropSubtreeNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + using var subtree = new ContainerRenderNode(); + var snapshot = new SnapshotBackdropRenderNode(); + subtree.AddChild(new ClearRenderNode(Colors.White)); + subtree.AddChild(snapshot); + subtree.AddChild(new ClearRenderNode(Colors.Transparent)); + subtree.AddChild(new DrawBackdropRenderNode(snapshot, s_drawBounds)); + + IReadOnlyList outputs = context.RecordSubtree(subtree); + context.Publish(context.Layer(outputs, s_domain)); + } + } + + private sealed class DerivedSnapshotBackdropRenderNode : SnapshotBackdropRenderNode + { + } + + public enum BackdropScope + { + Root, + Blend, + Transform, + Filter, + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ExecutionIslandPlannerCacheBoundaryTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ExecutionIslandPlannerCacheBoundaryTests.cs new file mode 100644 index 0000000000..d1695f04ac --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ExecutionIslandPlannerCacheBoundaryTests.cs @@ -0,0 +1,324 @@ +using System.Collections.Immutable; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class ExecutionIslandPlannerCacheBoundaryTests +{ + private static readonly Rect s_bounds = new(0, 0, 32, 24); + + [TestCase((int)RenderCacheBypassReason.CacheDisabled)] + [TestCase((int)RenderCacheBypassReason.CapturePublicationDisabled)] + [TestCase((int)RenderCacheBypassReason.OutsideCacheRules)] + [TestCase((int)RenderCacheBypassReason.UnstableBoundaryPlan)] + public void BypassedCandidate_DoesNotSplitMaximalCompatibleRun( + int bypassReasonValue) + { + var bypassReason = (RenderCacheBypassReason)bypassReasonValue; + GraphFixture fixture = CreateShaderGraph(includeGeometryPrefix: false); + RenderCacheResolution resolution = CreateResolution( + fixture, + RenderCacheResolutionKind.Bypass, + bypassReason); + + ExecutionIslandPlan plan = Plan(fixture, resolution, FusionMode.Enabled); + + Assert.Multiple(() => + { + Assert.That(plan.ShaderRuns, Has.Exactly(1).Items); + Assert.That(plan.ShaderRuns.Single().Stages.Select(static stage => stage.FragmentId), + Is.EqualTo(new[] { fixture.CachedProducer.Id, fixture.Tail.Id })); + Assert.That(plan.Boundaries, Has.None.Matches(static boundary => + boundary.Reason is ExecutionIslandBoundaryReason.CacheInput + or ExecutionIslandBoundaryReason.CacheCapture)); + }); + } + + [Test] + public void BypassedCandidate_WithFusionDisabledUsesOnlyFusionDisabledSplit() + { + GraphFixture fixture = CreateShaderGraph(includeGeometryPrefix: false); + RenderCacheResolution resolution = CreateResolution( + fixture, + RenderCacheResolutionKind.Bypass, + RenderCacheBypassReason.CacheDisabled); + + ExecutionIslandPlan plan = Plan(fixture, resolution, FusionMode.Disabled); + + Assert.Multiple(() => + { + Assert.That(plan.ShaderRuns.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { 1, 1 })); + Assert.That(plan.Boundaries.Count(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.FusionDisabled), Is.EqualTo(1)); + Assert.That(plan.Boundaries, Has.None.Matches(static boundary => + boundary.Reason is ExecutionIslandBoundaryReason.CacheInput + or ExecutionIslandBoundaryReason.CacheCapture)); + }); + } + + [Test] + public void SelectedMissCapture_SplitsAfterProducerWithExactCacheCaptureReason() + { + GraphFixture fixture = CreateShaderGraph(includeGeometryPrefix: false); + RenderCacheResolution resolution = CreateResolution( + fixture, + RenderCacheResolutionKind.MissCapture); + + ExecutionIslandPlan plan = Plan(fixture, resolution, FusionMode.Enabled); + ExecutionIslandBoundary[] cacheBoundaries = plan.Boundaries + .Where(static boundary => boundary.Reason is ExecutionIslandBoundaryReason.CacheInput + or ExecutionIslandBoundaryReason.CacheCapture) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(plan.ShaderRuns.Select(static run => run.Stages.Length), + Is.EqualTo(new[] { 1, 1 })); + Assert.That(cacheBoundaries, Has.Exactly(1).Items); + Assert.That(cacheBoundaries[0].BeforeFragmentId, Is.EqualTo(fixture.CachedProducer.Id)); + Assert.That(cacheBoundaries[0].AfterFragmentId, Is.Null); + Assert.That(cacheBoundaries[0].Reason, + Is.EqualTo(ExecutionIslandBoundaryReason.CacheCapture)); + }); + } + + [Test] + public void SelectedHit_OmitsReplacedProducerAndPrivateSubtreeFromExecutablePlan() + { + GraphFixture fixture = CreateShaderGraph(includeGeometryPrefix: true); + RenderCacheResolution resolution = CreateResolution( + fixture, + RenderCacheResolutionKind.Hit); + + ExecutionIslandPlan plan = Plan(fixture, resolution, FusionMode.Enabled); + ExecutionIslandBoundary[] cacheBoundaries = plan.Boundaries + .Where(static boundary => boundary.Reason is ExecutionIslandBoundaryReason.CacheInput + or ExecutionIslandBoundaryReason.CacheCapture) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(plan.Islands, Has.Exactly(1).Items); + Assert.That(plan.Islands.Single().Fragments, Is.EqualTo(new[] { fixture.Tail.Id })); + Assert.That(plan.ShaderRuns.Single().Stages.Select(static stage => stage.FragmentId), + Is.EqualTo(new[] { fixture.Tail.Id })); + Assert.That(plan.ShaderRuns.Single().CoverageSource, + Is.EqualTo(ShaderRunCoverageSource.MaterializedInput)); + Assert.That(plan.Islands.SelectMany(static island => island.Fragments), + Has.None.EqualTo(fixture.CachedProducer.Id)); + Assert.That(plan.Islands.SelectMany(static island => island.Fragments), + Has.None.EqualTo(fixture.Prefix.Id)); + Assert.That(plan.Boundaries, Has.None.Matches(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.Geometry)); + Assert.That(cacheBoundaries, Has.Exactly(1).Items); + Assert.That(cacheBoundaries[0].BeforeFragmentId, Is.Null); + Assert.That(cacheBoundaries[0].AfterFragmentId, Is.EqualTo(fixture.CachedProducer.Id)); + Assert.That(cacheBoundaries[0].Reason, + Is.EqualTo(ExecutionIslandBoundaryReason.CacheInput)); + }); + } + + [Test] + public void SelectedHit_DoesNotPruneSubtreeStillPublishedByAnotherRoot() + { + GraphFixture fixture = CreateShaderGraph( + includeGeometryPrefix: true, + publishPrefix: true); + RenderCacheResolution resolution = CreateResolution( + fixture, + RenderCacheResolutionKind.Hit); + + ExecutionIslandPlan plan = Plan(fixture, resolution, FusionMode.Enabled); + + Assert.Multiple(() => + { + Assert.That(plan.Islands.SelectMany(static island => island.Fragments), + Has.Some.EqualTo(fixture.Prefix.Id)); + Assert.That(plan.Islands.SelectMany(static island => island.Fragments), + Has.None.EqualTo(fixture.CachedProducer.Id)); + Assert.That(plan.Boundaries, Has.Some.Matches(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.Geometry)); + Assert.That(plan.Boundaries.Count(static boundary => + boundary.Reason == ExecutionIslandBoundaryReason.CacheInput), Is.EqualTo(1)); + }); + } + + [Test] + public void StructuralIdentity_DistinguishesHitFromMissCaptureAtSameCandidate() + { + GraphFixture fixture = CreateShaderGraph(includeGeometryPrefix: false); + RenderCacheResolution hit = CreateResolution(fixture, RenderCacheResolutionKind.Hit); + RenderCacheResolution miss = CreateResolution(fixture, RenderCacheResolutionKind.MissCapture); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_bounds); + + StructuralPlanIdentity hitIdentity = StructuralPlanIdentity.Create( + options.PlanIdentity, + fixture.Graph, + SkslBackendBudget.Unlimited, + hit); + StructuralPlanIdentity missIdentity = StructuralPlanIdentity.Create( + options.PlanIdentity, + fixture.Graph, + SkslBackendBudget.Unlimited, + miss); + + Assert.That(hitIdentity, Is.Not.EqualTo(missIdentity)); + } + + private static ExecutionIslandPlan Plan( + GraphFixture fixture, + RenderCacheResolution resolution, + FusionMode fusionMode) + => new ExecutionIslandPlanner().Plan( + fixture.Graph, + RenderRequestCompiler.ResolveRoots(fixture.Graph), + resolution, + fusionMode, + SkslBackendBudget.Unlimited); + + private static RenderCacheResolution CreateResolution( + GraphFixture fixture, + RenderCacheResolutionKind kind, + RenderCacheBypassReason bypassReason = RenderCacheBypassReason.None) + { + RenderCacheCandidate candidate = fixture.Graph.CacheCandidates.Single(); + RecordedRenderFragment recorded = fixture.Graph.Fragments.Single(fragment => + fragment.Id == candidate.FragmentId); + var identity = new RenderOutputCacheIdentity( + candidate.CacheKey, + RenderFragmentOutputIdentity.Create(fixture.CachedProducer, fixture.Graph.RequestId), + fixture.CachedProducer.Bounds, + RequiredRegion.Full, + density: 1, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity("planner-test-device", "planner-test-context")); + + RenderCacheDecision decision = kind switch + { + RenderCacheResolutionKind.Bypass => new RenderCacheDecision( + candidate, + kind, + bypassReason, + identity, + null, + null, + null), + RenderCacheResolutionKind.Hit => new RenderCacheDecision( + candidate, + kind, + RenderCacheBypassReason.None, + identity, + new RenderCacheHitSubstitution( + candidate.Id, + recorded.Id, + recorded.Values, + recorded.ProvenanceId, + identity, + new RenderCacheEntry(identity, new object())), + null, + null), + RenderCacheResolutionKind.MissCapture => new RenderCacheDecision( + candidate, + kind, + RenderCacheBypassReason.None, + identity, + null, + new RenderCacheMissCapture( + candidate.Id, + recorded.Id, + recorded.Values, + recorded.ProvenanceId, + identity), + null), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + return new RenderCacheResolution([decision]); + } + + private static GraphFixture CreateShaderGraph( + bool includeGeometryPrefix, + bool publishPrefix = false) + { + var requestId = new RenderRequestId(1); + RenderFragmentReference source = Fragment( + RenderFragmentKind.MaterializedInput, + payload: null, + EffectiveScale.At(1)); + RenderFragmentReference prefix = includeGeometryPrefix + ? Fragment(RenderFragmentKind.Geometry, payload: null, EffectiveScale.At(1), source) + : source; + RenderFragmentReference cachedProducer = CurrentPixel(prefix, "return color * 0.75;"); + RenderFragmentReference tail = CurrentPixel(cachedProducer, "return half4(color.bgr, color.a);"); + RenderFragmentReference[] references = includeGeometryPrefix + ? [source, prefix, cachedProducer, tail] + : [source, cachedProducer, tail]; + + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance( + typeof(ExecutionIslandPlannerCacheBoundaryTests), + "planner-cache-boundary-test"); + foreach (RenderFragmentReference reference in references) + { + RenderValueId[] inputs = reference.Inputs + .SelectMany(static input => input.ValueIds) + .ToArray(); + reference.ValueIds = [builder.AddValue(inputs, provenance, reference)]; + reference.Id = builder.AddFragment(reference.ValueIds, provenance, reference); + } + + builder.AddCacheCandidate(cachedProducer.Id!.Value, "selected-candidate"); + builder.PublishRoot(tail.Id!.Value); + if (publishPrefix) + builder.PublishRoot(prefix.Id!.Value); + + return new GraphFixture(builder.Build(), prefix, cachedProducer, tail); + } + + private static RenderFragmentReference CurrentPixel( + RenderFragmentReference input, + string body) + { + ShaderDescription description = ShaderDescription.CurrentPixel( + $"half4 apply(half4 color) {{ {body} }}"); + return Fragment( + RenderFragmentKind.Shader, + new ShaderRenderFragmentPayload(description), + EffectiveScale.Unbounded, + input); + } + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + object? payload, + EffectiveScale scale, + params RenderFragmentReference[] inputs) + => new( + kind, + s_bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: inputs.Any(static input => input.HasTargetEffects), + hasOpaqueExternalWork: inputs.Any(static input => input.HasOpaqueExternalWork), + inputs, + payload, + static _ => true); + + private sealed record GraphFixture( + RecordedRenderGraph Graph, + RenderFragmentReference Prefix, + RenderFragmentReference CachedProducer, + RenderFragmentReference Tail); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/InTreeDeclaredTraitTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/InTreeDeclaredTraitTests.cs new file mode 100644 index 0000000000..3aac2403b9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/InTreeDeclaredTraitTests.cs @@ -0,0 +1,228 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.Media.TextFormatting; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +/// +/// Pins the planner traits the in-tree render nodes declare and the cache decisions they produce, so a +/// reversed mapping is caught instead of silently changing which fragments survive a remapping scope. +/// +[TestFixture] +public sealed class InTreeDeclaredTraitTests +{ + private static readonly Rect s_domain = new(0, 0, 256, 128); + + private static readonly Matrix s_subpixelShift = Matrix.CreateTranslation(3.25f, 4.5f); + + [TestCase(TransformOperator.Prepend, false, true, RenderDeviceGridMapping.Remapped)] + [TestCase(TransformOperator.Prepend, true, true, RenderDeviceGridMapping.Preserved)] + [TestCase(TransformOperator.Append, false, false, RenderDeviceGridMapping.Remapped)] + [TestCase(TransformOperator.Append, true, false, RenderDeviceGridMapping.Preserved)] + [TestCase(TransformOperator.Set, false, false, RenderDeviceGridMapping.Remapped)] + [TestCase(TransformOperator.Set, true, false, RenderDeviceGridMapping.Remapped)] + public void TransformRenderNode_DeclaresEligibilityAndGridMappingIndependently( + TransformOperator transformOperator, + bool identityMatrix, + bool expectedValueReplayMap, + RenderDeviceGridMapping expectedMapping) + { + using var transform = new TransformRenderNode( + identityMatrix ? Matrix.Identity : s_subpixelShift, + transformOperator); + transform.AddChild(NewRectangleNode()); + + TargetScopeDescription description = RecordSingleScope(transform); + + Assert.Multiple(() => + { + Assert.That(description.IsValueReplayMap, Is.EqualTo(expectedValueReplayMap)); + Assert.That(description.DeviceGridMapping, Is.EqualTo(expectedMapping)); + }); + } + + [TestCase(false, RenderDeviceGridMapping.Remapped)] + [TestCase(true, RenderDeviceGridMapping.Preserved)] + public void DrawableGroupTransform_DeclaresItsGridMappingFromTheResolvedMatrix( + bool identityMatrix, + RenderDeviceGridMapping expectedMapping) + { + using DrawableGroup.CustomTransformRenderNode transform = NewGroupTransformNode(identityMatrix); + transform.AddChild(NewRectangleNode()); + + TargetScopeDescription description = RecordSingleScope(transform); + + Assert.Multiple(() => + { + Assert.That(description.IsValueReplayMap, Is.True); + Assert.That(description.DeviceGridMapping, Is.EqualTo(expectedMapping)); + }); + } + + [TestCase(false, true)] + [TestCase(true, false)] + public void DrawableGroupTransform_BypassesTheTextCacheOnlyWhenItRemapsTheGrid( + bool identityMatrix, + bool expectBypass) + { + using DrawableGroup.CustomTransformRenderNode transform = NewGroupTransformNode(identityMatrix); + RenderNode text = NewTextNode(); + text.Cache.RecordStableRequests(); + transform.AddChild(text); + + Assert.That(ResolveSingleCacheDecision(transform).BypassReason, Is.EqualTo( + expectBypass ? RenderCacheBypassReason.DeviceGridDependentOutput : RenderCacheBypassReason.None)); + } + + [Test] + public void VectorSourcesConservativelyDeclareDeviceGridPhaseDependence() + { + Assert.Multiple(() => + { + Assert.That( + DeclaredSensitivity(NewTextNode()), + Is.EqualTo(RenderDeviceGridSensitivity.PhaseDependent)); + Assert.That( + DeclaredSensitivity(NewRectangleNode()), + Is.EqualTo(RenderDeviceGridSensitivity.PhaseDependent)); + Assert.That( + DeclaredSensitivity(new EllipseRenderNode( + new Rect(0, 0, 40, 30), + Brushes.Resource.White, + null)), + Is.EqualTo(RenderDeviceGridSensitivity.PhaseDependent)); + }); + } + + [TestCase(true, TransformOperator.Prepend, false, true)] + [TestCase(true, TransformOperator.Prepend, true, false)] + [TestCase(true, TransformOperator.Append, false, true)] + [TestCase(true, TransformOperator.Append, true, false)] + [TestCase(false, TransformOperator.Prepend, false, true)] + public void TransformOverASource_BypassesPhaseDependentContentUnderARemappingScope( + bool useText, + TransformOperator transformOperator, + bool identityMatrix, + bool expectBypass) + { + RenderNode source = useText ? NewTextNode() : NewRectangleNode(); + source.Cache.RecordStableRequests(); + using var transform = new TransformRenderNode( + identityMatrix ? Matrix.Identity : s_subpixelShift, + transformOperator); + transform.AddChild(source); + + Assert.That(ResolveSingleCacheDecision(transform).BypassReason, Is.EqualTo( + expectBypass ? RenderCacheBypassReason.DeviceGridDependentOutput : RenderCacheBypassReason.None)); + } + + [Test] + public void TheGridPreservingInTreeScopesDeclareThatTheyPreserveTheGrid() + { + using var push = new PushRenderNode(); + push.AddChild(NewRectangleNode()); + using var rectClip = new RectClipRenderNode(s_domain, ClipOperation.Intersect); + rectClip.AddChild(NewRectangleNode()); + using var geometryClip = new GeometryClipRenderNode( + new RectGeometry + { + Width = { CurrentValue = s_domain.Width }, + Height = { CurrentValue = s_domain.Height }, + }.ToResource(CompositionContext.Default), + ClipOperation.Intersect); + geometryClip.AddChild(NewRectangleNode()); + + Assert.Multiple(() => + { + Assert.That( + RecordSingleScope(push).DeviceGridMapping, + Is.EqualTo(RenderDeviceGridMapping.Preserved)); + Assert.That( + RecordSingleScope(rectClip).DeviceGridMapping, + Is.EqualTo(RenderDeviceGridMapping.Preserved)); + Assert.That( + RecordSingleScope(geometryClip).DeviceGridMapping, + Is.EqualTo(RenderDeviceGridMapping.Preserved)); + }); + } + + private static TargetScopeDescription RecordSingleScope(RenderNode node) + { + using RenderRequest request = CreateRequest(cacheEnabled: false); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return ((TargetScopeRenderFragmentPayload)GetSingleRoot(graph).Payload!).Description; + } + + private static RenderCacheDecision ResolveSingleCacheDecision(RenderNode node) + { + using RenderRequest request = CreateRequest(cacheEnabled: true, RenderRequestPurpose.Frame); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler( + renderCacheContext: new RenderCacheResolutionContext( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + new RenderCacheDeviceContextIdentity("device", "context"))) + .Compile(request, graph); + return compiled.CacheResolution.Decisions.Single(); + } + + private static RenderDeviceGridSensitivity DeclaredSensitivity(RenderNode node) + { + using (node) + { + using RenderRequest request = CreateRequest(cacheEnabled: false); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + var payload = (OpaqueRenderFragmentPayload)GetSingleRoot(graph).Payload!; + return payload.Description.DeviceGridSensitivity; + } + } + + private static DrawableGroup.CustomTransformRenderNode NewGroupTransformNode(bool identityMatrix) + => new( + identityMatrix + ? null + : new TranslateTransform(s_subpixelShift.M31, s_subpixelShift.M32) + .ToResource(CompositionContext.Default), + default, + s_domain.Size, + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(s_domain)); + + private static RectangleRenderNode NewRectangleNode() + => new(new Rect(0, 0, 40, 30), Brushes.Resource.White, null); + + private static TextRenderNode NewTextNode() + { + var text = new FormattedText + { + Font = TypefaceProvider.Typeface().FontFamily, + Size = 48f, + Text = "ab", + }; + return new TextRenderNode(text, Brushes.Resource.White, null); + } + + private static RenderRequest CreateRequest( + bool cacheEnabled, + RenderRequestPurpose purpose = RenderRequestPurpose.Auxiliary) + => new(new RenderRequestOptions( + RenderIntent.Preview, + purpose, + targetDomain: s_domain, + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: cacheEnabled ? RenderCacheOptions.Enabled : RenderCacheOptions.Disabled)); + + private static RenderFragmentReference GetSingleRoot(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/MaterializedInputCompositeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/MaterializedInputCompositeTests.cs new file mode 100644 index 0000000000..6dc3961fa5 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/MaterializedInputCompositeTests.cs @@ -0,0 +1,276 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +[NonParallelizable] +public sealed class MaterializedInputCompositeTests +{ + private static readonly Rect s_sourceBounds = new(0, 0, 7, 5); + + [Test] + public void ExternalInput_WithExactOneToOneMapping_PreservesSourcePixelBytes() + { + using var source = new CpuRenderTarget(7, 5); + FillHighFrequencyPattern(source); + using Bitmap expected = source.Snapshot(); + + using Bitmap actual = RenderExternalInput( + source, + sourceDensity: 1, + destinationDensity: 1, + destinationSize: new PixelSize(7, 5), + transform: Matrix.Identity); + + Assert.Multiple(() => + { + AssertContainsVisibleContent(expected); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "An exact external input must retain every source pixel byte."); + }); + } + + [Test] + public void ExternalInput_WithDifferentDestinationDensity_UsesScaledComposite() + { + using var source = new CpuRenderTarget(7, 5); + FillHighFrequencyPattern(source); + var destinationSize = new PixelSize(14, 10); + + using Bitmap expected = RenderScaledReference( + source, + destinationDensity: 2, + destinationSize, + transform: Matrix.Identity); + using Bitmap actual = RenderExternalInput( + source, + sourceDensity: 1, + destinationDensity: 2, + destinationSize, + transform: Matrix.Identity); + + Assert.Multiple(() => + { + AssertContainsVisibleContent(expected); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "A density mismatch must retain the Mitchell-scaled fallback."); + }); + } + + [Test] + public void ExternalInput_WithFractionalTransform_UsesScaledComposite() + { + using var source = new CpuRenderTarget(7, 5); + FillHighFrequencyPattern(source); + var destinationSize = new PixelSize(9, 7); + Matrix transform = Matrix.CreateTranslation(0.5f, 0.25f); + + using Bitmap expected = RenderScaledReference( + source, + destinationDensity: 1, + destinationSize, + transform); + using Bitmap actual = RenderExternalInput( + source, + sourceDensity: 1, + destinationDensity: 1, + destinationSize, + transform); + + Assert.Multiple(() => + { + AssertContainsVisibleContent(expected); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "A fractional device mapping must retain the transformed scaled fallback."); + }); + } + + [Test] + public void ExternalInput_PreservesDeclaredDeviceGridPlacementAndApron() + { + var bounds = new Rect(2.25f, 1.5f, 7, 5); + var deviceGridOffset = new Vector(0.5f, 0.25f); + PixelRect deviceBounds = PixelRect.FromRect(bounds.Translate(deviceGridOffset), 1); + Rect rasterBounds = deviceBounds.ToRect(1).Translate(-deviceGridOffset); + using var source = new CpuRenderTarget(deviceBounds.Width, deviceBounds.Height); + FillHighFrequencyPattern(source); + var destinationSize = new PixelSize(12, 9); + + using Bitmap expected = RenderScaledReference( + source, + destinationDensity: 1, + destinationSize, + transform: Matrix.Identity, + rasterBounds); + using Bitmap actual = RenderExternalInput( + source, + sourceDensity: 1, + destinationDensity: 1, + destinationSize, + transform: Matrix.Identity, + bounds, + deviceBounds, + deviceGridOffset); + + Assert.Multiple(() => + { + AssertContainsVisibleContent(expected); + Assert.That( + actual.GetPixelSpan().SequenceEqual(expected.GetPixelSpan()), + Is.True, + "Materialization must preserve the supplied physical footprint and fractional device-grid phase."); + }); + } + + private static Bitmap RenderExternalInput( + RenderTarget source, + float sourceDensity, + float destinationDensity, + PixelSize destinationSize, + Matrix transform, + Rect? bounds = null, + PixelRect? deviceBounds = null, + Vector deviceGridOffset = default) + { + Rect sourceBounds = bounds ?? s_sourceBounds; + PixelRect sourceDeviceBounds = deviceBounds ?? PixelRect.FromRect(sourceBounds, sourceDensity); + using var node = new MaterializedInputNode( + source, + sourceBounds, + sourceDensity, + sourceDeviceBounds, + deviceGridOffset); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: sourceBounds, + outputScale: destinationDensity, + maxWorkingScale: destinationDensity, + cachePolicy: RenderCacheOptions.Disabled); + using var request = new RenderRequest(options); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + using CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph); + using var destination = new CpuRenderTarget(destinationSize.Width, destinationSize.Height); + using var canvas = new ImmediateCanvas( + destination, + destinationDensity, + destinationDensity, + destinationSize.ToSize(destinationDensity)); + canvas.Clear(); + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = + registry.BeginSession( + RenderIntent.Preview, + destination); + using (canvas.PushTransform(transform)) + { + new RenderRequestExecutor(targets).Execute(compiled, canvas); + } + + return destination.Snapshot(); + } + + private static Bitmap RenderScaledReference( + RenderTarget source, + float destinationDensity, + PixelSize destinationSize, + Matrix transform, + Rect? rasterBounds = null) + { + using var destination = new CpuRenderTarget(destinationSize.Width, destinationSize.Height); + using var canvas = new ImmediateCanvas( + destination, + destinationDensity, + destinationDensity, + destinationSize.ToSize(destinationDensity)); + canvas.Clear(); + using (canvas.PushTransform(transform)) + { + canvas.DrawRenderTargetScaledWithoutFlush(source, rasterBounds ?? s_sourceBounds); + } + + return destination.Snapshot(); + } + + private static void FillHighFrequencyPattern(RenderTarget target) + { + using var paint = new SKPaint + { + IsAntialias = false, + }; + for (int y = 0; y < target.Height; y++) + { + for (int x = 0; x < target.Width; x++) + { + paint.Color = ((x + y) & 1) == 0 + ? new SKColor(255, 24, 8, 255) + : new SKColor(4, 40, 255, 255); + target.Value.Canvas.DrawRect(x, y, 1, 1, paint); + } + } + target.Value.Flush(); + } + + private static void AssertContainsVisibleContent(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + bool hasVisiblePixel = false; + for (int index = 3; index < pixels.Length; index += 4) + { + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[index]); + if (float.IsFinite(alpha) && alpha > 0) + { + hasVisiblePixel = true; + break; + } + } + + Assert.That( + hasVisiblePixel, + Is.True, + "The composite reference must contain visible source content."); + } + + private sealed class MaterializedInputNode( + RenderTarget source, + Rect bounds, + float density, + PixelRect deviceBounds, + Vector deviceGridOffset) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource resource = context.Borrow(source); + context.Publish(context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + resource, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset, + RenderHitTestContract.OutputBounds))); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU test surface."), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs new file mode 100644 index 0000000000..6a23eed313 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs @@ -0,0 +1,146 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class ProductionResourceLifetimeTests +{ + private static readonly Rect s_bounds = new(0, 0, 16, 16); + + + [Test] + public void FanOut_RetainsProducerThroughEveryConsumerThenReusesItsSlot() + { + using var node = new FanOutOpaqueNode(); + using var renderer = CreateRenderer(node); + using RenderTarget target = new CpuRenderTarget(16, 16); + using var canvas = new ImmediateCanvas(target); + + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(node.ExecutionCount, Is.EqualTo(8)); + Assert.That(renderer.TargetPoolStatistics.LeasedTargets, Is.Zero); + }); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_bounds, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private sealed class LinearOpaqueChainNode(int stageCount) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle current = context.OpaqueSource(CreateSourceDescription("linear-source")); + for (int index = 0; index < stageCount; index++) + current = context.OpaqueMap(current, CreateMapDescription(("linear-map", index))); + context.Publish(current); + } + } + + private sealed class FanOutOpaqueNode : RenderNode + { + public int ExecutionCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(CreateSourceDescription( + "fan-out-source", + () => ExecutionCount++)); + RenderFragmentHandle left = context.OpaqueMap( + source, + CreateMapDescription("fan-out-left", () => ExecutionCount++)); + RenderFragmentHandle right = context.OpaqueMap( + source, + CreateMapDescription("fan-out-right", () => ExecutionCount++)); + context.Publish(context.OpaqueCombine( + [left, right], + CreateCombineDescription("fan-out-combine", () => ExecutionCount++))); + } + } + + private static OpaqueRenderDescription CreateSourceDescription( + object key, + Action? onExecute = null) + => OpaqueRenderDescription.CreateRequestLocal( + session => + { + onExecute?.Invoke(); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + + private static OpaqueRenderDescription CreateMapDescription( + object key, + Action? onExecute = null) + => OpaqueRenderDescription.CreateRequestLocal( + session => + { + onExecute?.Invoke(); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs.Single().Draw); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply); + + private static OpaqueRenderDescription CreateCombineDescription( + object key, + Action? onExecute = null) + => OpaqueRenderDescription.CreateRequestLocal( + session => + { + onExecute?.Invoke(); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => + { + foreach (RenderExecutionInput input in session.Inputs) + input.Draw(canvas); + }); + session.Publish(output); + }, + OpaqueRenderBoundsContract.FullInputs( + static inputs => inputs.Aggregate(Rect.Empty, static (result, input) => result.Union(input))), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RawScopeNestingAndCaptureOffsetTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RawScopeNestingAndCaptureOffsetTests.cs new file mode 100644 index 0000000000..8e589a81ec --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RawScopeNestingAndCaptureOffsetTests.cs @@ -0,0 +1,495 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class RawScopeNestingAndCaptureOffsetTests +{ + private static readonly Rect s_domain = new(0, 0, 64, 64); + private static readonly Rect s_mark = new(8, 8, 8, 8); + private const float Shift = 10; + + // Half-transparent so a capture drawn back over the mark is observable: a direct draw alone + // leaves alpha 128, an in-place round trip composites 128 over 128 and leaves alpha ~192. + private static readonly Color s_markColor = Color.FromArgb(128, 255, 0, 0); + private const byte RoundTripAlpha = 192; + // Well above the fringe a resampled round trip leaves around the mark, well below a real copy. + private const byte StrayAlpha = 64; + + [Test] + public void RawTargetScope_ExecutesNestedTargetWorkInsideItsReplay() + { + using var node = new NestedRawCommandNode(); + using var renderer = CreateRenderer(node); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The nested raw target work produced no bitmap."); + var sample = bitmap.SKBitmap.GetPixel( + (int)(s_mark.Center.X - rasterization.Bounds.X), + (int)(s_mark.Center.Y - rasterization.Bounds.Y)); + + Assert.Multiple(() => + { + Assert.That(node.NestedExecutions, Is.EqualTo(1), + "A raw target scope must let its replayed subtree perform nested target work."); + Assert.That(sample.Red, Is.EqualTo(byte.MaxValue)); + Assert.That(sample.Alpha, Is.EqualTo(byte.MaxValue)); + }); + } + + [Test] + public void RawTargetScope_RemainsAnOpaqueExternalBarrierWhileNestingIsAllowed() + { + using var node = new NestedRawCommandNode(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: s_domain)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentReference scope = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(static reference => reference.Kind == RenderFragmentKind.RawTargetScope); + + Assert.That(scope.HasOpaqueExternalWork, Is.True, + "Nested target work must not relax the raw scope's opaque-external barrier."); + } + + [Test] + public void TargetCapture_UnderANonZeroDeviceGridOffset_CopiesTheTargetWithoutDisplacingIt() + { + using var root = new ContainerRenderNode(); + root.AddChild(new MarkNode()); + var scope = new TransformRenderNode( + Matrix.CreateTranslation(Shift, 0), + TransformOperator.Append); + // Local (-10, 0, 64, 64) covers the whole target, so the round trip must land in place. + scope.AddChild(new CaptureRoundTripNode(new Rect(-Shift, 0, s_domain.Width, s_domain.Height))); + root.AddChild(scope); + using var renderer = CreateRenderer(root); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + AssertCaptureLandedOnTheMark(rasterization); + } + + [Test] + public void TargetCapture_UnderAScaledAndTranslatedTarget_CopiesTheTargetWithoutDisplacingIt() + { + using var root = new ContainerRenderNode(); + root.AddChild(new MarkNode()); + var scope = new TransformRenderNode( + Matrix.CreateScale(2, 2) * Matrix.CreateTranslation(Shift, 0), + TransformOperator.Append); + // Local (-5, 0, 32, 32) covers the whole target, so the round trip must land in place. + scope.AddChild(new CaptureRoundTripNode(new Rect(-5, 0, 32, 32))); + root.AddChild(scope); + using var renderer = CreateRenderer(root); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + AssertCaptureLandedOnTheMark(rasterization); + } + + [Test] + public void TargetCapture_UnderAScaledTarget_MaterializesAtTheTargetsPixelSupply() + { + using var root = new ContainerRenderNode(); + root.AddChild(new MarkNode()); + var scope = new TransformRenderNode( + Matrix.CreateScale(2, 2), + TransformOperator.Append); + // Local (0, 0, 32, 32) covers the whole target, so the round trip must land in place. + scope.AddChild(new CaptureRoundTripNode(new Rect(0, 0, 32, 32))); + root.AddChild(scope); + using var renderer = CreateRenderer(root); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The scaled target capture produced no bitmap."); + var origin = new PixelPoint((int)rasterization.Bounds.X, (int)rasterization.Bounds.Y); + var mark = bitmap.SKBitmap.GetPixel( + (int)s_mark.Center.X - origin.X, + (int)s_mark.Center.Y - origin.Y); + + // A capture taken below the target's supply comes back through a 2x upsample, which leaves a + // one-pixel fringe of half the mark's alpha around it. + int fringe = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.SKBitmap.GetPixel(x, y).Alpha == 0) + continue; + if (!s_mark.Contains(new Point(x + origin.X, y + origin.Y))) + fringe++; + } + } + + Assert.Multiple(() => + { + Assert.That(mark.Alpha, Is.EqualTo(RoundTripAlpha).Within(8), + "Replaying a capture of the target back into the same place must reproduce the mark there."); + Assert.That(fringe, Is.Zero, + "A capture under a scaled target must materialize at the target's own pixel supply."); + }); + } + + [Test] + [TestCase(0f)] + [TestCase(0.0005f)] + public void BuiltInBackdropCapture_UnderADegenerateTargetTransform_RendersTheRestOfTheFrame(float scale) + { + using var root = new ContainerRenderNode(); + root.AddChild(new MarkNode()); + var scope = new TransformRenderNode( + Matrix.CreateScale(scale, scale), + TransformOperator.Append); + var snapshot = new SnapshotBackdropRenderNode(); + scope.AddChild(snapshot); + scope.AddChild(new DrawBackdropRenderNode(snapshot, s_domain)); + root.AddChild(scope); + using var renderer = CreateRenderer(root); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The degenerate backdrop round trip produced no bitmap."); + var origin = new PixelPoint((int)rasterization.Bounds.X, (int)rasterization.Bounds.Y); + var mark = bitmap.SKBitmap.GetPixel( + (int)s_mark.Center.X - origin.X, + (int)s_mark.Center.Y - origin.Y); + + int ink = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.SKBitmap.GetPixel(x, y).Alpha == 0) + continue; + if (!s_mark.Contains(new Point(x + origin.X, y + origin.Y))) + ink++; + } + } + + Assert.Multiple(() => + { + Assert.That(mark.Alpha, Is.EqualTo(s_markColor.A), + "A backdrop under a degenerate transform must leave the rest of the frame untouched."); + Assert.That(ink, Is.Zero, + "A capture with no readable preimage must contribute no pixels."); + }); + } + + [Test] + [TestCase(2f, 2f, 2f)] + [TestCase(4f, 0.25f, 4f)] + [TestCase(8f, 0.125f, 8f)] + public void BuiltInBackdropCapture_UnderAnAnisotropicTargetTransform_UsesTheFinerAxis( + float scaleX, + float scaleY, + float maximumSingularValue) + { + var domain = new Rect(0, 0, 64, 64); + Matrix transform = Matrix.CreateScale(scaleX, scaleY); + using var root = new ContainerRenderNode(); + var scope = new TransformRenderNode( + transform, + TransformOperator.Append); + var probe = new CaptureSizeProbeNode(); + scope.AddChild(probe); + scope.AddChild(new DrawBackdropRenderNode(probe, domain)); + root.AddChild(scope); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = domain, + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Rect captureBounds = domain.TransformToAABB(transform.Invert()); + PixelRect expectedFootprint = PixelRect.FromRect(captureBounds, maximumSingularValue); + + Assert.Multiple(() => + { + Assert.That(probe.CapturedDensity, Is.EqualTo(maximumSingularValue).Within(1e-4f), + "A capture preserving the target's supply must retain the affine transform's maximum singular value."); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(expectedFootprint.Size)); + Assert.That(expectedFootprint.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(expectedFootprint.Height, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void BuiltInBackdropCapture_ClampsTheMaximumSingularValueToTheCaptureFootprint() + { + var domain = new Rect(0, 0, 1920, 1080); + Matrix transform = Matrix.CreateScale(4, 0.25f); + using var root = new ContainerRenderNode(); + var scope = new TransformRenderNode(transform, TransformOperator.Append); + var probe = new CaptureSizeProbeNode(); + scope.AddChild(probe); + scope.AddChild(new DrawBackdropRenderNode(probe, domain)); + root.AddChild(scope); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = domain, + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Rect captureBounds = domain.TransformToAABB(transform.Invert()); + float expectedDensity = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(captureBounds, 4); + PixelRect expectedFootprint = PixelRect.FromRect(captureBounds, expectedDensity); + Assert.Multiple(() => + { + Assert.That(probe.CapturedDensity, Is.EqualTo(expectedDensity).Within(1e-4f)); + Assert.That(probe.CapturedDensity, Is.LessThan(4)); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(expectedFootprint.Size)); + Assert.That(expectedFootprint.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(expectedFootprint.Height, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void BuiltInBackdropCapture_UnderShear_UsesTheMaximumSingularValue() + { + var domain = new Rect(0, 0, 256, 128); + var transform = new Matrix(1, 1, 0, 1, 0, 0); + using var root = new ContainerRenderNode(); + var scope = new TransformRenderNode( + transform, + TransformOperator.Append); + var probe = new CaptureSizeProbeNode(); + scope.AddChild(probe); + scope.AddChild(new DrawBackdropRenderNode(probe, domain)); + root.AddChild(scope); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = domain, + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + float expectedDensity = MathF.Sqrt((3 + MathF.Sqrt(5)) / 2); + Rect captureBounds = domain.TransformToAABB(transform.Invert()); + PixelSize expectedSize = PixelRect.FromRect(captureBounds, expectedDensity).Size; + Assert.Multiple(() => + { + Assert.That(probe.CapturedDensity, Is.EqualTo(expectedDensity).Within(1e-4f)); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(expectedSize)); + }); + } + + [Test] + public void BuiltInBackdropCapture_UnderPerspective_RejectsBeforeAllocatingTheCapture() + { + var domain = new Rect(0, 0, 64, 64); + using var root = new ContainerRenderNode(); + var scope = new TransformRenderNode( + new Matrix( + 1, 0, 0.01f, + 0, 1, 0, + 0, 0, 1), + TransformOperator.Append); + var probe = new CaptureSizeProbeNode(); + scope.AddChild(probe); + scope.AddChild(new DrawBackdropRenderNode(probe, domain)); + root.AddChild(scope); + var factory = new FailureTestTargetFactory(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = domain, + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + NotSupportedException? failure = Assert.Throws(() => renderer.Rasterize()); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("perspective")); + Assert.That(factory.CreateCalls, Is.EqualTo(1), + "only the root execution target may be acquired before perspective capture rejection"); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(default(PixelSize))); + }); + } + + private sealed class CaptureSizeProbeNode : SnapshotBackdropRenderNode, IBuiltInBackdropCaptureSink + { + public PixelSize CapturedDeviceSize { get; private set; } + + public float CapturedDensity { get; private set; } + + bool IBuiltInBackdropCaptureSink.TryCommitBackdropCapture(Bitmap bitmap, float density) + { + Record(bitmap, density); + return true; + } + + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + => Record(bitmap, density); + + private void Record(Bitmap bitmap, float density) + { + CapturedDeviceSize = new PixelSize(bitmap.Width, bitmap.Height); + CapturedDensity = density; + bitmap.Dispose(); + } + } + + private static void AssertCaptureLandedOnTheMark(RenderNodeRasterization rasterization) + { + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The offset target capture produced no bitmap."); + var origin = new PixelPoint((int)rasterization.Bounds.X, (int)rasterization.Bounds.Y); + var mark = bitmap.SKBitmap.GetPixel( + (int)s_mark.Center.X - origin.X, + (int)s_mark.Center.Y - origin.Y); + + // A resampled round trip bleeds a pixel or so past the mark. + var tolerated = s_mark.Inflate(2); + int strays = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + if (bitmap.SKBitmap.GetPixel(x, y).Alpha < StrayAlpha) + continue; + if (!tolerated.Contains(new Point(x + origin.X, y + origin.Y))) + strays++; + } + } + + Assert.Multiple(() => + { + Assert.That(mark.Alpha, Is.EqualTo(RoundTripAlpha).Within(8), + "Replaying a capture of the target back into the same place must reproduce the mark there."); + Assert.That(strays, Is.Zero, + "The captured copy must not land displaced from the region it was read from."); + }); + } + + /// + /// The scope a capture ends up in decides its resolved region and target domain, and the author has + /// neither when they build the description. Bounds reaching past the pixels available must therefore read + /// transparent rather than fail the frame, or the same description would work or throw depending only on + /// where it was used. + /// + [Test] + public void TargetCapture_ReachingPastTheTargetDomain_ReadsTransparentInsteadOfFailing() + { + using var root = new ContainerRenderNode(); + root.AddChild(new MarkNode()); + root.AddChild(new CaptureRoundTripNode(s_domain.Inflate(16))); + using var renderer = CreateRenderer(root); + + Assert.That(() => renderer.Rasterize().Dispose(), Throws.Nothing); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = RenderIntent.Preview, + TargetDomain = s_domain, + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + private sealed class MarkNode : RenderNode + { + public override void Process(RenderNodeContext context) + => context.Publish(context.OpaqueSource(OpaqueRenderDescription.Create( + "capture-offset-mark", + static (session, _) => + { + using OpaqueRenderOutput output = session.CreateOutput(s_mark); + output.Canvas.Use(static canvas => canvas.Clear(s_markColor)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_mark), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale))); + } + + private sealed class NestedRawCommandNode : RenderNode + { + public int NestedExecutions { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle nested = context.RawTargetCommand(RawTargetCommandDescription.CreateRequestLocal( + session => + { + NestedExecutions++; + session.Canvas.Clear(Colors.Red); + }, + s_domain, + RenderHitTestContract.OutputBounds)); + context.Publish(context.RawTargetScope( + nested, + RawTargetScopeDescription.CreateRequestLocal( + static session => session.ReplayInput(), + RenderBoundsContract.FullInput, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply))); + } + } + + private sealed class CaptureRoundTripNode(Rect captureBounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(captureBounds), + captureBounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.PreserveTargetSupply)); + context.Publish(context.ContributeValues(capture)); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RegionAnalyzerTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RegionAnalyzerTests.cs new file mode 100644 index 0000000000..b275457320 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RegionAnalyzerTests.cs @@ -0,0 +1,375 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class RegionAnalyzerTests +{ + [Test] + public void Analyze_MapsShiftedRequestBackwardThroughForwardGrowth() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source( + new Rect(10, 10, 100, 100), + EffectiveScale.At(2)); + RenderBoundsContract grow = RenderBoundsContract.Create( + static input => input.Inflate(new Thickness(5)), + static requested => requested.Inflate(new Thickness(5))); + RenderFragmentReference output = graph.Map(source, grow); + var options = Options(requestedRegion: new Rect(0, 0, 20, 20)); + + RegionAnalysis result = new RegionAnalyzer().Analyze(options, [output]); + + Assert.Multiple(() => + { + Assert.That(result.RootOutputExtent, Is.EqualTo(new Rect(5, 5, 110, 110))); + Assert.That(result.FinalCommitBounds, Is.EqualTo(new Rect(5, 5, 15, 15))); + Assert.That(result.GetFragmentRequirement(output), + Is.EqualTo(RequiredRegion.Region(new Rect(5, 5, 15, 15)))); + Assert.That(result.GetFragmentRequirement(source), + Is.EqualTo(RequiredRegion.Region(new Rect(10, 10, 15, 15)))); + Assert.That(result.GetMetadata(source).EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(result.GetMetadata(output).EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + }); + } + + [Test] + public void Analyze_NullRequestSelectsCompleteForwardShrinkWithoutPromotingItToFullFallback() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source( + new Rect(0, 0, 100, 100), + EffectiveScale.At(3)); + RenderBoundsContract shrink = RenderBoundsContract.Create( + static input => input.Deflate(new Thickness(10)), + static requested => requested.Inflate(new Thickness(10))); + RenderFragmentReference output = graph.Map(source, shrink); + + RegionAnalysis result = new RegionAnalyzer().Analyze(Options(), [output]); + + Assert.Multiple(() => + { + Assert.That(result.RootOutputExtent, Is.EqualTo(new Rect(10, 10, 80, 80))); + Assert.That(result.FinalCommitBounds, Is.EqualTo(result.RootOutputExtent)); + Assert.That(result.FinalCommitRegion, + Is.EqualTo(RequiredRegion.Region(new Rect(10, 10, 80, 80)))); + Assert.That(result.GetFragmentRequirement(source), + Is.EqualTo(RequiredRegion.Region(new Rect(0, 0, 100, 100)))); + Assert.That(result.GetFragmentRequirement(source), Is.Not.EqualTo(RequiredRegion.Full)); + Assert.That(result.GetMetadata(output).EffectiveScale, Is.EqualTo(EffectiveScale.At(3))); + }); + } + + [Test] + public void Analyze_ClipsOutsideAndShiftedEmptyCommitBoundsToTheRootOutputExtent() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source(new Rect(10, 20, 30, 40)); + var analyzer = new RegionAnalyzer(); + + RegionAnalysis outside = analyzer.Analyze( + Options(requestedRegion: new Rect(100, 200, 7, 9)), + [source]); + RegionAnalysis empty = analyzer.Analyze( + Options(requestedRegion: new Rect(70, 80, 0, 10)), + [source]); + + Assert.Multiple(() => + { + Assert.That(outside.FinalCommitBounds, Is.EqualTo(Rect.Empty)); + Assert.That(outside.FinalCommitRegion, Is.EqualTo(RequiredRegion.Empty)); + Assert.That(outside.GetFragmentRequirement(source), Is.EqualTo(RequiredRegion.Empty)); + Assert.That(empty.FinalCommitBounds, Is.EqualTo(new Rect(70, 80, 0, 10))); + Assert.That(empty.FinalCommitRegion, Is.EqualTo(RequiredRegion.Empty)); + Assert.That(empty.GetFragmentRequirement(source), Is.EqualTo(RequiredRegion.Empty)); + }); + } + + [Test] + public void Analyze_UsesExplicitFullForConservativeFullInputFallback() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source(new Rect(0, 0, 100, 80)); + RenderFragmentReference identity = graph.Map(source, RenderBoundsContract.Identity); + RenderFragmentReference output = graph.Map(identity, RenderBoundsContract.FullInput); + + RegionAnalysis result = new RegionAnalyzer().Analyze( + Options(requestedRegion: new Rect(30, 20, 10, 10)), + [output]); + + Assert.Multiple(() => + { + Assert.That(result.GetFragmentRequirement(output), + Is.EqualTo(RequiredRegion.Region(new Rect(30, 20, 10, 10)))); + Assert.That(result.GetFragmentRequirement(identity), Is.EqualTo(RequiredRegion.Full)); + Assert.That(result.GetFragmentRequirement(source), Is.EqualTo(RequiredRegion.Full)); + Assert.That(result.GetValueRequirement(source.ValueIds.Single()), Is.EqualTo(RequiredRegion.Full)); + }); + } + + [Test] + public void Analyze_UnionsFanOutRequirementsBeforeVisitingSharedProducer() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source(new Rect(0, 0, 100, 20)); + RenderBoundsContract leftBounds = RenderBoundsContract.Create( + static _ => new Rect(0, 0, 40, 20), + static requested => requested); + RenderBoundsContract rightBounds = RenderBoundsContract.Create( + static _ => new Rect(60, 0, 40, 20), + static requested => requested); + RenderFragmentReference left = graph.Map(source, leftBounds); + RenderFragmentReference right = graph.Map(source, rightBounds); + + RegionAnalysis result = new RegionAnalyzer().Analyze( + Options(requestedRegion: new Rect(10, 0, 80, 20)), + [left, right]); + + Assert.Multiple(() => + { + Assert.That(result.GetFragmentRequirement(left), + Is.EqualTo(RequiredRegion.Region(new Rect(10, 0, 30, 20)))); + Assert.That(result.GetFragmentRequirement(right), + Is.EqualTo(RequiredRegion.Region(new Rect(60, 0, 30, 20)))); + Assert.That(result.GetFragmentRequirement(source), + Is.EqualTo(RequiredRegion.Region(new Rect(10, 0, 80, 20)))); + }); + } + + [Test] + public void Analyze_ExpandsTargetReadApronWithoutChangingDeclaredDensity() + { + var graph = new FragmentGraph(); + Rect domain = new(0, 0, 100, 100); + RenderFragmentReference capture = graph.Capture(domain, EffectiveScale.At(2)); + RenderBoundsContract blur = RenderBoundsContract.Create( + static input => input.Inflate(new Thickness(10)), + static requested => requested.Inflate(new Thickness(10))); + RenderFragmentReference output = graph.Map(capture, blur, contributes: true); + + RegionAnalysis result = new RegionAnalyzer().Analyze( + Options(targetDomain: domain, requestedRegion: new Rect(40, 40, 10, 10)), + [output]); + + Assert.Multiple(() => + { + Assert.That(result.GetFragmentRequirement(capture), + Is.EqualTo(RequiredRegion.Region(new Rect(30, 30, 30, 30)))); + Assert.That(result.GetTargetAccessRequirement(capture), + Is.EqualTo(RequiredRegion.Region(new Rect(30, 30, 30, 30)))); + Assert.That(result.GetMetadata(capture).EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(result.GetMetadata(output).EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + }); + } + + [Test] + public void Analyze_RejectsInvalidForwardAndBackwardMappings() + { + var forwardGraph = new FragmentGraph(); + RenderFragmentReference forwardSource = forwardGraph.Source(new Rect(0, 0, 10, 10)); + RenderBoundsContract invalidForward = RenderBoundsContract.Create( + static _ => new Rect(float.NaN, 0, 10, 10), + static requested => requested); + RenderFragmentReference invalidForwardOutput = forwardGraph.Map( + forwardSource, + invalidForward, + recordedBounds: new Rect(0, 0, 10, 10)); + + var backwardGraph = new FragmentGraph(); + RenderFragmentReference backwardSource = backwardGraph.Source(new Rect(0, 0, 10, 10)); + RenderBoundsContract invalidBackward = RenderBoundsContract.Create( + static input => input, + static _ => new Rect(0, 0, -1, 10)); + RenderFragmentReference invalidBackwardOutput = backwardGraph.Map(backwardSource, invalidBackward); + + Assert.Multiple(() => + { + Assert.That( + () => new RegionAnalyzer().Analyze(Options(), [invalidForwardOutput]), + Throws.TypeOf()); + Assert.That( + () => new RegionAnalyzer().Analyze( + Options(requestedRegion: new Rect(0, 0, 5, 5)), + [invalidBackwardOutput]), + Throws.TypeOf()); + }); + } + + [Test] + public void Analyze_RejectsNonDeterministicConcreteForwardMapping() + { + var graph = new FragmentGraph(); + RenderFragmentReference source = graph.Source(new Rect(0, 0, 10, 10)); + int calls = 0; + RenderBoundsContract nonDeterministic = RenderBoundsContract.Create( + input => calls++ == 0 ? input : input.Translate(new Point(0.25f, 0)), + static requested => requested); + RenderFragmentReference output = graph.Map(source, nonDeterministic); + + InvalidOperationException? failure = Assert.Throws( + () => new RegionAnalyzer().Analyze(Options(), [output])); + + Assert.Multiple(() => + { + Assert.That( + failure!.Message, + Does.Contain("changed between recording and graph-wide metadata resolution")); + Assert.That(calls, Is.EqualTo(2)); + }); + } + + [Test] + public void Analyze_KeepsOutputQueryTargetRequestedAndCommitDomainsIndependent() + { + var graph = new FragmentGraph(); + RenderFragmentReference value = graph.Source(new Rect(0, 0, 20, 20)); + RenderFragmentReference command = graph.Command( + TargetRegion.Region(new Rect(50, 50, 10, 10)), + queryBounds: new Rect(100, 100, 5, 5)); + Rect targetDomain = new(0, 0, 200, 160); + Rect requested = new(140, 120, 30, 20); + + RegionAnalysis result = new RegionAnalyzer().Analyze( + Options(targetDomain, requested), + [value, command]); + + Assert.Multiple(() => + { + Assert.That(result.RootOutputExtent, Is.EqualTo(new Rect(0, 0, 60, 60))); + Assert.That(result.QueryBounds, Is.EqualTo(new Rect(0, 0, 105, 105))); + Assert.That(result.Measurement.OutputBounds, Is.EqualTo(result.RootOutputExtent)); + Assert.That(result.Measurement.QueryBounds, Is.EqualTo(result.QueryBounds)); + Assert.That(result.TargetDomain, Is.EqualTo(targetDomain)); + Assert.That(result.RequestedRegion, Is.EqualTo(requested)); + Assert.That(result.FinalCommitBounds, Is.EqualTo(Rect.Empty)); + Assert.That(result.GetFragmentRequirement(value), Is.EqualTo(RequiredRegion.Empty)); + Assert.That(result.GetTargetAccessRequirement(command), Is.EqualTo(RequiredRegion.Empty)); + }); + } + + private static RenderRequestOptions Options( + Rect? targetDomain = null, + Rect? requestedRegion = null) + => new( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain, + requestedRegion, + cachePolicy: Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled); + + private sealed class FragmentGraph + { + private readonly RenderRequestId _requestId = new(1); + private long _nextId; + + public RenderFragmentReference Source( + Rect bounds, + EffectiveScale? scale = null) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + static _ => { }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + return Stamp(new RenderFragmentReference( + RenderFragmentKind.OpaqueSource, + bounds, + scale ?? EffectiveScale.At(1), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: true, + inputs: null, + new OpaqueRenderFragmentPayload( + OpaqueRenderTopology.Source, + description, + Array.Empty()), + bounds.Contains)); + } + + public RenderFragmentReference Map( + RenderFragmentReference input, + RenderBoundsContract bounds, + bool? contributes = null, + Rect? recordedBounds = null) + { + Rect outputBounds = recordedBounds ?? bounds.TransformBounds(input.Bounds); + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + static _ => { }, + OpaqueRenderBoundsContract.Map(bounds), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply); + return Stamp(new RenderFragmentReference( + RenderFragmentKind.OpaqueMap, + outputBounds, + input.EffectiveScale, + RenderValueCardinality.Single, + contributes ?? input.ContributesValuesToTarget, + canBeUsedAsValueInput: true, + hasTargetEffects: input.HasTargetEffects, + hasOpaqueExternalWork: true, + [input], + new OpaqueRenderFragmentPayload( + OpaqueRenderTopology.Map, + description, + [RenderInputReadback.None]), + outputBounds.Contains)); + } + + public RenderFragmentReference Capture(Rect bounds, EffectiveScale scale) + { + TargetCaptureDescription description = TargetCaptureDescription.Create( + TargetRegion.Full, + bounds, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale); + return Stamp(new RenderFragmentReference( + RenderFragmentKind.TargetCapture, + bounds, + scale, + RenderValueCardinality.Single, + contributesValuesToTarget: false, + canBeUsedAsValueInput: true, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + inputs: null, + new TargetCaptureRenderFragmentPayload(description), + hitTest: null)); + } + + public RenderFragmentReference Command(TargetRegion affectedRegion, Rect queryBounds) + { + TargetCommandDescription description = TargetCommandDescription.CreateRequestLocal( + static _ => { }, + affectedRegion, + queryBounds, + RenderHitTestContract.OutputBounds); + return Stamp(new RenderFragmentReference( + RenderFragmentKind.TargetCommand, + queryBounds, + EffectiveScale.Unbounded, + RenderValueCardinality.None, + contributesValuesToTarget: false, + canBeUsedAsValueInput: false, + hasTargetEffects: true, + hasOpaqueExternalWork: false, + inputs: null, + new TargetCommandRenderFragmentPayload(description, []), + queryBounds.Contains)); + } + + private RenderFragmentReference Stamp(RenderFragmentReference reference) + { + long id = ++_nextId; + reference.Id = new RenderFragmentId(_requestId, id); + if (reference.ValueCardinality.Maximum != 0 || reference.ValueCardinality.Minimum != 0) + reference.ValueIds = [new RenderValueId(_requestId, id)]; + return reference; + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderRequestModelTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderRequestModelTests.cs new file mode 100644 index 0000000000..0d42243ced --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderRequestModelTests.cs @@ -0,0 +1,256 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class RenderRequestModelTests +{ + [Test] + public void Options_SanitizeScalesSnapshotCacheAndValidateRegions() + { + var cache = new RenderCacheOptions(true, new RenderCacheRules(400, 4)); + var requestedRegion = new Rect(17, 19, 0, 23); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 100, 80), + requestedRegion: requestedRegion, + outputScale: float.NaN, + maxWorkingScale: 0, + cachePolicy: cache); + + Assert.Multiple(() => + { + Assert.That(options.OutputScale, Is.EqualTo(1)); + Assert.That(options.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + Assert.That(options.RequestedRegion, Is.EqualTo(requestedRegion)); + Assert.That(options.CachePolicy, Is.Not.SameAs(cache)); + Assert.That(options.CachePolicy, Is.EqualTo(cache)); + Assert.That( + () => new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: Rect.Empty), + Throws.TypeOf()); + Assert.That( + () => new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + requestedRegion: new Rect(0, 0, -1, 2)), + Throws.TypeOf()); + }); + } + + [Test] + public void NestedOptions_InheritSharedPolicyOwnerDiagnosticsAndFusionMode() + { + using var owner = new RenderRequestOwner(); + + using var binding = new NestedRenderTargetBinding(); + var parent = new RenderRequestOptions( + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + outputScale: 2, + maxWorkingScale: 3, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Disabled, + owner: owner); + RenderRequestOptions nested = parent.CreateNested(binding); + + Assert.Multiple(() => + { + Assert.That(nested.Intent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That(nested.Purpose, Is.EqualTo(RenderRequestPurpose.Frame)); + Assert.That(nested.OutputScale, Is.EqualTo(2)); + Assert.That(nested.MaxWorkingScale, Is.EqualTo(3)); + Assert.That(nested.CachePolicy, Is.EqualTo(RenderCacheOptions.Disabled)); + Assert.That(nested.FusionMode, Is.EqualTo(FusionMode.Disabled)); + Assert.That(nested.Owner, Is.SameAs(owner)); + Assert.That(nested.TargetBinding, Is.SameAs(binding)); + Assert.That(nested.PlanIdentity, Is.EqualTo(parent.PlanIdentity)); + }); + } + + [Test] + public void NestedOptions_AllowAnExplicitConcreteTargetScaleWithoutPolicyDrift() + { + using var owner = new RenderRequestOwner(); + var parentOptions = new RenderRequestOptions( + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + outputScale: 1.75f, + maxWorkingScale: 0.75f, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Disabled, + owner: owner); + using var parent = new RenderRequest(parentOptions); + using var binding = new NestedRenderTargetBinding(); + RenderRequestOptions nestedOptions = parentOptions.CreateNestedAtScale(binding, 0.5f); + using var nested = new RenderRequest(nestedOptions, parent); + + Assert.Multiple(() => + { + Assert.That(nestedOptions.OutputScale, Is.EqualTo(0.5f)); + Assert.That(nestedOptions.MaxWorkingScale, Is.EqualTo(0.5f)); + Assert.That(nestedOptions.Owner, Is.SameAs(owner)); + Assert.That( + () => parentOptions.CreateNestedAtScale(binding, float.PositiveInfinity), + Throws.TypeOf()); + }); + } + + [Test] + public void NestedOptions_DoNotInheritParentRequestedRegionWithoutAnExplicitMapping() + { + var parentRegion = new Rect(10, 20, 30, 40); + var mappedChildRegion = new Rect(1, 2, 3, 4); + var parent = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + requestedRegion: parentRegion); + using var binding = new NestedRenderTargetBinding(); + + RenderRequestOptions implicitScale = parent.CreateNested(binding); + RenderRequestOptions explicitScale = parent.CreateNestedAtScale(binding, 0.5f); + RenderRequestOptions mapped = parent.CreateNested(binding, requestedRegion: mappedChildRegion); + + Assert.Multiple(() => + { + Assert.That(implicitScale.RequestedRegion, Is.Null); + Assert.That(explicitScale.RequestedRegion, Is.Null); + Assert.That(mapped.RequestedRegion, Is.EqualTo(mappedChildRegion)); + }); + } + + + [Test] + public void FusionMode_ParticipatesInPlanIdentityWithoutBecomingPublicRendererPolicy() + { + var enabled = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + fusionMode: FusionMode.Enabled); + var disabled = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + fusionMode: FusionMode.Disabled); + + Assert.That(enabled.PlanIdentity, Is.Not.EqualTo(disabled.PlanIdentity)); + enabled.Owner.Dispose(); + disabled.Owner.Dispose(); + } + + [Test] + public void Request_EnforcesLifecycleAndMetadataOnlyShortcut() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + owner: owner); + using var request = new RenderRequest(options); + + request.TransitionTo(RenderRequestState.Recording); + request.TransitionTo(RenderRequestState.Recorded); + request.TransitionTo(RenderRequestState.TargetDependenciesLowered); + request.TransitionTo(RenderRequestState.MetadataResolved); + request.TransitionTo(RenderRequestState.RegionsResolved); + request.TransitionTo(RenderRequestState.CachesResolved); + request.TransitionTo(RenderRequestState.Planned); + request.TransitionTo(RenderRequestState.Executing); + request.TransitionTo(RenderRequestState.Completed); + + Assert.Multiple(() => + { + Assert.That(request.State, Is.EqualTo(RenderRequestState.Completed)); + Assert.That(request.Id.Value, Is.GreaterThan(0)); + Assert.That( + () => request.TransitionTo(RenderRequestState.Executing), + Throws.TypeOf()); + }); + + var queryOptions = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Bounds, + owner: owner); + using var query = new RenderRequest(queryOptions); + query.TransitionTo(RenderRequestState.Recording); + query.TransitionTo(RenderRequestState.Recorded); + query.TransitionTo(RenderRequestState.TargetDependenciesLowered); + query.TransitionTo(RenderRequestState.MetadataResolved); + query.CompleteMetadataOnly(); + + Assert.That(query.State, Is.EqualTo(RenderRequestState.Completed)); + } + + [Test] + public void Failure_PreservesTheFirstFailureAndAllowsOnlyDisposalAfterward() + { + var primary = new InvalidOperationException("primary"); + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + owner: owner); + using var request = new RenderRequest(options); + request.TransitionTo(RenderRequestState.Recording); + + request.Fail(primary); + + Assert.Multiple(() => + { + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + Assert.That( + () => request.TransitionTo(RenderRequestState.Recorded), + Throws.TypeOf()); + }); + } + + [Test] + public void GraphBuilder_IssuesUniqueIdsAndPreservesAuthoredOrder() + { + var requestId = new RenderRequestId(42); + var builder = new RecordedRenderGraphBuilder(requestId); + RenderProvenanceId provenance = builder.AddProvenance("root", "renderer-entry"); + RenderValueId source = builder.AddValue([], provenance, payload: "source"); + RenderValueId mapped = builder.AddValue([source], provenance, payload: "map"); + RenderFragmentId first = builder.AddFragment([source], provenance, payload: "first"); + RenderFragmentId second = builder.AddFragment([mapped], provenance, payload: "second"); + builder.PublishRoot(second); + RenderCacheCandidateId candidate = builder.AddCacheCandidate(first, "candidate-key"); + + RecordedRenderGraph graph = builder.Build(); + + Assert.Multiple(() => + { + Assert.That(graph.RequestId, Is.EqualTo(requestId)); + Assert.That(source, Is.Not.EqualTo(mapped)); + Assert.That(first, Is.Not.EqualTo(second)); + Assert.That(graph.Fragments.Select(static item => item.AuthoredOrder), Is.EqualTo(new[] { 0, 1 })); + Assert.That(graph.Fragments.Select(static item => item.Id), Is.EqualTo(new[] { first, second })); + Assert.That(graph.Values[1].Inputs, Is.EqualTo(new[] { source })); + Assert.That(graph.PublicationRoots, Is.EqualTo(new[] { second })); + Assert.That(graph.Provenance.Single().Origin, Is.EqualTo("root")); + Assert.That(graph.CacheCandidates.Single().Id, Is.EqualTo(candidate)); + Assert.That(graph.CacheCandidates.Single().FragmentId, Is.EqualTo(first)); + Assert.That(() => builder.AddFragment([], provenance), Throws.TypeOf()); + }); + } + + [Test] + public void GraphBuilder_RejectsIdsFromAnotherRequest() + { + var first = new RecordedRenderGraphBuilder(new RenderRequestId(1)); + var second = new RecordedRenderGraphBuilder(new RenderRequestId(2)); + RenderProvenanceId firstProvenance = first.AddProvenance("one", "root"); + RenderProvenanceId secondProvenance = second.AddProvenance("two", "root"); + RenderValueId foreign = first.AddValue([], firstProvenance); + + Assert.That( + () => second.AddValue([foreign], secondProvenance), + Throws.TypeOf()); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs new file mode 100644 index 0000000000..e4b20b7363 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RenderTargetPoolTests.cs @@ -0,0 +1,1136 @@ +using System.Runtime.ExceptionServices; + +using Beutl.Graphics; +using Beutl.Graphics.Backend; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class RenderTargetPoolTests +{ + [Test] + public void Acquisition_DefinesNewAndReusedTargetsAsTransparent() + { + var factory = new TrackingTargetFactory( + create: static (size, _) => + { + var target = new TrackingRenderTarget(size.Width, size.Height); + target.Value.Canvas.Clear(SKColors.Magenta); + return target; + }); + using var pool = new RenderTargetPool(factory); + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 3)); + AssertTargetIsTransparent(lease.Target); + lease.Target.Value.Canvas.Clear(SKColors.Cyan); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 3)); + Assert.Multiple(() => + { + Assert.That(lease.WasReused, Is.True); + AssertTargetIsTransparent(lease.Target); + }); + } + } + + [Test] + public void PooledEffectTargetClone_HoldsTheLeaseUntilTheLastReferenceIsDisposed() + { + using var registry = new RenderTargetLeaseRegistry(new TrackingTargetFactory()); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Delivery); + using RenderTarget sourceTarget = RenderTarget.CreateNull(4, 4); + using var source = new EffectTarget(sourceTarget, new Rect(0, 0, 4, 4)); + RenderTargetLease lease = session.Acquire(new PixelSize(4, 4)); + EffectTarget pooled = source.CreateReplacement(lease); + EffectTarget clone = pooled.Clone(); + + Assert.That(clone.RenderTarget, Is.Not.SameAs(pooled.RenderTarget)); + + pooled.Dispose(); + Assert.Multiple(() => + { + Assert.That(lease.IsReleased, Is.False); + Assert.That(registry.Statistics.LeasedTargets, Is.EqualTo(1)); + }); + + clone.Dispose(); + Assert.Multiple(() => + { + Assert.That(lease.IsReleased, Is.True); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + Assert.That(registry.Statistics.AvailableTargets, Is.EqualTo(1)); + }); + } + + [Test] + public void DeferredLease_RemainsUnavailableUntilTheGpuReclaimBoundary() + { + using var pool = new RenderTargetPool(new TrackingTargetFactory()); + RenderTargetPoolRequest request = pool.BeginRequest(); + PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 4)); + + lease.DeferRelease(); + request.Dispose(); + + Assert.Multiple(() => + { + Assert.That(lease.State, Is.EqualTo(PooledRenderTargetLeaseState.Deferred)); + Assert.That(pool.Statistics.LeasedTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.AvailableTargets, Is.Zero); + }); + + lease.CompleteDeferredRelease(); + + Assert.Multiple(() => + { + Assert.That(lease.State, Is.EqualTo(PooledRenderTargetLeaseState.Available)); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.AvailableTargets, Is.EqualTo(1)); + }); + } + + [Test] + public void StableExactSize_WarmsOnce_WhileChangingSizeMisses() + { + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool(factory); + TrackingRenderTarget firstTarget; + long firstGeneration; + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + PooledRenderTargetLease lease = request.Acquire(new PixelSize(8, 6)); + firstTarget = (TrackingRenderTarget)lease.Target; + firstGeneration = lease.Generation; + Assert.That(lease.WasReused, Is.False); + lease.Dispose(); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + PooledRenderTargetLease lease = request.Acquire(new PixelSize(8, 6)); + Assert.Multiple(() => + { + Assert.That(lease.Target, Is.SameAs(firstTarget)); + Assert.That(lease.Generation, Is.GreaterThan(firstGeneration)); + Assert.That(lease.WasReused, Is.True); + }); + lease.Dispose(); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + PooledRenderTargetLease lease = request.Acquire(new PixelSize(9, 6)); + Assert.That(lease.WasReused, Is.False); + lease.Dispose(); + } + + RenderTargetPoolStatistics statistics = pool.Statistics; + Assert.Multiple(() => + { + Assert.That(statistics.Creates, Is.EqualTo(2)); + Assert.That(statistics.Misses, Is.EqualTo(2)); + Assert.That(statistics.Reuses, Is.EqualTo(1)); + Assert.That(statistics.AvailableTargets, Is.EqualTo(2)); + }); + } + + [Test] + public void ByteCap_EvictsTheLeastRecentlyReleasedTarget() + { + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool( + factory, + new RenderTargetPoolOptions + { + MaximumRetainedBytes = 80, + MaximumIdleRequests = int.MaxValue, + }); + PooledRenderTargetLease firstLease; + PooledRenderTargetLease secondLease; + PooledRenderTargetLease thirdLease; + TrackingRenderTarget firstTarget; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + firstLease = request.Acquire(new PixelSize(2, 2)); // 32 bytes + secondLease = request.Acquire(new PixelSize(3, 2)); // 48 bytes + thirdLease = request.Acquire(new PixelSize(1, 1)); // 8 bytes + firstTarget = (TrackingRenderTarget)firstLease.Target; + firstLease.Dispose(); + secondLease.Dispose(); + thirdLease.Dispose(); + } + + Assert.Multiple(() => + { + Assert.That(firstLease.State, Is.EqualTo(PooledRenderTargetLeaseState.Evicted)); + Assert.That(firstTarget.IsDisposed, Is.True); + Assert.That(secondLease.State, Is.EqualTo(PooledRenderTargetLeaseState.Available)); + Assert.That(thirdLease.State, Is.EqualTo(PooledRenderTargetLeaseState.Available)); + Assert.That(pool.Statistics.RetainedBytes, Is.EqualTo(56)); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + }); + } + + [Test] + public void IdleLimit_EvictsOnlyAfterTheConfiguredNumberOfRequests() + { + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool( + factory, + new RenderTargetPoolOptions + { + MaximumRetainedBytes = long.MaxValue, + MaximumIdleRequests = 1, + }); + PooledRenderTargetLease oldLease; + TrackingRenderTarget oldTarget; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + oldLease = request.Acquire(new PixelSize(2, 2)); + oldTarget = (TrackingRenderTarget)oldLease.Target; + oldLease.Dispose(); + } + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + request.Acquire(new PixelSize(3, 3)).Dispose(); + + Assert.That(oldTarget.IsDisposed, Is.False); + using (pool.BeginRequest()) + { + Assert.Multiple(() => + { + Assert.That(oldLease.State, Is.EqualTo(PooledRenderTargetLeaseState.Evicted)); + Assert.That(oldTarget.IsDisposed, Is.True); + }); + } + } + + [Test] + public void PreviewAllocationPressure_ReclaimsRetainedTargets_AndKeepsRenderingTheFrame() + { + var factory = new BudgetedTargetFactory(budgetBytes: 640); + using var registry = new RenderTargetLeaseRegistry(factory); + using (RenderTargetLeaseSession warmup = registry.BeginSession( + RenderIntent.Preview)) + { + warmup.Acquire(new PixelSize(4, 4)).Dispose(); + warmup.Acquire(new PixelSize(2, 2)).Dispose(); + } + + Assert.That(registry.Statistics.RetainedBytes, Is.EqualTo(160)); + + using RenderTargetLeaseSession frame = registry.BeginSession( + RenderIntent.Preview); + RenderTargetLease pressured = frame.Acquire(new PixelSize(8, 8)); + RenderTargetLease rest = frame.Acquire(new PixelSize(4, 4)); + + Assert.Multiple(() => + { + Assert.That(pressured.Target.Width, Is.EqualTo(8)); + Assert.That(rest.Target.Width, Is.EqualTo(4)); + Assert.That(factory.DeclinedRequests, Is.EqualTo(1)); + Assert.That(registry.Statistics.RetainedBytes, Is.Zero); + Assert.That(registry.Statistics.Evictions, Is.EqualTo(2)); + }); + } + + [Test] + public void DeclinedAllocation_DegradesForPreview_AndFailsFastForDelivery() + { + using var registry = new RenderTargetLeaseRegistry(new SizeRejectingTargetFactory(rejectedWidth: 9)); + + using (RenderTargetLeaseSession preview = registry.BeginSession( + RenderIntent.Preview)) + { + Assert.That(preview.TryAcquire(new PixelSize(9, 9)), Is.Null); + using RenderTargetLease rest = preview.Acquire(new PixelSize(4, 4)); + Assert.That(rest.Target.Width, Is.EqualTo(4)); + } + + using RenderTargetLeaseSession delivery = registry.BeginSession( + RenderIntent.Delivery); + Assert.Multiple(() => + { + Assert.That( + () => delivery.TryAcquire(new PixelSize(9, 9)), + Throws.InvalidOperationException.With.Message.Contains("could not allocate 9x9 pixels")); + Assert.DoesNotThrow(() => delivery.Acquire(new PixelSize(4, 4)).Dispose()); + }); + } + + [Test] + public void IdleReclamation_ReleasesRetainedTargetsWithoutARequest_AndKeepsLeasedOnes() + { + var factory = new TrackingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + TrackingRenderTarget idleTarget; + using (RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Preview)) + { + RenderTargetLease lease = session.Acquire(new PixelSize(4, 4)); + idleTarget = (TrackingRenderTarget)lease.Target; + lease.Dispose(); + } + + Assert.That(registry.Statistics.RetainedBytes, Is.EqualTo(4 * 4 * 8)); + + long releasedBytes = registry.ReleaseRetainedTargets(); + + Assert.Multiple(() => + { + Assert.That(releasedBytes, Is.EqualTo(4 * 4 * 8)); + Assert.That(idleTarget.IsDisposed, Is.True); + Assert.That(idleTarget.DisposeCalls, Is.EqualTo(1)); + Assert.That(registry.Statistics.RetainedBytes, Is.Zero); + Assert.That(registry.Statistics.OwnedTargets, Is.Zero); + }); + + using RenderTargetLeaseSession active = registry.BeginSession( + RenderIntent.Preview); + using RenderTargetLease leased = active.Acquire(new PixelSize(2, 2)); + var leasedTarget = (TrackingRenderTarget)leased.Target; + + Assert.Multiple(() => + { + Assert.That(registry.ReleaseRetainedTargets(), Is.Zero); + Assert.That(leasedTarget.IsDisposed, Is.False); + Assert.That(registry.Statistics.LeasedTargets, Is.EqualTo(1)); + }); + } + + [Test] + public void Reuse_IncrementsGeneration_AndOldOrDoubleReleaseFails() + { + using var pool = new RenderTargetPool(new TrackingTargetFactory()); + PooledRenderTargetLease first; + RenderTarget firstTarget; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + first = request.Acquire(new PixelSize(4, 4)); + firstTarget = first.Target; + first.Dispose(); + } + + using RenderTargetPoolRequest secondRequest = pool.BeginRequest(); + PooledRenderTargetLease second = secondRequest.Acquire(new PixelSize(4, 4)); + + Assert.Multiple(() => + { + Assert.That(second.Target, Is.SameAs(firstTarget)); + Assert.That(second.Generation, Is.GreaterThan(first.Generation)); + Assert.That( + () => first.Dispose(), + Throws.InvalidOperationException.With.Message.Contains("already been discharged")); + }); + + second.Dispose(); + Assert.That( + () => second.Dispose(), + Throws.InvalidOperationException.With.Message.Contains("already been discharged")); + } + + [Test] + public void SessionDisposalFailure_EndsBothSessionAndPoolRequest() + { + var factory = new TrackingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Preview); + RenderTargetLease lease = session.Acquire(new PixelSize(4, 4)); + var staleTarget = (TrackingRenderTarget)lease.Target; + lease.PooledLease.Slot.Generation++; + + Assert.That( + session.Dispose, + Throws.InvalidOperationException.With.Message.Contains("generation is stale")); + + Assert.Multiple(() => + { + Assert.That(staleTarget.IsDisposed, Is.True); + Assert.That(staleTarget.DisposeCalls, Is.EqualTo(1)); + Assert.That(registry.Statistics.OwnedTargets, Is.Zero); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + Assert.That(registry.Statistics.OwnedBytes, Is.Zero); + Assert.That(registry.Statistics.Evictions, Is.EqualTo(1)); + }); + Assert.DoesNotThrow(() => registry.BeginSession( + RenderIntent.Preview).Dispose()); + } + + [Test] + public void CleanupFailureCheckpoint_TracksSessionAndRequestFailuresIndependently() + { + using var registry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Preview); + var priorSessionFailure = new InvalidOperationException("prior-session"); + var priorRequestFailure = new InvalidOperationException("prior-request"); + var nextSessionFailure = new InvalidOperationException("next-session"); + var nextRequestFailure = new InvalidOperationException("next-request"); + session.RecordCleanupFailure(priorSessionFailure); + session.Request.RecordCleanupFailure(priorRequestFailure); + RenderTargetCleanupFailureCheckpoint checkpoint = session.CaptureCleanupFailureCheckpoint(); + + session.RecordCleanupFailure(nextSessionFailure); + session.Request.RecordCleanupFailure(nextRequestFailure); + + Assert.That( + session.GetCleanupFailuresSince(checkpoint), + Is.EqualTo(new[] { nextSessionFailure, nextRequestFailure })); + } + + [Test] + public void RegistryDisposal_PreservesSessionAndPoolFailures() + { + var poolFailure = new InvalidOperationException("pool-target-cleanup"); + var factory = new TrackingTargetFactory( + (size, _) => new TrackingRenderTarget( + size.Width, + size.Height, + disposeFailure: size.Width == 3 ? poolFailure : null)); + var registry = new RenderTargetLeaseRegistry(factory); + RenderTargetLeaseSession session = registry.BeginSession( + RenderIntent.Preview); + RenderTargetLease stale = session.Acquire(new PixelSize(4, 4)); + RenderTargetLease available = session.Acquire(new PixelSize(3, 3)); + available.Dispose(); + stale.PooledLease.Slot.Generation++; + + AggregateException? failure = Assert.Throws(registry.Dispose); + + Assert.Multiple(() => + { + Assert.That( + failure!.InnerExceptions.Select(static exception => exception.Message), + Is.EquivalentTo(new[] { "The render-target lease generation is stale.", poolFailure.Message })); + Assert.That( + factory.Created.Cast().Select(static target => target.IsDisposed), + Is.All.True); + Assert.That( + factory.Created.Cast().Select(static target => target.DisposeCalls), + Is.All.EqualTo(1)); + Assert.That(registry.Statistics.OwnedTargets, Is.Zero); + Assert.That(registry.Statistics.LeasedTargets, Is.Zero); + }); + Assert.DoesNotThrow(registry.Dispose); + } + + [Test] + public void RequestDisposalFailure_EvictsTheFailedLeaseAndContinuesCleanup() + { + var cleanup = new InvalidOperationException("stale-target-cleanup"); + var factory = new TrackingTargetFactory( + (size, _) => new TrackingRenderTarget( + size.Width, + size.Height, + disposeFailure: size.Width == 4 ? cleanup : null)); + using var pool = new RenderTargetPool(factory); + RenderTargetPoolRequest request = pool.BeginRequest(); + PooledRenderTargetLease releasable = request.Acquire(new PixelSize(3, 3)); + PooledRenderTargetLease stale = request.Acquire(new PixelSize(4, 4)); + var staleTarget = (TrackingRenderTarget)stale.Target; + stale.Slot.Generation++; + + Assert.That( + request.Dispose, + Throws.InvalidOperationException.With.Message.Contains("generation is stale")); + + Assert.Multiple(() => + { + Assert.That(stale.State, Is.EqualTo(PooledRenderTargetLeaseState.Evicted)); + Assert.That(releasable.State, Is.EqualTo(PooledRenderTargetLeaseState.Available)); + Assert.That(staleTarget.IsDisposed, Is.True); + Assert.That(staleTarget.DisposeCalls, Is.EqualTo(1)); + Assert.That(request.CleanupFailures, Is.EqualTo(new[] { cleanup })); + Assert.That(pool.Statistics.OwnedTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.AvailableTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.OwnedBytes, Is.EqualTo(3 * 3 * 8)); + Assert.That(pool.Statistics.RetainedBytes, Is.EqualTo(3 * 3 * 8)); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + }); + Assert.DoesNotThrow(() => pool.BeginRequest().Dispose()); + } + + [Test] + public void PoolDisposal_ContinuesAfterActiveRequestFailure() + { + var factory = new TrackingTargetFactory(); + var pool = new RenderTargetPool(factory); + using (RenderTargetPoolRequest warmup = pool.BeginRequest()) + warmup.Acquire(new PixelSize(3, 3)).Dispose(); + RenderTargetPoolRequest active = pool.BeginRequest(); + PooledRenderTargetLease stale = active.Acquire(new PixelSize(4, 4)); + stale.Slot.Generation++; + + Assert.That( + pool.Dispose, + Throws.InvalidOperationException.With.Message.Contains("generation is stale")); + + Assert.Multiple(() => + { + Assert.That(factory.Created.Cast().Select(static target => target.IsDisposed), + Is.All.True); + Assert.That(factory.Created.Cast().Select(static target => target.DisposeCalls), + Is.All.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.AvailableTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.OwnedBytes, Is.Zero); + Assert.That(pool.Statistics.RetainedBytes, Is.Zero); + }); + Assert.DoesNotThrow(() => pool.Dispose()); + } + + [Test] + public void PoolDisposal_AggregatesActiveRequestAndTargetCleanupFailures() + { + var targetCleanup = new InvalidOperationException("available-target-cleanup"); + var factory = new TrackingTargetFactory( + (size, _) => new TrackingRenderTarget( + size.Width, + size.Height, + disposeFailure: size.Width == 3 ? targetCleanup : null)); + var pool = new RenderTargetPool(factory); + using (RenderTargetPoolRequest warmup = pool.BeginRequest()) + warmup.Acquire(new PixelSize(3, 3)).Dispose(); + RenderTargetPoolRequest active = pool.BeginRequest(); + PooledRenderTargetLease stale = active.Acquire(new PixelSize(4, 4)); + stale.Slot.Generation++; + + AggregateException? failure = Assert.Throws(pool.Dispose); + + Assert.Multiple(() => + { + Assert.That( + failure!.Flatten().InnerExceptions.Select(static exception => exception.Message), + Is.EquivalentTo(new[] { "The render-target lease generation is stale.", targetCleanup.Message })); + Assert.That( + factory.Created.Cast().Select(static target => target.DisposeCalls), + Is.All.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + }); + Assert.DoesNotThrow(pool.Dispose); + } + + [TestCase((int)RenderTargetPoolRegistrationStage.OwnedSlot)] + [TestCase((int)RenderTargetPoolRegistrationStage.KnownTarget)] + [TestCase((int)RenderTargetPoolRegistrationStage.KnownSurface)] + public void FreshTargetRegistrationFailure_RollsBackEveryBookkeepingStageAndAllowsRetry( + int failureStageValue) + { + var failureStage = (RenderTargetPoolRegistrationStage)failureStageValue; + var primary = new InvalidOperationException($"target-registration-{failureStage}"); + bool failNextRegistration = true; + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool( + factory, + new RenderTargetPoolOptions + { + AfterTargetRegistrationStep = stage => + { + if (failNextRegistration && stage == failureStage) + { + failNextRegistration = false; + throw primary; + } + }, + }); + using RenderTargetPoolRequest request = pool.BeginRequest(); + + InvalidOperationException? failure = Assert.Throws( + () => request.Acquire(new PixelSize(4, 4))); + var rejected = (TrackingRenderTarget)factory.Created.Single(); + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(rejected.IsDisposed, Is.True); + Assert.That(rejected.DisposeCalls, Is.EqualTo(1)); + Assert.That(pool.Statistics.Creates, Is.Zero); + Assert.That(pool.Statistics.Misses, Is.EqualTo(1)); + Assert.That(pool.Statistics.Evictions, Is.Zero); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.AvailableTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.OwnedBytes, Is.Zero); + Assert.That(pool.Statistics.RetainedBytes, Is.Zero); + Assert.That(pool.Statistics.PeakLiveTargets, Is.Zero); + }); + + using PooledRenderTargetLease retry = request.Acquire(new PixelSize(4, 4)); + Assert.Multiple(() => + { + Assert.That(retry.Target, Is.Not.SameAs(rejected)); + Assert.That(pool.Statistics.Creates, Is.EqualTo(1)); + Assert.That(pool.Statistics.Misses, Is.EqualTo(2)); + Assert.That(pool.Statistics.OwnedTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.LeasedTargets, Is.EqualTo(1)); + }); + } + + [Test] + public void FreshLeaseRegistrationFailure_EvictsTheSlotAndAllowsRetry() + { + var primary = new InvalidOperationException("lease-registration-failure"); + var cleanup = new InvalidOperationException("lease-registration-cleanup"); + bool failNextRegistration = true; + int leasedTargetsAtFailure = -1; + RenderTargetPool? observedPool = null; + var factory = new TrackingTargetFactory( + (size, index) => new TrackingRenderTarget( + size.Width, + size.Height, + disposeFailure: index == 0 ? cleanup : null)); + using var pool = new RenderTargetPool( + factory, + new RenderTargetPoolOptions + { + BeforeLeaseRegistration = () => + { + if (failNextRegistration) + { + failNextRegistration = false; + leasedTargetsAtFailure = observedPool!.Statistics.LeasedTargets; + throw primary; + } + }, + }); + observedPool = pool; + using RenderTargetPoolRequest request = pool.BeginRequest(); + + InvalidOperationException? failure = Assert.Throws( + () => request.Acquire(new PixelSize(4, 4))); + TrackingRenderTarget rejected = (TrackingRenderTarget)factory.Created.Single(); + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(leasedTargetsAtFailure, Is.EqualTo(1)); + Assert.That(rejected.IsDisposed, Is.True); + Assert.That(rejected.DisposeCalls, Is.EqualTo(1)); + Assert.That(request.CleanupFailures, Is.EqualTo(new[] { cleanup })); + Assert.That(pool.Statistics.Creates, Is.EqualTo(1)); + Assert.That(pool.Statistics.Misses, Is.EqualTo(1)); + Assert.That(pool.Statistics.Reuses, Is.Zero); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.AvailableTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.OwnedBytes, Is.Zero); + Assert.That(pool.Statistics.RetainedBytes, Is.Zero); + Assert.That(pool.Statistics.PeakLiveTargets, Is.EqualTo(1)); + }); + + using PooledRenderTargetLease retry = request.Acquire(new PixelSize(4, 4)); + Assert.Multiple(() => + { + Assert.That(retry.Target, Is.Not.SameAs(rejected)); + Assert.That(pool.Statistics.Creates, Is.EqualTo(2)); + Assert.That(pool.Statistics.Misses, Is.EqualTo(2)); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.LeasedTargets, Is.EqualTo(1)); + }); + } + + [Test] + public void ReusedLeaseRegistrationFailure_EvictsTheSlotAndAllowsRetry() + { + var primary = new InvalidOperationException("reused-lease-registration-failure"); + bool failNextRegistration = false; + int leasedTargetsAtFailure = -1; + RenderTargetPool? observedPool = null; + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool( + factory, + new RenderTargetPoolOptions + { + BeforeLeaseRegistration = () => + { + if (failNextRegistration) + { + failNextRegistration = false; + leasedTargetsAtFailure = observedPool!.Statistics.LeasedTargets; + throw primary; + } + }, + }); + observedPool = pool; + PooledRenderTargetLease available; + TrackingRenderTarget rejected; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + available = request.Acquire(new PixelSize(4, 4)); + rejected = (TrackingRenderTarget)available.Target; + available.Dispose(); + } + + failNextRegistration = true; + using RenderTargetPoolRequest retryRequest = pool.BeginRequest(); + InvalidOperationException? failure = Assert.Throws( + () => retryRequest.Acquire(new PixelSize(4, 4))); + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(leasedTargetsAtFailure, Is.EqualTo(1)); + Assert.That(rejected.IsDisposed, Is.True); + Assert.That(rejected.DisposeCalls, Is.EqualTo(1)); + Assert.That(pool.Statistics.Creates, Is.EqualTo(1)); + Assert.That(pool.Statistics.Misses, Is.EqualTo(1)); + Assert.That(pool.Statistics.Reuses, Is.EqualTo(1)); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.AvailableTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That(pool.Statistics.OwnedBytes, Is.Zero); + Assert.That(pool.Statistics.RetainedBytes, Is.Zero); + Assert.That(pool.Statistics.PeakLiveTargets, Is.EqualTo(1)); + }); + + using PooledRenderTargetLease retry = retryRequest.Acquire(new PixelSize(4, 4)); + Assert.Multiple(() => + { + Assert.That(retry.Target, Is.Not.SameAs(rejected)); + Assert.That(pool.Statistics.Creates, Is.EqualTo(2)); + Assert.That(pool.Statistics.Misses, Is.EqualTo(2)); + Assert.That(pool.Statistics.Reuses, Is.EqualTo(1)); + Assert.That(pool.Statistics.Evictions, Is.EqualTo(1)); + Assert.That(pool.Statistics.OwnedTargets, Is.EqualTo(1)); + Assert.That(pool.Statistics.LeasedTargets, Is.EqualTo(1)); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void DeferredGpuDraw_PreservesSnapshotAcrossSameSlotReuse() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var pool = new RenderTargetPool(factory: null); + using RenderTargetPoolRequest request = pool.BeginRequest(); + PooledRenderTargetLease source = request.Acquire(new PixelSize(4, 4)); + using PooledRenderTargetLease destination = request.Acquire(new PixelSize(4, 4)); + RenderTarget releasedTarget = source.Target; + releasedTarget.Value.Canvas.Clear(SKColors.Red); + destination.Target.Value.Canvas.Clear(SKColors.Transparent); + using var canvas = ImmediateCanvas.CreateExecutorManaged( + destination.Target, + density: 1f, + maxWorkingScale: float.PositiveInfinity, + logicalSize: new Size(4, 4), + intent: RenderIntent.Preview); + var observedFlushes = new List(); + + using (ImmediateCanvas.ObserveFlushes(observedFlushes.Add)) + { + canvas.DrawRenderTargetPixelsWithoutFlush(releasedTarget, 0, 0); + source.Dispose(); + using PooledRenderTargetLease reused = request.Acquire(new PixelSize(4, 4)); + Assert.That(reused.Target, Is.SameAs(releasedTarget)); + reused.Target.Value.Canvas.Clear(SKColors.Blue); + Assert.That(observedFlushes, Is.Empty, + "Recording the draw and reusing its source slot must not add an executor-managed flush."); + + using Bitmap snapshot = destination.Target.Snapshot(); + ReadOnlySpan pixels = snapshot.GetPixelSpan(); + float red = (float)BitConverter.UInt16BitsToHalf(pixels[0]); + float blue = (float)BitConverter.UInt16BitsToHalf(pixels[2]); + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[3]); + Assert.Multiple(() => + { + Assert.That(red, Is.GreaterThan(0.99f)); + Assert.That(blue, Is.LessThan(0.01f)); + Assert.That(alpha, Is.GreaterThan(0.99f)); + }); + } + }); + } + + [Test] + public void DischargedLease_RejectsTargetAndDeviceSizeAccess() + { + using var pool = new RenderTargetPool(new TrackingTargetFactory()); + using RenderTargetPoolRequest request = pool.BeginRequest(); + PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 4)); + lease.Dispose(); + + Assert.Multiple(() => + { + Assert.That( + () => _ = lease.Target, + Throws.InvalidOperationException.With.Message.Contains("already been discharged")); + Assert.That( + () => _ = lease.DeviceSize, + Throws.InvalidOperationException.With.Message.Contains("already been discharged")); + }); + } + + [Test] + public void ContextRecreation_EvictsOldBucketsBeforeAllocation() + { + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool(factory); + object firstContext = new(); + object secondContext = new(); + PooledRenderTargetLease firstLease; + TrackingRenderTarget firstTarget; + using (RenderTargetPoolRequest request = pool.BeginRequestForContext(firstContext, 0)) + { + firstLease = request.Acquire(new PixelSize(5, 5)); + firstTarget = (TrackingRenderTarget)firstLease.Target; + firstLease.Dispose(); + } + + using RenderTargetPoolRequest secondRequest = pool.BeginRequestForContext(secondContext, 0); + PooledRenderTargetLease secondLease = secondRequest.Acquire(new PixelSize(5, 5)); + + Assert.Multiple(() => + { + Assert.That(firstLease.State, Is.EqualTo(PooledRenderTargetLeaseState.Evicted)); + Assert.That(firstTarget.IsDisposed, Is.True); + Assert.That(secondLease.Target, Is.Not.SameAs(firstTarget)); + Assert.That(pool.Statistics.Creates, Is.EqualTo(2)); + }); + } + + [Test] + public void BoundCpuContext_IsForwardedToSubsequentTargetlessFactoryMiss() + { + var factory = new TrackingTargetFactory(); + using var pool = new RenderTargetPool(factory); + + using (RenderTargetPoolRequest request = pool.BeginRequestForContext(new object(), 0)) + request.Acquire(new PixelSize(2, 2)).Dispose(); + using (RenderTargetPoolRequest request = pool.BeginRequest()) + request.Acquire(new PixelSize(3, 3)).Dispose(); + + Assert.That(factory.Allocations, Has.Count.EqualTo(2)); + Assert.That(factory.Allocations, Has.All.Matches(allocation => + allocation.PixelFormat == RenderTargetPixelFormat.LinearPremultipliedRgba16Float + && allocation.GraphicsContext is null + && allocation.GraphicsContextHandle == 0 + && allocation.GraphicsBackend is null)); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void TargetlessGpuBinding_ForwardsLiveContextOnLaterMissAndRecreation() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using IGraphicsContext recreatedContext = GraphicsContextFactory.CreateContext(); + var factory = new DescriptorTargetFactory(); + using var pool = new RenderTargetPool(factory); + GRRecordingContext firstContext; + + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + using PooledRenderTargetLease lease = request.Acquire(new PixelSize(2, 2)); + firstContext = lease.Target.Value.Context + ?? throw new AssertionException("The first target-less allocation must bind a GPU context."); + } + + factory.ExpectedContext = firstContext; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + request.Acquire(new PixelSize(3, 3)).Dispose(); + + factory.ExpectedContext = recreatedContext.SkiaContext; + using (RenderTargetPoolRequest request = pool.BeginRequestForContext( + recreatedContext.SkiaContext, + recreatedContext.SkiaContext.Handle)) + { + request.Acquire(new PixelSize(4, 4)).Dispose(); + } + + Assert.Multiple(() => + { + Assert.That(factory.Observations, Has.Count.EqualTo(3)); + Assert.That(factory.Observations, Has.All.Matches(observation => + observation.PixelFormat == RenderTargetPixelFormat.LinearPremultipliedRgba16Float + && observation.ContextMatchedExpectation)); + Assert.That(factory.Observations[0].HasGraphicsContext, Is.False); + Assert.That(factory.Observations[0].GraphicsContextHandle, Is.Null); + Assert.That(factory.Observations[0].GraphicsBackend, Is.Null); + Assert.That(factory.Observations[1].HasGraphicsContext, Is.True); + Assert.That(factory.Observations[1].GraphicsContextHandle, Is.EqualTo(firstContext.Handle)); + Assert.That(factory.Observations[1].GraphicsBackend, Is.EqualTo(firstContext.Backend)); + Assert.That(factory.Observations[2].HasGraphicsContext, Is.True); + Assert.That(factory.Observations[2].GraphicsContextHandle, + Is.EqualTo(recreatedContext.SkiaContext.Handle)); + Assert.That(factory.Observations[2].GraphicsBackend, + Is.EqualTo(recreatedContext.SkiaContext.Backend)); + }); + }); + } + + [Test] + public void FactoryTarget_MustMatchSizeAndRgba16fContract() + { + var wrongSizeFactory = new TrackingTargetFactory( + (_, _) => new TrackingRenderTarget(2, 2)); + using (var pool = new RenderTargetPool(wrongSizeFactory)) + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + Assert.That( + () => request.Acquire(new PixelSize(3, 3)), + Throws.InvalidOperationException.With.Message.Contains("exact device size")); + Assert.That(wrongSizeFactory.Created.Single().IsDisposed, Is.True); + } + + var wrongFormatFactory = new TrackingTargetFactory( + (size, _) => new TrackingRenderTarget(size.Width, size.Height, SKColorType.Rgba8888)); + using (var pool = new RenderTargetPool(wrongFormatFactory)) + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + Assert.That( + () => request.Acquire(new PixelSize(3, 3)), + Throws.InvalidOperationException.With.Message.Contains("RGBA16F")); + Assert.That(wrongFormatFactory.Created.Single().IsDisposed, Is.True); + } + } + + [Test] + public void FactoryCannotReturnBorrowedDestination_AndPoolDoesNotDisposeIt() + { + using var external = new TrackingRenderTarget(4, 4); + var factory = new TrackingTargetFactory((_, _) => external); + using var pool = new RenderTargetPool(factory); + using RenderTargetPoolRequest request = pool.BeginRequest(external); + + Assert.Multiple(() => + { + Assert.That( + () => request.Acquire(new PixelSize(4, 4)), + Throws.InvalidOperationException.With.Message.Contains("borrowed destination")); + Assert.That(external.IsDisposed, Is.False); + Assert.That(external.DisposeCalls, Is.Zero); + }); + } + + [Test] + public void FactoryCannotReturnAnAlreadyLeasedTarget() + { + TrackingRenderTarget? shared = null; + var factory = new TrackingTargetFactory( + (size, _) => shared ??= new TrackingRenderTarget(size.Width, size.Height)); + using var pool = new RenderTargetPool(factory); + using RenderTargetPoolRequest request = pool.BeginRequest(); + PooledRenderTargetLease first = request.Acquire(new PixelSize(4, 4)); + + Assert.Multiple(() => + { + Assert.That( + () => request.Acquire(new PixelSize(5, 4)), + Throws.InvalidOperationException.With.Message.Contains("already owned")); + Assert.That(first.Target.IsDisposed, Is.False); + Assert.That(first.State, Is.EqualTo(PooledRenderTargetLeaseState.Leased)); + }); + } + + [Test] + public void AcceptedCacheTransfer_RemovesTargetFromPoolOwnershipExactlyOnce() + { + using var pool = new RenderTargetPool(new TrackingTargetFactory()); + TrackingRenderTarget target; + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + PooledRenderTargetLease lease = request.Acquire(new PixelSize(4, 4)); + target = (TrackingRenderTarget)lease.TransferToAcceptedCache(); + Assert.Multiple(() => + { + Assert.That(lease.State, Is.EqualTo(PooledRenderTargetLeaseState.CacheTransferred)); + Assert.That(pool.Statistics.OwnedTargets, Is.Zero); + Assert.That(pool.Statistics.LeasedTargets, Is.Zero); + Assert.That( + () => lease.TransferToAcceptedCache(), + Throws.InvalidOperationException.With.Message.Contains("already been discharged")); + }); + } + + pool.Dispose(); + Assert.That(target.IsDisposed, Is.False); + target.Dispose(); + } + + [Test] + public void PoolDisposal_ContinuesAfterEveryTargetFailure() + { + var factory = new TrackingTargetFactory( + (size, index) => new TrackingRenderTarget( + size.Width, + size.Height, + disposeFailure: new InvalidOperationException($"dispose-{index}"))); + var pool = new RenderTargetPool(factory); + using (RenderTargetPoolRequest request = pool.BeginRequest()) + { + request.Acquire(new PixelSize(2, 2)).Dispose(); + request.Acquire(new PixelSize(3, 3)).Dispose(); + } + + AggregateException? failure = Assert.Throws(() => pool.Dispose()); + Assert.Multiple(() => + { + Assert.That( + failure!.InnerExceptions.Select(static exception => exception.Message), + Is.EquivalentTo(new[] { "dispose-0", "dispose-1" })); + Assert.That(factory.Created.Cast().Select(static target => target.DisposeCalls), + Is.All.EqualTo(1)); + }); + + Assert.DoesNotThrow(() => pool.Dispose()); + } + + private sealed class TrackingTargetFactory( + Func? create = null) : IRenderTargetFactory + { + public List Created { get; } = []; + + public List Allocations { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + Allocations.Add(allocation); + RenderTarget target = create?.Invoke(deviceSize, Created.Count) + ?? new TrackingRenderTarget(deviceSize.Width, deviceSize.Height); + Created.Add(target); + return target; + } + } + + private sealed class SizeRejectingTargetFactory(int rejectedWidth) : IRenderTargetFactory + { + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + => allocation.DeviceSize.Width == rejectedWidth + ? null + : new TrackingRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class BudgetedTargetFactory(long budgetBytes) : IRenderTargetFactory + { + private readonly List _live = []; + + public int DeclinedRequests { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + _live.RemoveAll(static target => target.IsDisposed); + long requested = (long)deviceSize.Width * deviceSize.Height * 8; + long live = _live.Sum(static target => (long)target.Width * target.Height * 8); + if (live + requested > budgetBytes) + { + DeclinedRequests++; + return null; + } + + var created = new TrackingRenderTarget(deviceSize.Width, deviceSize.Height); + _live.Add(created); + return created; + } + } + + private sealed class DescriptorTargetFactory : IRenderTargetFactory + { + public GRRecordingContext? ExpectedContext { get; set; } + + public List Observations { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + Observations.Add(new AllocationObservation( + allocation.PixelFormat, + allocation.GraphicsContext is not null, + allocation.GraphicsContextHandle, + allocation.GraphicsBackend, + ReferenceEquals(allocation.GraphicsContext, ExpectedContext))); + PixelSize size = allocation.DeviceSize; + if (allocation.GraphicsContext is null) + return RenderTarget.Create(size.Width, size.Height); + + SKSurface? surface = SKSurface.Create( + allocation.GraphicsContext, + false, + new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())); + return surface is null ? null : new TrackingRenderTarget(surface, size.Width, size.Height); + } + } + + private readonly record struct AllocationObservation( + RenderTargetPixelFormat PixelFormat, + bool HasGraphicsContext, + nint? GraphicsContextHandle, + GRBackend? GraphicsBackend, + bool ContextMatchedExpectation); + + private sealed class TrackingRenderTarget : RenderTarget + { + private readonly Exception? _disposeFailure; + + public TrackingRenderTarget(SKSurface surface, int width, int height) + : base(surface, width, height) + { + } + + public TrackingRenderTarget( + int width, + int height, + SKColorType colorType = SKColorType.RgbaF16, + Exception? disposeFailure = null) + : base( + SKSurface.Create(new SKImageInfo( + width, + height, + colorType, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height) + { + _disposeFailure = disposeFailure; + } + + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + bool fail = disposing && !IsDisposed && _disposeFailure is not null; + if (disposing && !IsDisposed) + DisposeCalls++; + base.Dispose(disposing); + if (fail) + throw _disposeFailure!; + } + } + + private static unsafe void AssertTargetIsTransparent(RenderTarget target) + { + using Bitmap snapshot = target.Snapshot(); + for (int y = 0; y < snapshot.Height; y++) + { + var row = new ReadOnlySpan( + (byte*)snapshot.Data + (long)y * snapshot.RowBytes, + snapshot.Width * 4); + Assert.That(row.ToArray(), Is.All.EqualTo((Half)0)); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs new file mode 100644 index 0000000000..9ebaf96de3 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs @@ -0,0 +1,1392 @@ +using System.Collections.Immutable; +using System.Reflection; +using System.Runtime.CompilerServices; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.Threading; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class RendererWideRecordingTests +{ + [Test] + [Category("GpuPassFusionGpu")] + public void ProductionFrameRenderer_PreservesPlanLifetimeAcrossFrames() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + using var renderer = new Renderer(8, 8, RenderIntent.Preview); + var frame = new CompositionFrame( + ImmutableArray.Empty, + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + + renderer.Render(frame); + renderer.Render(frame); + + Assert.Multiple(() => + { + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Hits, Is.EqualTo(1)); + }); + }); + } + + + /// + /// The GPU-gated test above self-skips without a Vulkan device, so this CPU-surface case is what keeps the + /// plan cache covered on every machine. + /// + [Test] + public void ProductionFrameRenderer_CompilesItsPlanOnceOnTheFirstFrame() + { + RenderThread.Dispatcher.Invoke(() => + { + using var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(8, 8)); + var frame = new CompositionFrame( + ImmutableArray.Empty, + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + + renderer.Render(frame); + + Assert.Multiple(() => + { + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Hits, Is.Zero); + }); + }); + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_DisposedFromOwnerThread_ReleasesSurfaceOnRenderThread() + { + var surface = new DisposalThreadProbeRenderTarget(8, 8); + var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface); + var frame = new CompositionFrame( + ImmutableArray.Empty, + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + RenderThread.Dispatcher.Invoke(() => renderer.Render(frame)); + + Assert.That(RenderThread.Dispatcher.CheckAccess(), Is.False, + "the fixture must dispose from the renderer owner's non-render thread"); + renderer.Dispose(); + + Assert.Multiple(() => + { + Assert.That(surface.DisposeCount, Is.EqualTo(1)); + Assert.That(surface.DisposedOnRenderThread, Is.True); + }); + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_DisposeAfterDispatcherShutdown_ReleasesOwnedResources() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + var state = new DispatcherDisposalState(dispatcher); + Renderer? renderer = null; + try + { + DispatcherDisposalProbeRenderTarget surface = dispatcher.Invoke( + () => new DispatcherDisposalProbeRenderTarget(state)); + renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface, + dispatcher: dispatcher); + + dispatcher.Shutdown(); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True); + + Assert.DoesNotThrow(renderer.Dispose); + Assert.Multiple(() => + { + Assert.That(state.DisposeCount, Is.EqualTo(1)); + Assert.That(state.DisposedAfterShutdown, Is.True); + }); + } + finally + { + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + renderer?.Dispose(); + } + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_ConcurrentDispose_ReleasesOwnedResourcesOnce() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + var disposalState = new DispatcherDisposalState(dispatcher); + var hookState = new ConcurrentDisposeState(); + ConcurrentDisposeProbeRenderer? renderer = null; + using var start = new ManualResetEventSlim(false); + try + { + DispatcherDisposalProbeRenderTarget surface = dispatcher.Invoke( + () => new DispatcherDisposalProbeRenderTarget(disposalState)); + renderer = new ConcurrentDisposeProbeRenderer(dispatcher, surface, hookState); + dispatcher.Shutdown(); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True); + + Task[] disposals = Enumerable.Range(0, 8) + .Select(_ => Task.Run(() => + { + start.Wait(TimeSpan.FromSeconds(5)); + renderer.Dispose(); + })) + .ToArray(); + start.Set(); + Assert.That(Task.WaitAll(disposals, TimeSpan.FromSeconds(5)), Is.True); + + Assert.Multiple(() => + { + Assert.That(hookState.CallCount, Is.EqualTo(1)); + Assert.That(disposalState.DisposeCount, Is.EqualTo(1)); + }); + } + finally + { + start.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + renderer?.Dispose(); + } + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_CacheApisAfterDispatcherShutdown_FailWithoutWaiting() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + Renderer? renderer = null; + try + { + var surface = dispatcher.Invoke(() => new DispatcherDisposalProbeRenderTarget( + new DispatcherDisposalState(dispatcher))); + renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface, + dispatcher: dispatcher); + + void AssertCacheApisAreRejected() + { + static void AssertRejected(Action operation) + { + Task task = Task.Run(operation); + Assert.That( + SpinWait.SpinUntil(() => task.IsCompleted, TimeSpan.FromSeconds(1)), + Is.True, + "a cache API waited on a dispatcher that can no longer accept work"); + Assert.That(task.Exception?.GetBaseException(), Is.TypeOf()); + } + + Assert.Multiple(() => + { + AssertRejected(renderer.ClearAllCaches); + AssertRejected(() => renderer.CacheOptions = RenderCacheOptions.Disabled); + AssertRejected(() => renderer.ReleaseRetainedRenderTargets()); + }); + } + + dispatcher.Invoke(() => + { + dispatcher.Shutdown(); + AssertCacheApisAreRejected(); + }); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True); + AssertCacheApisAreRejected(); + } + finally + { + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + renderer?.Dispose(); + } + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_FinalizerCompletesCleanupAcrossDispatcherShutdown() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + var state = new DispatcherDisposalState(dispatcher); + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + WeakReference? renderer = null; + bool dispatcherJoined; + try + { + renderer = AbandonRenderer(dispatcher, state); + dispatcher.Dispatch(() => + { + entered.Set(); + release.Wait(TimeSpan.FromSeconds(30)); + }, DispatchPriority.High); + Assert.That(entered.Wait(TimeSpan.FromSeconds(5)), Is.True); + + dispatcher.Shutdown(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + Assert.That(state.DisposeCount, Is.Zero); + } + finally + { + release.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcherJoined = dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + } + + GC.Collect(); + Assert.Multiple(() => + { + Assert.That(dispatcherJoined, Is.True); + Assert.That(renderer!.IsAlive, Is.False); + Assert.That(state.DisposeCount, Is.EqualTo(1)); + Assert.That(state.DisposedOnOwnerThread, Is.True); + }); + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_FinalizerReleasesOwnedResourcesOnRenderThread() + { + var state = new DisposalThreadState(); + WeakReference renderer = AbandonRenderer(state); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + RenderThread.Dispatcher.Invoke(static () => { }); + GC.Collect(); + + Assert.Multiple(() => + { + Assert.That(renderer.IsAlive, Is.False); + Assert.That(state.DisposeCount, Is.EqualTo(1)); + Assert.That(state.DisposedOnRenderThread, Is.True); + }); + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_FinalizerCallsDerivedHookInlineBeforeRenderThreadCleanup() + { + var state = new FinalizerHookState(); + WeakReference renderer = AbandonFinalizerHookProbe(state); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + bool hookCompletedBeforeRenderThreadBarrier = state.Completed; + RenderThread.Dispatcher.Invoke(static () => { }); + GC.Collect(); + + Assert.Multiple(() => + { + Assert.That(renderer.IsAlive, Is.False); + Assert.That(hookCompletedBeforeRenderThreadBarrier, Is.True); + Assert.That(state.CallCount, Is.EqualTo(1)); + Assert.That(state.CalledOnRenderThread, Is.False); + }); + } + + [Test] + [NonParallelizable] + public void ProductionFrameRenderer_FailedConstruction_FinalizerCleanupKeepsRenderThreadAlive() + { + Dispatcher dispatcher = RenderThread.Dispatcher; + Exception? unhandledException = null; + EventHandler handler = (_, args) => + { + Interlocked.CompareExchange(ref unhandledException, args.Exception, null); + args.Handled = true; + }; + dispatcher.UnhandledException += handler; + try + { + FailRendererConstructionAfterRenderResourcesAreCreated(); + FailRendererConstructionBeforeFrameRendererIsAssigned(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + dispatcher.Invoke(static () => { }); + } + finally + { + dispatcher.UnhandledException -= handler; + } + + Assert.That(Volatile.Read(ref unhandledException), Is.Null); + } + + [Test] + [NonParallelizable] + public void ClearAllCaches_QueuedBeforeDispose_DoesNotReplaceDisposedFrameRenderer() + { + var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: new CpuRenderTarget(8, 8)); + FieldInfo frameRendererField = typeof(Renderer).GetField( + "_frameRenderer", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var initialFrameRenderer = (RenderNodeRenderer)frameRendererField.GetValue(renderer)!; + var renderThreadBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRenderThread = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blockerCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var clearStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? clearFailure = null; + var clearThread = new Thread(() => + { + clearStarted.TrySetResult(); + try + { + renderer.ClearAllCaches(); + } + catch (Exception ex) + { + clearFailure = ex; + } + }) + { + IsBackground = true, + }; + bool renderThreadWasBlocked = false; + bool clearDidStart = false; + bool clearWasQueued = false; + bool blockerWasDrained; + bool clearCompleted; + bool renderThreadWasDrained = false; + try + { + RenderThread.Dispatcher.Dispatch( + () => + { + try + { + renderThreadBlocked.TrySetResult(); + releaseRenderThread.Task.GetAwaiter().GetResult(); + } + finally + { + blockerCompleted.TrySetResult(); + } + }, + DispatchPriority.High); + renderThreadWasBlocked = renderThreadBlocked.Task.Wait(TimeSpan.FromSeconds(5)); + + clearThread.Start(); + clearDidStart = clearStarted.Task.Wait(TimeSpan.FromSeconds(5)); + clearWasQueued = SpinWait.SpinUntil( + () => (clearThread.ThreadState & ThreadState.WaitSleepJoin) != 0, + TimeSpan.FromSeconds(5)); + + RenderThread.Dispatcher.Dispatch(renderer.Dispose, DispatchPriority.High); + } + finally + { + releaseRenderThread.TrySetResult(); + blockerWasDrained = blockerCompleted.Task.Wait(TimeSpan.FromSeconds(5)); + clearCompleted = (clearThread.ThreadState & ThreadState.Unstarted) != 0 + || clearThread.Join(TimeSpan.FromSeconds(5)); + if (blockerWasDrained) + { + renderThreadWasDrained = RenderThread.Dispatcher + .InvokeAsync(static () => { }) + .Wait(TimeSpan.FromSeconds(5)); + } + } + + Exception[] failures = clearFailure is AggregateException aggregate + ? [.. aggregate.Flatten().InnerExceptions] + : clearFailure is null ? [] : [clearFailure]; + + Assert.Multiple(() => + { + Assert.That(renderThreadWasBlocked, Is.True); + Assert.That(clearDidStart, Is.True); + Assert.That(clearWasQueued, Is.True); + Assert.That(blockerWasDrained, Is.True); + Assert.That(clearCompleted, Is.True); + Assert.That(renderThreadWasDrained, Is.True); + Assert.That(failures.Any(static ex => ex is ObjectDisposedException), Is.True); + Assert.That(frameRendererField.GetValue(renderer), Is.SameAs(initialFrameRenderer)); + Assert.That(initialFrameRenderer.IsDisposed, Is.True); + }); + } + + [Test] + [Category("GpuPassFusionGpu")] + public void ProductionFrameRenderer_ClearAllCachesColdResetsFrameCachesWithoutChangingPolicy() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var state = new RendererWideTreeState(1) { UseShaderProgram = true }; + var drawable = new RendererWideProbeDrawable(0, state); + using Drawable.Resource resource = + (Drawable.Resource)drawable.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(resource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + using var renderer = new Renderer(8, 8, RenderIntent.Preview) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + RenderCacheOptions expectedOptions = renderer.CacheOptions; + + renderer.Render(frame); + renderer.Render(frame); + + Assert.Multiple(() => + { + Assert.That(renderer.FrameStructuralPlanCacheStatistics.RetainedPlans, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Hits, Is.EqualTo(1)); + Assert.That(renderer.FrameProgramCacheStatistics.RetainedPrograms, Is.GreaterThan(0)); + Assert.That(renderer.FrameTargetPoolStatistics.RetainedBytes, Is.GreaterThan(0)); + }); + + renderer.ClearAllCaches(); + + Assert.Multiple(() => + { + Assert.That(renderer.CacheOptions, Is.SameAs(expectedOptions)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics, Is.EqualTo(default(StructuralPlanCacheStatistics))); + Assert.That(renderer.FrameProgramCacheStatistics, Is.EqualTo(default(ProgramCacheStatistics))); + Assert.That(renderer.FrameTargetPoolStatistics, Is.EqualTo(default(RenderTargetPoolStatistics))); + }); + + renderer.Render(frame); + + Assert.Multiple(() => + { + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Compilations, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Misses, Is.EqualTo(1)); + Assert.That(renderer.FrameStructuralPlanCacheStatistics.Hits, Is.Zero); + Assert.That(renderer.FrameProgramCacheStatistics.RetainedPrograms, Is.GreaterThan(0)); + Assert.That(renderer.FrameTargetPoolStatistics.RetainedBytes, Is.GreaterThan(0)); + }); + }); + } + + [Test] + [NonParallelizable] + [Category("GpuPassFusionGpu")] + public void CacheMutations_FromCallerThread_DisposeCachedTreesOnRenderThread() + { + VulkanTestEnvironment.EnsureAvailable(); + var state = new CacheMutationThreadState(); + var drawable = new CacheMutationThreadProbeDrawable(state); + using var resource = (Drawable.Resource)drawable.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(resource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + Renderer renderer = VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var result = new Renderer(8, 8, RenderIntent.Preview); + result.Render(frame); + return result; + }); + + try + { + Assert.That(RenderThread.Dispatcher.CheckAccess(), Is.False); + + renderer.ClearAllCaches(); + Assert.That(state.DisposedOnRenderThread, Is.EqualTo(new[] { true })); + + VulkanTestEnvironment.InvokeOnRenderThread(() => renderer.Render(frame)); + renderer.CacheOptions = RenderCacheOptions.Disabled; + + Assert.Multiple(() => + { + Assert.That(state.DisposedOnRenderThread, Is.EqualTo(new[] { true, true })); + Assert.That(renderer.CacheOptions, Is.EqualTo(RenderCacheOptions.Disabled)); + }); + } + finally + { + VulkanTestEnvironment.InvokeOnRenderThread(renderer.Dispose); + } + } + + [Test] + [NonParallelizable] + [Category("GpuPassFusionGpu")] + public void Detachment_FromCallerThread_DisposesCachedTreeOnRenderThread() + { + VulkanTestEnvironment.EnsureAvailable(); + var state = new CacheMutationThreadState(); + var root = new CacheMutationHierarchyRoot(); + var drawable = new CacheMutationThreadProbeDrawable(state); + root.Attach(drawable); + using var resource = (Drawable.Resource)drawable.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(resource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + Renderer renderer = VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var result = new Renderer(8, 8, RenderIntent.Preview); + result.Render(frame); + return result; + }); + + try + { + Assert.That(RenderThread.Dispatcher.CheckAccess(), Is.False); + + root.Detach(drawable); + VulkanTestEnvironment.InvokeOnRenderThread(static () => { }); + + Assert.That(state.DisposedOnRenderThread, Is.EqualTo(new[] { true })); + } + finally + { + VulkanTestEnvironment.InvokeOnRenderThread(renderer.Dispose); + } + } + + [Test] + [NonParallelizable] + public void DetachmentAfterDispatcherShutdown_DoesNotRetainRendererOrDrawable() + { + (Dispatcher dispatcher, WeakReference renderer, WeakReference drawable) = + AbandonDetachedRendererAfterShutdown(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.Multiple(() => + { + Assert.That(renderer.IsAlive, Is.False); + Assert.That(drawable.IsAlive, Is.False); + }); + GC.KeepAlive(dispatcher); + } + + [Test] + public void CompleteTarget_RecordsEveryOrderedRootBeforeAnyExecution() + { + bool[] recorded = new bool[3]; + var executed = new List(); + using var first = new DeferredProbeNode(0, recorded, executed); + using var second = new DeferredProbeNode(1, recorded, executed); + using var third = new DeferredProbeNode(2, recorded, executed); + using var completeTarget = new CompleteTargetRenderNode(first, [second, third]); + using var destination = new CpuRenderTarget(8, 8); + using var canvas = new ImmediateCanvas(destination); + using var renderer = new RenderNodeRenderer( + completeTarget, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 8, 8), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(recorded, Is.All.True); + Assert.That(executed, Is.EqualTo(new[] { 0, 1, 2 })); + }); + } + + [Test] + public void CompleteTarget_RecordsClearCommandsCaptureAndPainterOrderBeforeExecution() + { + bool[] recorded = new bool[4]; + var executed = new List(); + using var clear = new RecordingRootNode(0, recorded, new ClearRenderNode(Colors.Transparent)); + using var source = new OrderedSourceNode(1, recorded, executed); + using var command = new OrderedTargetCommandNode(2, recorded, executed); + using var capture = new OrderedCaptureNode(3, recorded, executed); + using var completeTarget = new CompleteTargetRenderNode(clear, [source, command, capture]); + using var destination = new CpuRenderTarget(8, 8); + using var canvas = new ImmediateCanvas(destination); + using var renderer = new RenderNodeRenderer( + completeTarget, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 8, 8), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(recorded, Is.All.True); + Assert.That(executed, Is.EqualTo(new[] { "source", "command", "capture" })); + }); + } + + + [Test] + public void ProductionRenderer_LazilyCachesBoundariesForCurrentFrame() + { + RenderThread.Dispatcher.Invoke(() => + { + var state = new RendererWideTreeState(2); + var first = new RendererWideProbeDrawable(0, state); + var second = new RendererWideProbeDrawable(1, state); + using Drawable.Resource firstResource = + (Drawable.Resource)first.ToResource(CompositionContext.Default); + using Drawable.Resource secondResource = + (Drawable.Resource)second.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(firstResource, secondResource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + using var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(8, 8)) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + var expectedBounds = new Rect(0, 0, 8, 8); + + renderer.Render(frame); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 1, 1 })); + + Assert.That(renderer.GetBoundary(first), Is.EqualTo(expectedBounds)); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 2, 1 })); + Assert.That(renderer.GetBoundary(first), Is.EqualTo(expectedBounds)); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 2, 1 }), + "A repeated single-drawable query must reuse the current-frame bounds."); + + Assert.That(renderer.GetBoundaries(0), Is.EqualTo(new[] { expectedBounds, expectedBounds })); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 2, 2 })); + Assert.That(renderer.GetBoundaries(0), Is.EqualTo(new[] { expectedBounds, expectedBounds })); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 2, 2 }), + "A repeated layer query must reuse every current-frame bound."); + + renderer.UpdateFrame(frame); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 2, 2 })); + Assert.That(renderer.GetBoundaries(0), Is.EqualTo(new[] { expectedBounds, expectedBounds })); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 3, 3 }), + "Updating the current frame must invalidate every lazy bound."); + + renderer.Render(frame); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 4, 4 })); + Assert.That(renderer.GetBoundary(first), Is.EqualTo(expectedBounds)); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 5, 4 }), + "A successful render must invalidate every lazy bound."); + Assert.That(renderer.GetBoundary(first), Is.EqualTo(expectedBounds)); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 5, 4 })); + + Assert.That(renderer.RecalculateBoundaries(0), Is.EqualTo(new[] { expectedBounds, expectedBounds })); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 6, 5 }), + "Forced recalculation must record every matching drawable even when one bound is cached."); + Assert.That(state.ExecutionOrder, Is.EqualTo(new[] { 0, 1, 0, 1 })); + + renderer.ClearAllCaches(); + Assert.Multiple(() => + { + Assert.That(renderer.GetBoundaries(0), Is.Empty); + Assert.That(renderer.GetBoundary(first), Is.Null); + Assert.That(state.RecordCalls, Is.EqualTo(new[] { 6, 5 }), + "Clearing caches must not measure disposed current-frame entries."); + }); + }); + } + + [Test] + public void ProductionRenderer_UsesQueryBoundsForAFullTargetDrawable() + { + RenderThread.Dispatcher.Invoke(() => + { + var group = new DrawableGroup(); + group.Children.Add(new RectShape + { + Width = { CurrentValue = 3 }, + Height = { CurrentValue = 2 }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + Transform = { CurrentValue = new TranslateTransform(2, 1) }, + Fill = { CurrentValue = Brushes.White }, + }); + using Drawable.Resource resource = + (Drawable.Resource)group.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(resource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + using var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(8, 8)) + { + CacheOptions = RenderCacheOptions.Disabled, + }; + + renderer.Render(frame); + + Assert.Multiple(() => + { + Assert.That(renderer.GetBoundary(group), Is.EqualTo(new Rect(2, 1, 3, 2))); + Assert.That(renderer.RecalculateBoundaries(0), Is.EqualTo(new[] { new Rect(2, 1, 3, 2) })); + }); + }); + } + + [Test] + public void BoundaryCollectionQueries_RequireRenderThreadAccess() + { + Renderer renderer = RenderThread.Dispatcher.Invoke(() => new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(8, 8))); + try + { + Assert.That(RenderThread.Dispatcher.CheckAccess(), Is.False); + Assert.Throws(() => renderer.GetBoundaries(0)); + Assert.Throws(() => renderer.RecalculateBoundaries(0)); + } + finally + { + RenderThread.Dispatcher.Invoke(renderer.Dispose); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference AbandonRenderer(DisposalThreadState state) + { + var surface = new DisposalThreadProbeRenderTarget(8, 8, state); + GC.SuppressFinalize(surface); + var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface); + return new WeakReference(renderer, trackResurrection: true); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference AbandonRenderer(Dispatcher dispatcher, DispatcherDisposalState state) + { + DispatcherDisposalProbeRenderTarget surface = dispatcher.Invoke( + () => new DispatcherDisposalProbeRenderTarget(state)); + GC.SuppressFinalize(surface); + var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface, + dispatcher: dispatcher); + return new WeakReference(renderer, trackResurrection: true); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (Dispatcher Dispatcher, WeakReference Renderer, WeakReference Drawable) + AbandonDetachedRendererAfterShutdown() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + var state = new CacheMutationThreadState(); + var root = new CacheMutationHierarchyRoot(); + var drawable = new CacheMutationThreadProbeDrawable(state); + root.Attach(drawable); + using var resource = (Drawable.Resource)drawable.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(resource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: new CpuRenderTarget(8, 8), + dispatcher: dispatcher); + dispatcher.Invoke(() => renderer.UpdateFrame(frame)); + dispatcher.Shutdown(); + if (!dispatcher.Thread.Join(TimeSpan.FromSeconds(5))) + throw new TimeoutException("The test dispatcher did not stop."); + + root.Detach(drawable); + renderer.Dispose(); + return ( + dispatcher, + new WeakReference(renderer), + new WeakReference(drawable)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference AbandonFinalizerHookProbe(FinalizerHookState state) + { + var renderer = new FinalizerHookProbeRenderer(state); + return new WeakReference(renderer, trackResurrection: true); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void FailRendererConstructionAfterRenderResourcesAreCreated() + { + var exception = Assert.Throws(() => new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: new CpuRenderTarget(7, 8))); + Assert.That(exception!.InnerException, Is.TypeOf()); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void FailRendererConstructionBeforeFrameRendererIsAssigned() + { + Assert.Throws(() => new Renderer( + width: 0, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1)); + } + + private sealed class RecordingRootNode( + int index, + bool[] recorded, + RenderNode child) : RenderNode + { + public override void Process(RenderNodeContext context) + { + recorded[index] = true; + context.PublishRange(context.RecordNode(child, [])); + } + + protected override void OnDispose(bool disposing) + { + child.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class OrderedSourceNode( + int index, + bool[] recorded, + ICollection executed) : RenderNode + { + public override void Process(RenderNodeContext context) + { + recorded[index] = true; + context.Publish(context.OpaqueSource(CreateDescription( + "source", + recorded, + executed, + static (session, output) => + output.Canvas.Use(canvas => canvas.Clear(new Color(255, 40, 80, 120)))))); + } + } + + private sealed class OrderedTargetCommandNode( + int index, + bool[] recorded, + ICollection executed) : RenderNode + { + public override void Process(RenderNodeContext context) + { + recorded[index] = true; + TargetCommandDescription description = TargetCommandDescription.CreateRequestLocal( + session => + { + Assert.That(recorded, Is.All.True); + executed.Add("command"); + session.Canvas.Use(canvas => canvas.Clear(new Color(255, 24, 48, 72))); + }, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None); + context.Publish(context.TargetCommand([], description)); + } + } + + private sealed class OrderedCaptureNode( + int index, + bool[] recorded, + ICollection executed) : RenderNode + { + public override void Process(RenderNodeContext context) + { + recorded[index] = true; + Rect bounds = new(0, 0, 8, 8); + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Full, + bounds, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + OpaqueRenderDescription replay = OpaqueRenderDescription.CreateRequestLocal( + session => + { + Assert.That(recorded, Is.All.True); + executed.Add("capture"); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs.Single().Draw); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderHitTestContract.AnyInput, + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply); + context.Publish(context.ContributeValues(context.OpaqueMap(capture, replay))); + } + } + + private static OpaqueRenderDescription CreateDescription( + string name, + bool[] recorded, + ICollection executed, + Action draw) + { + return OpaqueRenderDescription.CreateRequestLocal( + session => + { + Assert.That(recorded, Is.All.True); + executed.Add(name); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + draw(session, output); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + } + + private sealed class DeferredProbeNode( + int index, + bool[] recorded, + List executed) : RenderNode + { + public override void Process(RenderNodeContext context) + { + recorded[index] = true; + var description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + Assert.That(recorded, Is.All.True, + "No planner-controlled 2D callback may run until every target root is recorded."); + executed.Add(index); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(new Color(255, 32, 64, 96))); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CacheMutationHierarchyRoot : Hierarchical, IHierarchicalRoot + { + public event EventHandler? DescendantAttached; + + public event EventHandler? DescendantDetached; + + public void Attach(IHierarchical child) + { + ((IModifiableHierarchical)this).AddChild(child); + } + + public void Detach(IHierarchical child) + { + ((IModifiableHierarchical)this).RemoveChild(child); + } + + public void OnDescendantAttached(IHierarchical descendant) + { + DescendantAttached?.Invoke(this, descendant); + } + + public void OnDescendantDetached(IHierarchical descendant) + { + DescendantDetached?.Invoke(this, descendant); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private sealed class DispatcherDisposalProbeRenderTarget(DispatcherDisposalState state) + : RenderTarget(SKSurface.CreateNull(8, 8), 8, 8) + { + protected override void Dispose(bool disposing) + { + if (disposing && !IsDisposed) + { + state.Record(); + } + + base.Dispose(disposing); + } + } + + private sealed class DispatcherDisposalState(Dispatcher dispatcher) + { + private int _disposeCount; + private int _disposedAfterShutdown; + private int _disposedOnOwnerThread; + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public bool DisposedAfterShutdown => Volatile.Read(ref _disposedAfterShutdown) != 0; + + public bool DisposedOnOwnerThread => Volatile.Read(ref _disposedOnOwnerThread) != 0; + + public void Record() + { + if (dispatcher.HasShutdownFinished) + Volatile.Write(ref _disposedAfterShutdown, 1); + if (dispatcher.CheckAccess()) + Volatile.Write(ref _disposedOnOwnerThread, 1); + Interlocked.Increment(ref _disposeCount); + } + } + + private sealed class ConcurrentDisposeProbeRenderer( + Dispatcher dispatcher, + RenderTarget surface, + ConcurrentDisposeState state) + : Renderer( + width: 8, + height: 8, + intent: RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: surface, + dispatcher: dispatcher) + { + protected override void OnDispose(bool disposing) + { + if (disposing) + state.Record(); + base.OnDispose(disposing); + } + } + + private sealed class ConcurrentDisposeState + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + public void Record() => Interlocked.Increment(ref _callCount); + } + + private sealed class DisposalThreadProbeRenderTarget( + int width, + int height, + DisposalThreadState? state = null) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height) + { + private readonly DisposalThreadState _state = state ?? new DisposalThreadState(); + + public int DisposeCount => _state.DisposeCount; + + public bool DisposedOnRenderThread => _state.DisposedOnRenderThread; + + protected override void Dispose(bool disposing) + { + if (disposing && !IsDisposed) + { + _state.DisposeCount++; + _state.DisposedOnRenderThread = RenderThread.Dispatcher.CheckAccess(); + } + + base.Dispose(disposing); + } + } + + private sealed class DisposalThreadState + { + public int DisposeCount { get; set; } + + public bool DisposedOnRenderThread { get; set; } + } + + private sealed class FinalizerHookProbeRenderer(FinalizerHookState state) + : Renderer( + width: 8, + height: 8, + intent: RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: 1, + surface: new CpuRenderTarget(8, 8)) + { + protected override void OnDispose(bool disposing) + { + if (!disposing) + state.Record(); + + base.OnDispose(disposing); + } + } + + private sealed class FinalizerHookState + { + private int _callCount; + private int _calledOnRenderThread; + private int _completed; + + public int CallCount => Volatile.Read(ref _callCount); + + public bool CalledOnRenderThread => Volatile.Read(ref _calledOnRenderThread) != 0; + + public bool Completed => Volatile.Read(ref _completed) != 0; + + public void Record() + { + if (RenderThread.Dispatcher.CheckAccess()) + Volatile.Write(ref _calledOnRenderThread, 1); + + Interlocked.Increment(ref _callCount); + Volatile.Write(ref _completed, 1); + } + } +} + +internal sealed class RendererWideTreeState(int count) +{ + public int[] BuildCalls { get; } = new int[count]; + + public int[] RecordCalls { get; } = new int[count]; + + public int[] FrameRecordCalls { get; } = new int[count]; + + public List ExecutionOrder { get; } = []; + + public ProductionTreeProbeNode?[] Nodes { get; } = new ProductionTreeProbeNode[count]; + + public bool UseShaderProgram { get; init; } +} + +// Top-level partial because EngineObjectResourceGenerator does not support nested types. +internal sealed partial class RendererWideProbeDrawable : Drawable +{ + private readonly int _index; + private readonly RendererWideTreeState _state; + + public RendererWideProbeDrawable(int index, RendererWideTreeState state) + { + _index = index; + _state = state; + } + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + _state.BuildCalls[_index]++; + var node = new ProductionTreeProbeNode(_index, _state); + node.Cache.RecordStableRequests(RenderNodeCache.StableRequestCount - 1); + _state.Nodes[_index] = node; + context.DrawNode(node); + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(8, 8); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} + +internal sealed class ProductionTreeProbeNode( + int index, + RendererWideTreeState state) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + int completedBuildCount = state.BuildCalls[index]; + Assert.That(completedBuildCount, Is.GreaterThan(0)); + Assert.That(state.BuildCalls, Is.All.EqualTo(completedBuildCount), + "Every drawable tree must be built before the complete request starts recording."); + state.RecordCalls[index]++; + if (context.Purpose == RenderRequestPurpose.Frame) + { + state.FrameRecordCalls[index]++; + } + + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + int completedFrameRecordCount = state.FrameRecordCalls[index]; + Assert.That(completedFrameRecordCount, Is.GreaterThan(0)); + Assert.That(state.FrameRecordCalls, Is.All.EqualTo(completedFrameRecordCount), + "Every top-level tree must be recorded before the first execution callback."); + state.ExecutionOrder.Add(index); + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear( + index == 0 + ? new Color(160, 96, 32, 16) + : new Color(160, 16, 64, 128))); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + RenderFragmentHandle source = context.OpaqueSource(description); + if (state.UseShaderProgram) + { + ShaderDescription shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return half4(color.b, color.g, color.r, color.a); }"); + context.Publish(context.Shader(source, shader)); + } + else + { + context.Publish(source); + } + } +} + +internal sealed class CacheMutationThreadState +{ + public List DisposedOnRenderThread { get; } = []; +} + +// Top-level partial because EngineObjectResourceGenerator does not support nested types. +internal sealed partial class CacheMutationThreadProbeDrawable : Drawable +{ + private readonly CacheMutationThreadState _state; + + public CacheMutationThreadProbeDrawable(CacheMutationThreadState state) + { + _state = state; + } + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + context.DrawNode(new CacheMutationThreadProbeNode(_state)); + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(8, 8); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} + +internal sealed class CacheMutationThreadProbeNode( + CacheMutationThreadState state) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(new Color(255, 32, 64, 96))); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(new Rect(0, 0, 8, 8)), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + state.DisposedOnRenderThread.Add(RenderThread.Dispatcher.CheckAccess()); + base.OnDispose(disposing); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ResourcePlanUseScheduleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ResourcePlanUseScheduleTests.cs new file mode 100644 index 0000000000..c43620415c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ResourcePlanUseScheduleTests.cs @@ -0,0 +1,80 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class ResourcePlanUseScheduleTests +{ + [Test] + public void SelectedCacheHit_PrunesProducerInputsFromRemainingUseCounts() + { + var requestId = new RenderRequestId(1); + RenderFragmentReference sharedSource = Fragment(RenderFragmentKind.OpaqueSource, []); + sharedSource.Id = new RenderFragmentId(requestId, 1); + RenderFragmentReference hitProducer = Fragment(RenderFragmentKind.Opacity, [sharedSource]); + hitProducer.Id = new RenderFragmentId(requestId, 2); + + ResourcePlanUseSchedule unpruned = ResourcePlanUseSchedule.Create( + [hitProducer, sharedSource]); + ResourcePlanUseSchedule pruned = ResourcePlanUseSchedule.Create( + [hitProducer, sharedSource], + new HashSet { hitProducer.Id.Value }); + + Assert.Multiple(() => + { + Assert.That( + unpruned.Lifetimes.Single(item => ReferenceEquals(item.Fragment, sharedSource)) + .ConsumerPositions, + Has.Length.EqualTo(2)); + Assert.That( + pruned.Lifetimes.Single(item => ReferenceEquals(item.Fragment, sharedSource)) + .ConsumerPositions, + Has.Length.EqualTo(1), + "The remaining authored root use must not be inflated by an input edge below a selected hit."); + Assert.That( + pruned.BeginExecution().GetRemainingUseCount(sharedSource), + Is.EqualTo(1)); + }); + } + + [Test] + public void SelectedCacheHit_PrunesExclusiveProducerSubtree() + { + var requestId = new RenderRequestId(1); + RenderFragmentReference source = Fragment(RenderFragmentKind.OpaqueSource, []); + source.Id = new RenderFragmentId(requestId, 1); + RenderFragmentReference hitProducer = Fragment(RenderFragmentKind.Opacity, [source]); + hitProducer.Id = new RenderFragmentId(requestId, 2); + + ResourcePlanUseSchedule schedule = ResourcePlanUseSchedule.Create( + [hitProducer], + new HashSet { hitProducer.Id.Value }); + + Assert.Multiple(() => + { + Assert.That(schedule.Lifetimes.Select(static item => item.Fragment), + Is.EqualTo(new[] { hitProducer })); + Assert.That(schedule.BeginExecution().GetRemainingUseCount(hitProducer), Is.EqualTo(1)); + Assert.That( + () => schedule.BeginExecution().GetRemainingUseCount(source), + Throws.InvalidOperationException); + }); + } + + private static RenderFragmentReference Fragment( + RenderFragmentKind kind, + IReadOnlyList inputs) + => new( + kind, + new Rect(0, 0, 16, 16), + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs, + payload: null, + hitTest: null); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicOwningDomainTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicOwningDomainTests.cs new file mode 100644 index 0000000000..68e2f9e0d1 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicOwningDomainTests.cs @@ -0,0 +1,726 @@ +using System.Reactive; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class SymbolicOwningDomainTests +{ + private static readonly Rect s_rootDomain = new(0, 0, 100, 60); + + [Test] + public void UnknownLegacy_UnderTranslation_ResolvesLocalDomainBeforeMappingToRoot() + { + var effect = new SymbolicDomainFilterEffect(); + using TransformRenderNode root = WrapInTranslation( + CreateFilter(effect, new Rect(-5, 10, 10, 10)), + 10); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + RenderFragmentReference transform = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.TargetScope); + + Assert.Multiple(() => + { + Assert.That(legacy.BoundsRequirement, Is.EqualTo(RenderFragmentBoundsRequirement.OwningTargetDomain)); + Assert.That(legacy.RecordedBounds, Is.EqualTo(new Rect(-5, 10, 10, 10))); + Assert.That(legacy.Bounds, Is.EqualTo(new Rect(-10, 0, 100, 60))); + Assert.That(transform.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(compiled.Regions.GetMetadata(legacy).Bounds, Is.EqualTo(legacy.Bounds)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + }); + } + + [Test] + public void UnknownLegacy_LargeResolvedDomain_RecomputesDownstreamScaleClamp() + { + var domain = new Rect(0, 0, 10_000, 100); + var effect = new SymbolicDomainFilterEffect(); + using TransformRenderNode root = WrapInTranslation( + CreateFilter(effect, new Rect(5, 6, 20, 12)), + 0); + + using CompiledRenderRequest compiled = Compile(root, domain, outputScale: 2); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + RenderFragmentReference transform = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.TargetScope); + float expected = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(domain, 2); + + Assert.Multiple(() => + { + Assert.That(legacy.RecordedEffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(legacy.EffectiveScale.Value, Is.EqualTo(expected).Within(1e-6f)); + Assert.That(transform.EffectiveScale.Value, Is.EqualTo(expected).Within(1e-6f)); + Assert.That(compiled.Regions.GetMetadata(transform).EffectiveScale, + Is.EqualTo(transform.EffectiveScale)); + }); + } + + [Test] + public void UnknownLegacy_NoOpUnderTranslation_PreservesPixelsFromLocalNegativeCoordinates() + { + var effect = new SymbolicDomainFilterEffect(); + using TransformRenderNode root = WrapInTranslation( + CreateFilter(effect, new Rect(-5, 10, 10, 10)), + 10); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(100, 60); + using var canvas = new ImmediateCanvas(target); + + renderer.Render(canvas); + using Bitmap bitmap = target.Snapshot(); + + Assert.Multiple(() => + { + Assert.That(effect.CallbackCount, Is.EqualTo(1)); + Assert.That(AlphaAt(bitmap, 6, 15), Is.GreaterThan(0.9f)); + }); + } + + [Test] + public void UnknownLegacy_UnderIntersectClip_ResolvesToClippedLocalDomain() + { + var clip = new Rect(20, 5, 30, 40); + var effect = new SymbolicDomainFilterEffect(); + using var root = new RectClipRenderNode(clip, ClipOperation.Intersect); + root.AddChild(CreateFilter(effect, new Rect(25, 10, 5, 5))); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + RenderFragmentReference clipped = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.TargetScope); + + Assert.Multiple(() => + { + Assert.That(legacy.Bounds, Is.EqualTo(clip)); + Assert.That(clipped.Bounds, Is.EqualTo(clip)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(clip)); + }); + } + + [Test] + public void UnknownLegacy_ExplicitTargetlessDomain_ResolvesDuringMetadata() + { + var domain = new Rect(-20, -10, 80, 50); + var effect = new SymbolicDomainFilterEffect(); + using FilterEffectRenderNode root = CreateFilter(effect, new Rect(5, 6, 20, 12)); + + using CompiledRenderRequest compiled = Compile(root, domain); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + + Assert.Multiple(() => + { + Assert.That(legacy.Bounds, Is.EqualTo(domain)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(effect.CallbackCount, Is.Zero); + }); + } + + [Test] + public void UnknownLegacy_RealDestinationSuppliesOwningDomain() + { + var effect = new SymbolicDomainFilterEffect(); + using FilterEffectRenderNode root = CreateFilter(effect, new Rect(5, 6, 20, 12)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(64, 48); + using var canvas = new ImmediateCanvas(target); + + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + Assert.That(effect.CallbackCount, Is.EqualTo(1)); + } + + [Test] + public void UnknownLegacy_WithoutOwningDomain_FailsBeforeRuntimeCallback() + { + var effect = new SymbolicDomainFilterEffect(); + using FilterEffectRenderNode root = CreateFilter(effect, new Rect(5, 6, 20, 12)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + InvalidOperationException? error = Assert.Throws(() => renderer.Measure()); + + Assert.Multiple(() => + { + Assert.That(error!.Message, Does.Contain("transformBounds").And.Contain("owning target domain")); + Assert.That(effect.CallbackCount, Is.Zero); + }); + } + + [Test] + public void UnknownLegacy_InsideFiniteLayer_UsesLayerDomainWithoutRootDomain() + { + var domain = new Rect(-20, 5, 40, 30); + var effect = new SymbolicDomainFilterEffect(); + using var root = new LayerRenderNode(domain); + root.AddChild(CreateFilter(effect, new Rect(-10, 10, 5, 5))); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + + Assert.Multiple(() => + { + Assert.That(legacy.Bounds, Is.EqualTo(domain)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(effect.CallbackCount, Is.Zero); + }); + } + + [Test] + public void FullTargetCommand_FilterUsesOwningDomainLayerAndRecomputesLegacyBounds() + { + var effect = new FiniteLegacyFilterEffect(); + using var root = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + root.AddChild(new ClearRenderNode(Colors.White)); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference layer = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.Layer); + RenderFragmentReference legacy = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + var layerPayload = (LayerRenderFragmentPayload)layer.Payload!; + + Assert.Multiple(() => + { + Assert.That(effect.ObservedInputBounds.IsInvalid, Is.True, + "A full target domain must remain symbolic while the filter records."); + Assert.That(layerPayload.Domain, Is.Null, + "The internal Layer must resolve from its owning target instead of freezing a root placeholder."); + Assert.That(layer.BoundsRequirement, + Is.EqualTo(RenderFragmentBoundsRequirement.OwningTargetDomain)); + Assert.That(layer.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(legacy.Bounds, Is.EqualTo(s_rootDomain.Inflate(new Thickness(2)))); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + }); + } + + [Test] + public void UnknownLegacy_FiniteLegacyParentRecomputesBoundsAndHitTestFromResolvedInput() + { + var unknown = new SymbolicDomainFilterEffect(); + var finite = new FiniteLegacyFilterEffect(); + using FilterEffectRenderNode root = CreateFilter(finite, unknown, new Rect(5, 6, 20, 12)); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference[] legacy = References(compiled.Graph).Values + .Where(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment) + .ToArray(); + RenderFragmentReference unknownFragment = legacy.Single(static reference => + reference.BoundsRequirement == RenderFragmentBoundsRequirement.OwningTargetDomain); + RenderFragmentReference finiteFragment = legacy.Single(static reference => + reference.BoundsRequirement == RenderFragmentBoundsRequirement.Finite); + Rect inflatedDomain = s_rootDomain.Inflate(new Thickness(2)); + + Assert.Multiple(() => + { + Assert.That(finite.ObservedInputBounds.IsInvalid, Is.True, + "Legacy authoring must not observe provisional finite bounds from a symbolic input."); + Assert.That(unknownFragment.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(finiteFragment.RecordedBounds, Is.EqualTo(new Rect(3, 4, 24, 16))); + Assert.That(finiteFragment.Bounds, Is.EqualTo(inflatedDomain)); + Assert.That(finiteFragment.HitTest(new Point(-1, 30)), Is.True); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + }); + } + + [Test] + public void UnknownLegacy_KeepsShaderAndGeometrySuffixInOneOpaqueSegment() + { + var effect = new SymbolicDomainFilterEffect { AppendTypedSuffix = true }; + using FilterEffectRenderNode root = CreateFilter(effect, new Rect(5, 6, 20, 12)); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference[] references = References(compiled.Graph).Values.ToArray(); + RenderFragmentReference legacy = references + .Single(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment); + var payload = (FilterEffectSegmentRenderFragmentPayload)legacy.Payload!; + IFEItem[] items = payload.Context.Registry.Use( + payload.Context, + static context => context.GetOrderedItems().ToArray()); + + Assert.Multiple(() => + { + Assert.That(references.Any(static reference => + reference.Kind is RenderFragmentKind.Shader or RenderFragmentKind.Geometry), Is.False); + Assert.That(items, Has.Length.EqualTo(3)); + Assert.That(items[0], Is.InstanceOf()); + Assert.That(items[1], Is.InstanceOf()); + Assert.That(items[2], Is.InstanceOf()); + Assert.That(legacy.Bounds, Is.EqualTo(s_rootDomain)); + }); + } + + [Test] + public void BuiltInBackdrop_DerivedShaderGeometryFanOut_UsesProducerDomain() + { + var producerDomain = new Rect(0, 0, 40, 30); + var secondConsumerDomain = new Rect(20, 0, 40, 30); + using var root = new BuiltInDerivedFanOutNode(producerDomain, secondConsumerDomain); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + RenderFragmentReference[] references = References(compiled.Graph).Values.ToArray(); + RenderFragmentReference capture = references.Single(static reference => + reference.Kind == RenderFragmentKind.BuiltInBackdropCapture); + RenderFragmentReference shader = references.Single(static reference => + reference.Kind == RenderFragmentKind.Shader); + RenderFragmentReference geometry = references.Single(static reference => + reference.Kind == RenderFragmentKind.Geometry); + RenderFragmentReference[] layers = references + .Where(static reference => reference.Kind == RenderFragmentKind.Layer) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(capture.Bounds, Is.EqualTo(producerDomain)); + Assert.That(shader.Bounds, Is.EqualTo(producerDomain)); + Assert.That(geometry.Bounds, Is.EqualTo(producerDomain)); + Assert.That(layers.Single(reference => + ((LayerRenderFragmentPayload)reference.Payload!).Domain == producerDomain).Bounds, + Is.EqualTo(producerDomain)); + Assert.That(layers.Single(reference => + ((LayerRenderFragmentPayload)reference.Payload!).Domain == secondConsumerDomain).Bounds, + Is.EqualTo(producerDomain.Intersect(secondConsumerDomain))); + }); + } + + [Test] + public void UnknownLegacy_DerivedFanOutAcrossDifferentDomains_IsRejected() + { + var effect = new SymbolicDomainFilterEffect(); + using var root = new UnknownLegacyDerivedFanOutNode( + effect, + new Rect(0, 0, 40, 30), + new Rect(20, 0, 40, 30)); + + InvalidOperationException? error = Assert.Throws(() => + { + using CompiledRenderRequest _ = Compile(root, targetDomain: null); + }); + + Assert.Multiple(() => + { + Assert.That(error!.Message, Does.Contain("two different owning target domains")); + Assert.That(effect.CallbackCount, Is.Zero); + }); + } + + [Test] + public void BuiltInBackdrop_InsideFiniteLayer_ResolvesLayerDomainWithoutRootDomain() + { + var domain = new Rect(12, 8, 40, 30); + using var root = new LayerRenderNode(domain); + root.AddChild(new SnapshotBackdropRenderNode()); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + RenderFragmentReference capture = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.BuiltInBackdropCapture); + TargetDependencyStep captureStep = compiled.TargetDependencies.Steps + .Single(static step => step.Kind == TargetDependencyKind.Capture); + TargetScopePlan captureScope = compiled.TargetDependencies.Scopes + .Single(scope => scope.Id == captureStep.ScopeId); + + Assert.Multiple(() => + { + Assert.That(capture.BoundsRequirement, Is.EqualTo(RenderFragmentBoundsRequirement.OwningTargetDomain)); + Assert.That(capture.Bounds, Is.EqualTo(domain)); + Assert.That(capture.EffectiveScale, Is.EqualTo(EffectiveScale.Unbounded)); + Assert.That(captureScope.ResolvedDomain, Is.EqualTo(domain)); + }); + } + + [Test] + public void BuiltInBackdrop_UnderTranslation_UsesCaptureProducerLocalCoordinates() + { + using TransformRenderNode root = WrapInTranslation(new SnapshotBackdropRenderNode(), 10); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference capture = References(compiled.Graph).Values + .Single(static reference => reference.Kind == RenderFragmentKind.BuiltInBackdropCapture); + TargetDependencyStep captureStep = compiled.TargetDependencies.Steps + .Single(static step => step.Kind == TargetDependencyKind.Capture); + TargetScopePlan captureScope = compiled.TargetDependencies.Scopes + .Single(scope => scope.Id == captureStep.ScopeId); + var localDomain = new Rect(-10, 0, 100, 60); + + Assert.Multiple(() => + { + Assert.That(captureScope.ResolvedDomain, Is.EqualTo(localDomain)); + Assert.That(capture.Bounds, Is.EqualTo(localDomain)); + Assert.That(compiled.Regions.GetMetadata(capture).Bounds, Is.EqualTo(localDomain)); + }); + } + + [Test] + public void BuiltInBackdrop_UnderTranslation_CapturesResolvedExtentAtRuntime() + { + var probe = new BuiltInCaptureProbeNode(); + using var root = new ContainerRenderNode(); + root.AddChild(new RectangleRenderNode(s_rootDomain, Brushes.Resource.White, null)); + root.AddChild(WrapInTranslation(probe, 10)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(100, 60); + using var canvas = new ImmediateCanvas(target); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(probe.CaptureCount, Is.EqualTo(1)); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(new PixelSize(100, 60))); + Assert.That(probe.CapturedDensity, Is.EqualTo(1)); + }); + } + + [Test] + public void BuiltInBackdrop_InsideFiniteLayer_CapturesLayerExtentAtRuntime() + { + var domain = new Rect(12, 8, 40, 30); + var probe = new BuiltInCaptureProbeNode(); + using var root = new LayerRenderNode(domain); + root.AddChild(new RectangleRenderNode(domain, Brushes.Resource.White, null)); + root.AddChild(probe); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using var target = new CpuRenderTarget(100, 60); + using var canvas = new ImmediateCanvas(target); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(probe.CaptureCount, Is.EqualTo(1)); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(new PixelSize(40, 30))); + Assert.That(probe.CapturedDensity, Is.EqualTo(1)); + }); + } + + [Test] + public void BuiltInBackdrop_ExternalTargetDensityMatchesPlannedDemandAndCapturedBitmap() + { + var domain = new Rect(0, 0, 8_192, 1); + const float density = 2; + var probe = new BuiltInCaptureProbeNode(); + using (CompiledRenderRequest compiled = Compile(probe, domain, outputScale: density)) + { + RenderFragmentReference capture = References(compiled.Graph).Values + .Single(static reference => + reference.Kind == RenderFragmentKind.BuiltInBackdropCapture); + Assert.That( + compiled.MaterializationDemands[capture], + Is.EqualTo(EffectiveScale.At(density))); + } + + using var renderer = new RenderNodeRenderer( + probe, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + OutputScale = density, + MaxWorkingScale = 4, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + PixelSize deviceSize = PixelRect.FromRect(domain, density).Size; + using var target = new CpuRenderTarget(deviceSize.Width, deviceSize.Height); + using var canvas = new ImmediateCanvas( + target, + density, + maxWorkingScale: 4, + domain.Size); + + renderer.Render(canvas); + + Assert.Multiple(() => + { + Assert.That(deviceSize.Width, Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(probe.CaptureCount, Is.EqualTo(1)); + Assert.That(probe.CapturedDeviceSize, Is.EqualTo(deviceSize)); + Assert.That(probe.CapturedDensity, Is.EqualTo(density)); + }); + } + + private static FilterEffectRenderNode CreateFilter( + SymbolicDomainFilterEffect effect, + Rect inputBounds) + { + var node = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + node.AddChild(new EllipseRenderNode(inputBounds, Brushes.Resource.White, null)); + return node; + } + + private static FilterEffectRenderNode CreateFilter( + FiniteLegacyFilterEffect outerEffect, + SymbolicDomainFilterEffect innerEffect, + Rect inputBounds) + { + var outer = new FilterEffectRenderNode(outerEffect.ToResource(CompositionContext.Default)); + outer.AddChild(CreateFilter(innerEffect, inputBounds)); + return outer; + } + + private static TransformRenderNode WrapInTranslation(RenderNode child, float x) + { + var result = new TransformRenderNode( + Matrix.CreateTranslation(x, 0), + TransformOperator.Prepend); + result.AddChild(child); + return result; + } + + private static CompiledRenderRequest Compile( + RenderNode root, + Rect? targetDomain, + float outputScale = 1) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain, + outputScale: outputScale, + cachePolicy: RenderCacheOptions.Disabled)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static IReadOnlyDictionary References( + RecordedRenderGraph graph) + => graph.Fragments.ToDictionary( + static fragment => fragment.Id, + static fragment => (RenderFragmentReference)fragment.Payload!); + + private static float AlphaAt(Bitmap bitmap, int x, int y) + { + Span row = bitmap.GetRow(y); + return (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private sealed class BuiltInCaptureProbeNode : RenderNode, IBuiltInBackdropCaptureSink + { + public int CaptureCount { get; private set; } + + public PixelSize CapturedDeviceSize { get; private set; } + + public float CapturedDensity { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.DisableRenderCache(); + context.Publish(context.BuiltInBackdropCapture(this)); + } + + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + { + CaptureCount++; + CapturedDeviceSize = new PixelSize(bitmap.Width, bitmap.Height); + CapturedDensity = density; + bitmap.Dispose(); + } + } + + private sealed class BuiltInDerivedFanOutNode( + Rect producerDomain, + Rect secondConsumerDomain) + : RenderNode, IBuiltInBackdropCaptureSink + { + private const string IdentityShader = "half4 apply(half4 color) { return color; }"; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.BuiltInBackdropCapture(this); + RenderFragmentHandle shader = context.Shader( + capture, + ShaderDescription.CurrentPixel(IdentityShader)); + RenderFragmentHandle geometry = context.Geometry( + shader, + GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + RenderFragmentHandle contributing = context.ContributeValues(geometry); + context.Publish(context.Layer([contributing], producerDomain)); + context.Publish(context.Layer([contributing], secondConsumerDomain)); + } + + void IBuiltInBackdropCaptureSink.CommitBackdropCapture(Bitmap bitmap, float density) + => bitmap.Dispose(); + } + + private sealed class UnknownLegacyDerivedFanOutNode : RenderNode + { + private const string IdentityShader = "half4 apply(half4 color) { return color; }"; + private readonly FilterEffectRenderNode _filter; + private readonly Rect _firstDomain; + private readonly Rect _secondDomain; + + public UnknownLegacyDerivedFanOutNode( + SymbolicDomainFilterEffect effect, + Rect firstDomain, + Rect secondDomain) + { + _filter = CreateFilter(effect, new Rect(5, 6, 20, 12)); + _firstDomain = firstDomain; + _secondDomain = secondDomain; + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle legacy = context.RecordSubtree(_filter).Single(); + RenderFragmentHandle derived = context.Shader( + legacy, + ShaderDescription.CurrentPixel(IdentityShader)); + context.Publish(context.Layer([derived], _firstDomain)); + context.Publish(context.Layer([derived], _secondDomain)); + } + + protected override void OnDispose(bool disposing) + { + _filter.Dispose(); + base.OnDispose(disposing); + } + } +} + +[SuppressResourceClassGeneration] +internal sealed partial class SymbolicDomainFilterEffect : FilterEffect +{ + private const string IdentityShader = "half4 apply(half4 color) { return color; }"; + + public int CallbackCount { get; private set; } + + public bool AppendTypedSuffix { get; init; } + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.CustomEffect(Unit.Default, (_, _) => CallbackCount++); + if (!AppendTypedSuffix) + return; + + context.Shader(ShaderDescription.CurrentPixel(IdentityShader)); + context.Geometry(GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} + +[SuppressResourceClassGeneration] +internal sealed partial class FiniteLegacyFilterEffect : FilterEffect +{ + public Rect ObservedInputBounds { get; private set; } + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + ObservedInputBounds = context.Bounds; + context.CustomEffect( + Unit.Default, + static (_, _) => { }, + static (_, bounds) => bounds.Inflate(new Thickness(2))); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicSupplyMappingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicSupplyMappingTests.cs new file mode 100644 index 0000000000..121345ac8e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicSupplyMappingTests.cs @@ -0,0 +1,73 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Transformation; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class SymbolicSupplyMappingTests +{ + [Test] + public void CustomTransform_MapsSupplyAfterSymbolicInputResolution() + { + var targetDomain = new Rect(0, 0, 10_000, 100); + var effect = new SymbolicDomainFilterEffect(); + var filter = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + filter.AddChild(new EllipseRenderNode( + new Rect(5, 6, 20, 12), + Brushes.Resource.White, + null)); + using Transform.Resource scale = new ScaleTransform(50, 50) + .ToResource(CompositionContext.Default); + using var root = ScaleRecordingTestHelper.SubtreePipeline( + filter, + new HalfInputSupplyRenderNode(), + new DrawableGroup.CustomTransformRenderNode( + scale, + default, + targetDomain.Size, + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(targetDomain))); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure( + root, + outputScale: 2, + targetDomain: targetDomain); + Rect inputDomain = targetDomain.TransformToAABB(scale.Matrix.Invert()); + float expected = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(inputDomain, 2); + + Assert.Multiple(() => + { + Assert.That(expected, Is.LessThan(2), "The setup must resolve the symbolic input below its recorded scale."); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expected).Within(1e-6f)); + }); + } + + private sealed class HalfInputSupplyRenderNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.Inputs) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + execute: static _ => throw new AssertionException( + "Metadata analysis must not execute opaque callbacks."), + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MapInputSupplyPreservingDemand( + HalfSupply)); + context.Publish(context.OpaqueMap(input, description)); + } + } + + private static EffectiveScale HalfSupply(EffectiveScale input) + => input.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(input.Value / 2); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs new file mode 100644 index 0000000000..45bdb5501e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs @@ -0,0 +1,890 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Planning; + +[TestFixture] +public sealed class TargetScopeLoweringTests +{ + private static readonly Rect s_rootDomain = new(0, 0, 100, 60); + + [Test] + public void RootSequence_ThreadsA_Clear_BThroughOneTargetTokenChain() + { + using var root = new ContainerRenderNode(); + root.AddChild(new SourceNode(new Rect(0, 0, 20, 20), "root-a")); + root.AddChild(new ClearRenderNode(Colors.Transparent)); + root.AddChild(new SourceNode(new Rect(40, 10, 20, 20), "root-b")); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + TargetDependencyStep[] steps = compiled.TargetDependencies.Steps.ToArray(); + + Assert.Multiple(() => + { + Assert.That(steps.Select(static step => step.Kind), Is.EqualTo(new[] + { + TargetDependencyKind.Composite, + TargetDependencyKind.Command, + TargetDependencyKind.Composite, + })); + Assert.That(steps.Select(static step => step.ScopeId).Distinct().Count(), Is.EqualTo(1)); + Assert.That(steps[1].InputToken, Is.EqualTo(steps[0].OutputToken)); + Assert.That(steps[2].InputToken, Is.EqualTo(steps[1].OutputToken)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(new Rect(0, 0, 60, 30))); + }); + } + + [Test] + public void FiniteLayer_ThreadsA_Clear_BLocallyThenCompositesExactlyOnce() + { + var domain = new Rect(10, 20, 50, 30); + using var root = new ContainerRenderNode(); + var layer = new LayerRenderNode(domain); + layer.AddChild(new SourceNode(new Rect(12, 22, 5, 4), "layer-a")); + layer.AddChild(new ClearRenderNode(Colors.Transparent)); + layer.AddChild(new SourceNode(new Rect(30, 35, 6, 7), "layer-b")); + root.AddChild(layer); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + TargetScopePlan local = FindOwnedScope(compiled, RenderFragmentKind.Layer); + TargetDependencyStep[] localSteps = compiled.TargetDependencies.Steps + .Where(step => step.ScopeId == local.Id) + .ToArray(); + TargetDependencyStep outer = compiled.TargetDependencies.Steps.Single(step => + step.Kind == TargetDependencyKind.ScopeComposite); + + Assert.Multiple(() => + { + Assert.That(local.ResolvedDomain, Is.EqualTo(domain)); + Assert.That(local.IsOrderOnly, Is.False); + Assert.That(localSteps.Select(static step => step.Kind), Is.EqualTo(new[] + { + TargetDependencyKind.Composite, + TargetDependencyKind.Command, + TargetDependencyKind.Composite, + })); + Assert.That(localSteps[1].InputToken, Is.EqualTo(localSteps[0].OutputToken)); + Assert.That(localSteps[2].InputToken, Is.EqualTo(localSteps[1].OutputToken)); + Assert.That(outer.ScopeId, Is.Not.EqualTo(local.Id)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(new Rect(12, 22, 24, 20))); + }); + } + + [Test] + public void FiniteLayerFanOutAcrossTargetDomains_ExecutesWithOneResolvedOwningScope() + { + var layerDomain = new Rect(0, 0, 24, 16); + var secondTargetDomain = new Rect(8, 4, 40, 24); + using var root = new LayerFanOutNode(layerDomain, secondTargetDomain); + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + int owningScopeCount = compiled.TargetDependencies.Scopes.Count(scope => + scope.OwnerFragmentId is { } owner + && References(compiled.Graph)[owner].Kind == RenderFragmentKind.Layer); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(raster.Bitmap, Is.Not.Null); + Assert.That(owningScopeCount, Is.EqualTo(1)); + Assert.That(root.SourceExecutionCount, Is.EqualTo(1)); + Assert.That(AlphaAt(raster.Bitmap!, 4, 4), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void TransformedFullTargetLayer_ResolvesAgainstMappedCurrentTargetDomain() + { + using var root = new ContainerRenderNode(); + var transform = new TransformRenderNode( + Matrix.CreateTranslation(10, 0), + TransformOperator.Prepend); + var isolation = new LayerRenderNode(default); + isolation.AddChild(new ClearRenderNode(Colors.White)); + transform.AddChild(isolation); + root.AddChild(transform); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + TargetScopePlan transformed = FindOwnedScope(compiled, RenderFragmentKind.TargetScope); + TargetScopePlan isolated = FindOwnedScope(compiled, RenderFragmentKind.TargetLayerScope); + RenderFragmentReference transformedReference = References(compiled.Graph)[transformed.OwnerFragmentId!.Value]; + + Assert.Multiple(() => + { + Assert.That(transformed.ResolvedDomain, Is.EqualTo(new Rect(-10, 0, 100, 60))); + Assert.That(isolated.ResolvedDomain, Is.EqualTo(new Rect(-10, 0, 100, 60))); + Assert.That(isolated.ParentId, Is.EqualTo(transformed.Id)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(compiled.ExecutionTargetBounds, Is.EqualTo(s_rootDomain)); + Assert.That( + compiled.Regions.GetFragmentRequirement(transformedReference).Resolve(s_rootDomain), + Is.EqualTo(s_rootDomain)); + }); + + var factory = new CpuTargetFactory(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + using RenderNodeRasterization raster = renderer.Rasterize(); + Bitmap bitmap = raster.Bitmap!; + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(bitmap, Is.Not.Null); + Assert.That(AlphaAt(bitmap, 0, 30), Is.GreaterThan(0.99f), + "The inverse-mapped local Full must include the root's left edge."); + Assert.That(AlphaAt(bitmap, 99, 30), Is.GreaterThan(0.99f), + "The local Full must still include the root's right edge."); + }); + } + + [Test] + public void ClippedTransformedFullTargetLayer_ResolvesAgainstTheClippedLocalDomain() + { + var clipDomain = new Rect(20, 0, 30, 60); + using var root = new RectClipRenderNode(clipDomain, ClipOperation.Intersect); + var transform = new TransformRenderNode( + Matrix.CreateTranslation(10, 0), + TransformOperator.Prepend); + var isolation = new LayerRenderNode(default); + isolation.AddChild(new ClearRenderNode(Colors.White)); + transform.AddChild(isolation); + root.AddChild(transform); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + IReadOnlyDictionary references = References(compiled.Graph); + TargetScopePlan[] mappedScopes = compiled.TargetDependencies.Scopes + .Where(scope => scope.OwnerFragmentId is { } owner + && references[owner].Kind == RenderFragmentKind.TargetScope) + .ToArray(); + TargetScopePlan clipped = mappedScopes.Single(scope => + scope.ResolvedDomain == clipDomain); + TargetScopePlan transformed = mappedScopes.Single(scope => + scope.ParentId == clipped.Id); + TargetScopePlan isolated = FindOwnedScope(compiled, RenderFragmentKind.TargetLayerScope); + RenderFragmentReference transformedReference = references[transformed.OwnerFragmentId!.Value]; + var localDomain = new Rect(10, 0, 30, 60); + + Assert.Multiple(() => + { + Assert.That(transformed.ResolvedDomain, Is.EqualTo(localDomain)); + Assert.That(isolated.ResolvedDomain, Is.EqualTo(localDomain)); + Assert.That(isolated.ParentId, Is.EqualTo(transformed.Id)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(clipDomain)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(compiled.ExecutionTargetBounds, Is.EqualTo(clipDomain)); + Assert.That( + compiled.Regions.GetFragmentRequirement(transformedReference).Resolve(clipDomain), + Is.EqualTo(clipDomain)); + }); + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(clipDomain)); + Assert.That(AlphaAt(raster.Bitmap!, 0, 30), Is.GreaterThan(0.99f)); + Assert.That(AlphaAt(raster.Bitmap!, 29, 30), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void TransformedRootReadback_MapsItsLocalAccessIntoTheRootExecutionTarget() + { + var localAccess = new Rect(5, 7, 20, 11); + using var root = new ContainerRenderNode(); + root.AddChild(new OrderOnlyCommandNode()); + var transform = new TransformRenderNode( + Matrix.CreateTranslation(10, 0), + TransformOperator.Prepend); + transform.AddChild(new ReadbackCommandNode(localAccess)); + root.AddChild(transform); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference readback = References(compiled.Graph).Values.Single(reference => + reference.Payload is TargetCommandRenderFragmentPayload payload + && payload.Description.Access == TargetAccess.Readback); + + Assert.Multiple(() => + { + Assert.That(compiled.SelectedOutputBounds, Is.EqualTo(new Rect(15, 7, 20, 11))); + Assert.That( + compiled.Regions.GetTargetAccessRequirement(readback).Resolve(s_rootDomain), + Is.EqualTo(localAccess)); + Assert.That( + compiled.ExecutionTargetBounds, + Is.EqualTo(new Rect(15, 7, 20, 11))); + }); + } + + [Test] + public void FiniteReadbackRequirement_CrossesAnUnresolvedTargetScope() + { + var localAccess = new Rect(5, 7, 20, 11); + using var root = new ContainerRenderNode(); + root.AddChild(new OrderOnlyCommandNode()); + var transform = new TransformRenderNode( + Matrix.CreateTranslation(10, 0), + TransformOperator.Prepend); + transform.AddChild(new ReadbackCommandNode(localAccess)); + root.AddChild(transform); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + + Assert.That( + compiled.ExecutionTargetBounds, + Is.EqualTo(new Rect(15, 7, 20, 11))); + } + + [TestCase("opacity")] + [TestCase("blend")] + [TestCase("opacity-mask")] + public void TypedTargetStateScope_PreservesTargetOnlyFullWrite(string scopeKind) + { + using ContainerRenderNode root = scopeKind switch + { + "opacity" => new OpacityRenderNode(1), + "blend" => new BlendModeRenderNode(BlendMode.SrcOver), + "opacity-mask" => new OpacityMaskRenderNode( + Brushes.Resource.White, + s_rootDomain, + invert: false), + _ => throw new ArgumentOutOfRangeException(nameof(scopeKind)), + }; + root.AddChild(new ClearRenderNode(Colors.White)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(AlphaAt(raster.Bitmap!, 0, 30), Is.GreaterThan(0.99f)); + Assert.That(AlphaAt(raster.Bitmap!, 99, 30), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void DestructiveBlend_LowersAsFullDomainTargetCommand() + { + var sourceBounds = new Rect(20, 10, 30, 20); + using var root = new BlendModeRenderNode(BlendMode.DstIn); + root.AddChild(new SourceNode(sourceBounds, "dst-in-source")); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference blend = References(compiled.Graph).Values.Single(reference => + reference.Kind == RenderFragmentKind.Blend); + TargetDependencyStep step = compiled.TargetDependencies.Steps.Single(item => + item.FragmentId == blend.Id); + + Assert.Multiple(() => + { + Assert.That(step.Kind, Is.EqualTo(TargetDependencyKind.Command)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(s_rootDomain)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(sourceBounds)); + Assert.That( + compiled.Regions.GetTargetAccessRequirement(blend).Resolve(s_rootDomain), + Is.EqualTo(s_rootDomain)); + Assert.That( + compiled.Regions.GetFragmentRequirement(blend).Resolve(s_rootDomain), + Is.EqualTo(s_rootDomain)); + }); + } + + [Test] + public void NonDestructiveBlend_KeepsChildBoundsComposite() + { + var sourceBounds = new Rect(20, 10, 30, 20); + using var root = new BlendModeRenderNode(BlendMode.Multiply); + root.AddChild(new SourceNode(sourceBounds, "multiply-source")); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + RenderFragmentReference blend = References(compiled.Graph).Values.Single(reference => + reference.Kind == RenderFragmentKind.Blend); + TargetDependencyStep step = compiled.TargetDependencies.Steps.Single(item => + item.FragmentId == blend.Id); + + Assert.Multiple(() => + { + Assert.That(step.Kind, Is.EqualTo(TargetDependencyKind.Composite)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(sourceBounds)); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(sourceBounds)); + Assert.That(compiled.Regions.GetTargetAccessRequirement(blend).IsEmpty, Is.True); + }); + } + + [TestCaseSource(nameof(AllBlendModes))] + public void BlendMode_FullTargetRegionClassificationMatchesTransparentSourceSemantics( + BlendMode blendMode) + { + bool transparentSourceChangesDestination = TransparentSourceChangesDestination(blendMode); + + Assert.That( + BlendModeRenderNode.RequiresFullTargetRegion(blendMode), + Is.EqualTo(transparentSourceChangesDestination)); + } + + private static IEnumerable AllBlendModes() + => Enum.GetValues(); + + private static bool TransparentSourceChangesDestination(BlendMode blendMode) + { + var destination = new SKColor(53, 107, 181, 199); + using var bitmap = new SKBitmap( + new SKImageInfo(1, 1, SKColorType.Rgba8888, SKAlphaType.Premul)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(destination); + SKColor before = bitmap.GetPixel(0, 0); + using var paint = new SKPaint + { + Color = SKColors.Transparent, + BlendMode = (SKBlendMode)blendMode, + }; + + canvas.DrawRect(SKRect.Create(1, 1), paint); + + return bitmap.GetPixel(0, 0) != before; + } + + [Test] + public void RootFullTargetLayer_ReplaysAndCompositesItsLocalTarget() + { + using var root = new EmptyTargetLayerNode(TargetRegion.Full); + root.AddChild(new FullCommandNode(Colors.White)); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(AlphaAt(raster.Bitmap!, 0, 30), Is.GreaterThan(0.99f)); + Assert.That(AlphaAt(raster.Bitmap!, 99, 30), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void EmptyTargetLayer_RemainsOrderOnlyWithoutAChainOrPixelSteps() + { + using var root = new ContainerRenderNode(); + var empty = new EmptyTargetLayerNode(); + empty.AddChild(new SourceNode(new Rect(0, 0, 20, 20), "empty-source")); + empty.AddChild(new ClearRenderNode(Colors.Transparent)); + root.AddChild(empty); + root.AddChild(new SourceNode(new Rect(30, 0, 10, 10), "after-empty")); + + using CompiledRenderRequest compiled = Compile(root, s_rootDomain); + TargetScopePlan scope = FindOwnedScope(compiled, RenderFragmentKind.TargetLayerScope); + IReadOnlyDictionary references = References(compiled.Graph); + + Assert.Multiple(() => + { + Assert.That(scope.ResolvedDomain, Is.EqualTo(Rect.Empty)); + Assert.That(scope.IsOrderOnly, Is.True); + Assert.That(compiled.TargetDependencies.Steps.Count(step => step.ScopeId == scope.Id), Is.Zero); + Assert.That(compiled.TargetDependencies.Steps.Length, Is.EqualTo(1)); + Assert.That( + references[compiled.TargetDependencies.Steps.Single().FragmentId].Kind, + Is.EqualTo(RenderFragmentKind.OpaqueSource)); + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(new Rect(30, 0, 10, 10))); + Assert.That(compiled.Measurement.QueryBounds, Is.EqualTo(new Rect(30, 0, 10, 10))); + }); + } + + [Test] + public void EmptyTargetLayer_SuppressesChildExecutionAndCompletesTheIslandSchedule() + { + using var root = new ContainerRenderNode(); + var empty = new EmptyTargetLayerNode(); + var suppressed = new SourceNode(new Rect(0, 0, 20, 20), "suppressed"); + var visible = new SourceNode(new Rect(30, 0, 10, 10), "visible", execute: true); + empty.AddChild(suppressed); + root.AddChild(empty); + root.AddChild(visible); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(suppressed.ExecuteCount, Is.Zero); + Assert.That(visible.ExecuteCount, Is.EqualTo(1)); + Assert.That(raster.Bounds, Is.EqualTo(new Rect(30, 0, 10, 10))); + Assert.That(AlphaAt(raster.Bitmap!, 5, 5), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void FullWithoutAnOwningDomain_FailsDuringLowering_NotDuringRecording() + { + using var fullCommand = new FullCommandNode(); + using var owner = new RenderRequestOwner(); + var request = new RenderRequest(Options(targetDomain: null, owner: owner)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(fullCommand); + + InvalidOperationException? error = Assert.Throws( + () => new RenderRequestCompiler().Compile(request, graph)); + + Assert.That(error!.Message, Does.Contain("finite").And.Contain("target domain").IgnoreCase); + } + + /// + /// The scope a capture is used in decides its target domain, and an author has no way to read that when + /// building the description, so bounds sticking out of it cannot be a precondition they are able to meet. + /// The capture's value is cleared before the copy, so the part with no pixels behind it reads transparent, + /// which is the same answer as capturing an area nothing drew into. + /// + [Test] + public void FullTargetCaptureBoundsOutsideFiniteLayer_LowerToTransparentInsteadOfFailing() + { + var layerDomain = new Rect(10, 20, 30, 20); + var captureBounds = new Rect(5, 20, 10, 10); + using var root = new OutOfDomainCaptureLayerNode(layerDomain, captureBounds); + + Assert.That( + () => + { + using CompiledRenderRequest _ = Compile(root, targetDomain: null); + }, + Throws.Nothing); + } + + [Test] + public void RequestedRegionDoesNotSupplyMissingTargetDomain_ButFiniteLayerDoes() + { + using var command = new FullCommandNode(); + Assert.That( + () => Compile(command, targetDomain: null, requestedRegion: new Rect(2, 3, 4, 5)), + Throws.TypeOf()); + + using var root = new ContainerRenderNode(); + var finite = new LayerRenderNode(new Rect(10, 20, 30, 40)); + var nestedFull = new EmptyTargetLayerNode(TargetRegion.Full); + nestedFull.AddChild(new ClearRenderNode(Colors.Transparent)); + finite.AddChild(nestedFull); + root.AddChild(finite); + + using CompiledRenderRequest compiled = Compile(root, targetDomain: null); + TargetScopePlan isolated = FindOwnedScope(compiled, RenderFragmentKind.TargetLayerScope); + Assert.That(isolated.ResolvedDomain, Is.EqualTo(new Rect(10, 20, 30, 40))); + } + + [Test] + public void FullClear_UsesOutputDomainButKeepsQueryAndHitTestingEmpty() + { + var domain = new Rect(10, 20, 40, 30); + using var root = new FullCommandNode(Colors.White); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(measurement.QueryBounds, Is.EqualTo(Rect.Empty)); + Assert.That(renderer.HitTest(new Point(20, 25)), Is.False); + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + Assert.That(AlphaAt(raster.Bitmap!, 20, 15), Is.GreaterThan(0.99f)); + } + + [Test] + public void ReadbackCommandWrites_AreIncludedInOutputPlanning() + { + using var root = new WritingReadbackCommandNode(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_rootDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(root.ExecutionCount, Is.EqualTo(1)); + Assert.That(raster.Bounds, Is.EqualTo(s_rootDomain)); + Assert.That(raster.Bitmap, Is.Not.Null); + Assert.That(AlphaAt(raster.Bitmap!, 50, 30), Is.GreaterThan(0.99f)); + }); + } + + [Test] + public void Rasterize_ReportsTheDeviceCoverOfAShiftedSelection_AndEmptySelectionDoesNotAllocate() + { + var factory = new CpuTargetFactory(); + var shifted = new Rect(10.25f, 20.25f, 3.5f, 2.5f); + using var root = new SourceNode(shifted, "shifted-raster", execute: true); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + using (RenderNodeRasterization raster = renderer.Rasterize()) + { + Assert.Multiple(() => + { + Assert.That(raster.Bounds, Is.EqualTo(PixelRect.FromRect(shifted, 2).ToRect(2))); + Assert.That(raster.Bounds.Contains(shifted), Is.True); + Assert.That(raster.Bitmap, Is.Not.Null); + Assert.That(raster.OutputScale, Is.EqualTo(2)); + }); + } + + int allocationsAfterShifted = factory.AllocationCount; + var emptySelection = new Rect(70, 80, 0, 5); + using var emptyRenderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + RequestedRegion = emptySelection, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + using RenderNodeRasterization empty = emptyRenderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(empty.Bounds, Is.EqualTo(emptySelection)); + Assert.That(empty.IsEmpty, Is.True); + Assert.That(empty.Bitmap, Is.Null); + Assert.That(factory.AllocationCount, Is.EqualTo(allocationsAfterShifted)); + }); + } + + private static CompiledRenderRequest Compile( + RenderNode root, + Rect? targetDomain, + Rect? requestedRegion = null) + { + var request = new RenderRequest(Options(targetDomain, requestedRegion)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return new RenderRequestCompiler().Compile(request, graph); + } + + private static RenderRequestOptions Options( + Rect? targetDomain, + Rect? requestedRegion = null, + RenderRequestOwner? owner = null) + => new( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain, + requestedRegion, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner); + + private static TargetScopePlan FindOwnedScope( + CompiledRenderRequest compiled, + RenderFragmentKind kind) + { + IReadOnlyDictionary references = References(compiled.Graph); + return compiled.TargetDependencies.Scopes.Single(scope => + scope.OwnerFragmentId is { } owner && references[owner].Kind == kind); + } + + private static IReadOnlyDictionary References( + RecordedRenderGraph graph) + => graph.Fragments.ToDictionary( + static fragment => fragment.Id, + static fragment => (RenderFragmentReference)fragment.Payload!); + + private static float AlphaAt(Bitmap bitmap, int x, int y) + { + Span row = bitmap.GetRow(y); + return (float)BitConverter.UInt16BitsToHalf(row[(x * 4) + 3]); + } + + private static Brush.Resource CreateRemoteDrawableBrush() + { + var content = new RectShape(); + content.Width.CurrentValue = 10; + content.Height.CurrentValue = 8; + content.AlignmentX.CurrentValue = AlignmentX.Right; + content.AlignmentY.CurrentValue = AlignmentY.Bottom; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + return (Brush.Resource)brush.ToResource(CompositionContext.Default); + } + + private sealed class FullCommandNode(Color? color = null) : RenderNode + { + private readonly Color _color = color ?? Colors.Transparent; + + public override void Process(RenderNodeContext context) + { + Color color = _color; + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => session.Canvas.Use(canvas => canvas.Clear(color)), + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None))); + } + } + + private sealed class ReadbackCommandNode(Rect region) : RenderNode + { + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => session.UseSnapshot(static _ => { }), + TargetRegion.Region(region), + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + } + } + + private sealed class WritingReadbackCommandNode : RenderNode + { + public int ExecutionCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => + { + ExecutionCount++; + session.UseSnapshot(static _ => { }); + session.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + }, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + } + } + + private sealed class OrderOnlyCommandNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + static _ => { }, + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None))); + } + } + + private sealed class FiniteCommandNode(Rect bounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + static _ => { }, + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.None))); + } + } + + private sealed class EmptyTargetLayerNode(TargetRegion? region = null) : ContainerRenderNode + { + private readonly TargetRegion _region = region ?? TargetRegion.Empty; + + public override void Process(RenderNodeContext context) + => context.Publish(context.TargetLayerScope(context.Inputs, _region)); + } + + private sealed class LayerFanOutNode : RenderNode + { + private readonly Rect _layerDomain; + private readonly Rect _secondTargetDomain; + private readonly SourceNode _source; + + public LayerFanOutNode(Rect layerDomain, Rect secondTargetDomain) + { + _layerDomain = layerDomain; + _secondTargetDomain = secondTargetDomain; + _source = new SourceNode(layerDomain, "layer-fan-out", execute: true); + } + + public int SourceExecutionCount => _source.ExecuteCount; + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.RecordNode(_source, []).Single(); + RenderFragmentHandle layer = context.Layer([source], _layerDomain); + context.Publish(layer); + context.Publish(context.TargetLayerScope( + [layer], + TargetRegion.Region(_secondTargetDomain))); + } + + protected override void OnDispose(bool disposing) + { + _source.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class OutOfDomainCaptureLayerNode(Rect layerDomain, Rect captureBounds) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Full, + captureBounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle contributing = context.ContributeValues(capture); + context.Publish(context.Layer([contributing], layerDomain)); + } + } + + private sealed class SourceNode(Rect bounds, string key, bool execute = false) : RenderNode + { + public int ExecuteCount { get; private set; } + + public override string ToString() => $"{nameof(SourceNode)}({key})"; + + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + session => + { + ExecuteCount++; + if (!execute) + throw new AssertionException("Metadata and lowering must not execute source callbacks."); + + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(static canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueSource(description)); + } + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public int AllocationCount { get; private set; } + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + AllocationCount++; + return new CpuRenderTarget(deviceSize.Width, deviceSize.Height); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RasterFootprintMetadataTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RasterFootprintMetadataTests.cs new file mode 100644 index 0000000000..e5d7a0fe86 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RasterFootprintMetadataTests.cs @@ -0,0 +1,834 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[NonParallelizable] +[TestFixture] +public sealed class RasterFootprintMetadataTests +{ + [Test] + public void ExecutionInput_DrawsTheCompleteRasterFootprintWithoutChangingSemanticBounds() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + PixelRect deviceBounds = PixelRect.FromRect(bounds, density); + Rect rasterBounds = deviceBounds.ToRect(density); + Rect? drawnBounds = null; + var token = new RenderExecutionSessionToken(); + var input = new RenderExecutionInput( + token, + bounds, + EffectiveScale.At(density), + deviceBounds, + draw: (_, destination, _, _) => drawnBounds = destination, + drawDeviceSpace: static (_, _) => { }, + createShader: null, + createSnapshot: null, + readbackDeclared: false); + + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var canvas = new RenderCallbackCanvas( + token, + density, + bounds, + deviceBounds, + () => new ImmediateCanvas(target, density, logicalSize: rasterBounds.Size), + CallbackCanvasCapability.Draw); + + canvas.Use(input.Draw); + + Assert.Multiple(() => + { + Assert.That(input.Bounds, Is.EqualTo(bounds)); + Assert.That(input.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(input.RasterBounds, Is.EqualTo(rasterBounds)); + Assert.That(input.LogicalOrigin, Is.EqualTo(rasterBounds.Position)); + Assert.That(drawnBounds, Is.EqualTo(rasterBounds)); + }); + + token.Complete(); + } + + [Test] + public void CallbackCanvas_UsesAnExplicitPhysicalFootprintForOriginAndClipping() + { + const float density = 2; + var logicalBounds = new Rect(10.25f, 20.25f, 8, 6); + PixelRect canonical = PixelRect.FromRect(logicalBounds, density); + var deviceBounds = new PixelRect( + canonical.X - 1, + canonical.Y - 1, + canonical.Width + 2, + canonical.Height + 2); + Rect rasterBounds = deviceBounds.ToRect(density); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var facade = new RenderCallbackCanvas( + token, + density, + logicalBounds, + deviceBounds, + () => new ImmediateCanvas(target, density, logicalSize: rasterBounds.Size), + CallbackCanvasCapability.Draw); + + facade.Use(canvas => + { + Assert.Multiple(() => + { + Assert.That(facade.LogicalBounds, Is.EqualTo(logicalBounds)); + Assert.That(facade.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(facade.RasterBounds, Is.EqualTo(rasterBounds)); + Assert.That(facade.LogicalOrigin, Is.EqualTo(rasterBounds.Position)); + Assert.That(canvas.Transform.Transform(rasterBounds.Position), Is.EqualTo(default(Point))); + }); + }); + + token.Complete(); + } + + [Test] + public void TargetAttachedCallback_DrawDeviceSpaceUsesTheBackingSurfaceOrigin() + { + var callbackBounds = new Rect(10, 12, 8, 6); + var token = new RenderExecutionSessionToken(); + Point? observedLocalPoint = null; + var input = new RenderExecutionInput( + token, + new Rect(0, 0, 2, 2), + EffectiveScale.At(1), + draw: static (_, _, _, _) => { }, + drawDeviceSpace: (_, point) => observedLocalPoint = point, + createShader: null, + createSnapshot: null, + readbackDeclared: false); + using RenderTarget target = RenderTarget.CreateNull(64, 48); + var facade = new RenderCallbackCanvas( + token, + density: 1, + callbackBounds, + () => new ImmediateCanvas(target, logicalSize: new Size(64, 48)), + CallbackCanvasCapability.TargetCommandRegion, + mapLogicalOrigin: false); + + facade.Use(canvas => input.DrawDeviceSpace(canvas, new Point(20, 30))); + + Assert.That(observedLocalPoint, Is.EqualTo(new Point(20, 30))); + token.Complete(); + } + + [Test] + public void TargetAttachedCallback_ReportsTheAmbientTranslationDeviceGrid() + { + var callbackBounds = new Rect(10, 12, 8, 6); + var expectedOffset = new Vector(0.25f, 0.75f); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(64, 48); + using var destination = new ImmediateCanvas(target, logicalSize: new Size(64, 48)); + using (destination.PushTransform(Matrix.CreateTranslation(expectedOffset))) + { + RenderCallbackCanvas facade = RenderCallbackCanvas.CreateTargetAttached( + token, + callbackBounds, + destination, + CallbackCanvasCapability.TargetCommandRegion); + + Assert.Multiple(() => + { + Assert.That(facade.DeviceGridOffset, Is.EqualTo(expectedOffset)); + Assert.That( + facade.RasterBounds, + Is.EqualTo(facade.DeviceBounds.ToRect(facade.Density).Translate(-expectedOffset))); + Assert.That(facade.RasterBounds.Contains(callbackBounds), Is.True); + }); + } + + token.Complete(); + } + + [Test] + public void TargetAttachedCallback_AcceptsRoundingNoiseAcrossLargeDeviceTranslation() + { + var callbackBounds = new Rect( + -0.025896728f, + -3.2809492E-06f, + 150.05179f, + 110); + var translation = new Vector(49.97410583f, 70); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(256, 192); + using var destination = new ImmediateCanvas(target, logicalSize: new Size(256, 192)); + using (destination.PushTransform(Matrix.CreateTranslation(translation))) + { + RenderCallbackCanvas facade = RenderCallbackCanvas.CreateTargetAttached( + token, + callbackBounds, + destination, + CallbackCanvasCapability.TargetScope); + + Assert.Multiple(() => + { + Assert.That(facade.Density, Is.EqualTo(1)); + Assert.That(facade.DeviceGridOffset, Is.EqualTo(translation)); + Assert.That(facade.DeviceBounds, Is.EqualTo(new PixelRect(49, 70, 151, 110))); + Assert.That(facade.RasterBounds, Is.EqualTo(new Rect(-0.97410583f, 0, 151, 110))); + }); + } + + token.Complete(); + } + + [TestCase(1.7f)] + [TestCase(1.3333333f)] + [TestCase(1.06f)] + public void CallbackCanvas_AcceptsLargeCanonicalDeviceFootprints(float density) + { + var logicalBounds = new Rect(0, 0, 1920, 1080); + PixelRect deviceBounds = PixelRect.FromRect(logicalBounds, density); + Rect rasterBounds = deviceBounds.ToRect(density); + var token = new RenderExecutionSessionToken(); + try + { + Assert.That( + () => new RenderCallbackCanvas( + token, + density, + logicalBounds, + deviceBounds, + static () => throw new InvalidOperationException("The constructor must not open a canvas."), + CallbackCanvasCapability.Draw, + rasterBounds: rasterBounds), + Throws.Nothing); + } + finally + { + token.Complete(); + } + } + + [Test] + public void DeviceBoundsValidation_RejectsOffByOneExtentAboveFloatPrecisionBoundary() + { + const int deviceExtent = 8_388_610; + + Assert.Multiple(() => + { + Assert.That( + DeviceBoundsValidation.MatchesExtent(deviceExtent, density: 1, deviceExtent), + Is.True); + Assert.That( + DeviceBoundsValidation.MatchesExtent(deviceExtent + 1, density: 1, deviceExtent), + Is.False, + "An off-by-one backing extent must not be accepted when float ULPs exceed one pixel."); + }); + } + + [TestCase(1.7f)] + [TestCase(1.3333333f)] + [TestCase(1.06f)] + public void ExecutionInput_AcceptsLargeCanonicalDeviceFootprints(float density) + { + var logicalBounds = new Rect(0, 0, 1920, 1080); + PixelRect deviceBounds = PixelRect.FromRect(logicalBounds, density); + Rect rasterBounds = deviceBounds.ToRect(density); + var token = new RenderExecutionSessionToken(); + try + { + Assert.That( + () => new RenderExecutionInput( + token, + logicalBounds, + EffectiveScale.At(density), + deviceBounds, + rasterBounds, + draw: static (_, _, _, _) => { }, + drawDeviceSpace: static (_, _) => { }, + createShader: null, + createSnapshot: null, + readbackDeclared: false), + Throws.Nothing); + } + finally + { + token.Complete(); + } + } + + [Test] + public void CallbackCanvas_RejectsAnOffByOneBackingExtent() + { + const float density = 1.7f; + var logicalBounds = new Rect(0, 0, 1920, 1080); + PixelRect canonical = PixelRect.FromRect(logicalBounds, density); + Rect rasterBounds = canonical.ToRect(density); + var mismatched = new PixelRect( + canonical.X, + canonical.Y, + canonical.Width + 1, + canonical.Height); + var token = new RenderExecutionSessionToken(); + try + { + Assert.That( + () => new RenderCallbackCanvas( + token, + density, + logicalBounds, + mismatched, + static () => throw new InvalidOperationException("The constructor must not open a canvas."), + CallbackCanvasCapability.Draw, + rasterBounds: rasterBounds), + Throws.ArgumentException.With.Message.Contains("backing size")); + } + finally + { + token.Complete(); + } + } + + [Test] + public void RenderNodeRenderer_TargetScopeRendersAtScaleOnePointSeven() + { + const float density = 1.7f; + var logicalBounds = new Rect(0, 0, 1920, 1080); + using var root = new TransformRenderNode(Matrix.Identity, TransformOperator.Prepend); + root.AddChild(new RectangleRenderNode(logicalBounds, Brushes.Resource.White, pen: null)); + using var target = new CpuRenderTarget( + (int)Math.Ceiling(logicalBounds.Width * density), + (int)Math.Ceiling(logicalBounds.Height * density)); + using var destination = new ImmediateCanvas( + target, + density, + logicalSize: logicalBounds.Size); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = logicalBounds, + OutputScale = density, + MaxWorkingScale = density, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + Assert.That(() => renderer.Render(destination), Throws.Nothing); + } + + [Test] + public void TargetAttachedTargetScope_ClipsTheRasterApronOnTheAmbientDeviceGrid() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var callbackBounds = new Rect(10, 4, 4, 4); + var gridOffset = new Vector(0.25f, 0.75f); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.Create(32, 16) + ?? throw new InvalidOperationException("RenderTarget.Create returned null."); + using var destination = new ImmediateCanvas(target, logicalSize: new Size(32, 16)); + destination.Clear(); + PixelRect expectedBounds; + using (destination.PushTransform(Matrix.CreateTranslation(gridOffset))) + { + RenderCallbackCanvas facade = RenderCallbackCanvas.CreateTargetAttached( + token, + callbackBounds, + destination, + CallbackCanvasCapability.TargetScope); + expectedBounds = RenderScaleUtilities.AddRasterApron(facade.DeviceBounds); + using var paint = new SKPaint { Color = SKColors.White }; + var session = new TargetScopeSession( + token, + callbackBounds, + callbackBounds, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + facade, + [], + canvas => canvas.Canvas.DrawRect(SKRect.Create(32, 16), paint)); + + facade.Use(_ => session.ReplayInput()); + session.ValidateCompletion(); + } + + token.Complete(); + using Bitmap bitmap = target.Snapshot(); + + Assert.That(MeasureAlphaBounds(bitmap), Is.EqualTo(expectedBounds)); + }); + } + + [Test] + public void CachedValue_PreservesThePhysicalFootprintIndependentlyOfSemanticBounds() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + PixelRect canonical = PixelRect.FromRect(bounds, density); + var deviceBounds = new PixelRect( + canonical.Position, + new PixelSize(canonical.Width + 1, canonical.Height + 2)); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var value = new RenderNodeCachedValue( + target, + bounds, + EffectiveScale.At(density), + deviceBounds); + + Assert.Multiple(() => + { + Assert.That(value.Bounds, Is.EqualTo(bounds)); + Assert.That(value.CompleteBounds, Is.EqualTo(bounds)); + Assert.That(value.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(value.RasterBounds, Is.EqualTo(deviceBounds.ToRect(density))); + }); + } + + [Test] + public void CachedValue_RejectsAPhysicalFootprintThatDoesNotContainSemanticBounds() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + PixelRect canonical = PixelRect.FromRect(bounds, density); + var shifted = new PixelRect( + canonical.X + 1, + canonical.Y, + canonical.Width, + canonical.Height); + using RenderTarget target = RenderTarget.CreateNull(shifted.Width, shifted.Height); + + Assert.That( + () => new RenderNodeCachedValue(target, bounds, EffectiveScale.At(density), shifted), + Throws.ArgumentException.With.Property("ParamName").EqualTo("deviceBounds")); + } + + [Test] + public void EffectTarget_TranslatesRasterBoundsWithoutMutatingItsAllocationFootprint() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + var deviceGridOffset = new Vector(0.25f, -0.125f); + PixelRect deviceBounds = PixelRect.FromRect(bounds.Translate(deviceGridOffset), density); + using RenderTarget renderTarget = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + using var target = new EffectTarget( + renderTarget, + bounds, + EffectiveScale.At(density), + deviceBounds, + deviceGridOffset); + Rect initialRasterBounds = deviceBounds.ToRect(density).Translate(-deviceGridOffset); + var translation = new Vector(3.25f, -1.5f); + + target.Bounds = target.Bounds.Translate(translation); + using EffectTarget clone = target.Clone(); + using var targets = new EffectTargets { target.Clone() }; + using EffectTargets clonedTargets = targets.Clone(); + + Assert.Multiple(() => + { + Assert.That(target.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(target.DeviceGridOffset, Is.EqualTo(deviceGridOffset)); + Assert.That(target.RasterBounds, Is.EqualTo(initialRasterBounds.Translate(translation))); + Assert.That(target.RasterBounds.Size, Is.EqualTo(initialRasterBounds.Size)); + Assert.That(clone.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(clone.DeviceGridOffset, Is.EqualTo(deviceGridOffset)); + Assert.That(clone.Bounds, Is.EqualTo(target.Bounds)); + Assert.That(clone.RasterBounds, Is.EqualTo(target.RasterBounds)); + Assert.That(clonedTargets[0].DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(clonedTargets[0].DeviceGridOffset, Is.EqualTo(deviceGridOffset)); + Assert.That(clonedTargets[0].RasterBounds, Is.EqualTo(target.RasterBounds)); + }); + } + + [Test] + public void DeviceBufferSize_RemainsIndependentFromCanonicalDeviceOrigin() + { + const float density = 2; + var bounds = new Rect(10.25f, 20.25f, 8, 6); + + PixelRect actual = CustomFilterEffectContext.DeviceBufferBounds(bounds, density); + + Assert.Multiple(() => + { + Assert.That(actual, Is.EqualTo(PixelRect.FromRect(bounds, density))); + Assert.That(actual.Size, Is.EqualTo(new PixelSize(17, 13))); + Assert.That( + CustomFilterEffectContext.DeviceBufferSize(bounds, density), + Is.EqualTo((16, 12))); + }); + } + + [Test] + public void ResolveTargetDensity_UsesLegacyLocalDimensions() + { + var sourceBounds = new Rect(0, 0, 1, 1); + var gridOffset = new Vector(0.5f, 0); + PixelRect sourceDeviceBounds = PixelRect.FromRect( + sourceBounds.Translate(gridOffset), + 1); + using RenderTarget backing = RenderTarget.CreateNull( + sourceDeviceBounds.Width, + sourceDeviceBounds.Height); + using var source = new EffectTarget( + backing, + sourceBounds, + EffectiveScale.At(1), + sourceDeviceBounds, + gridOffset); + using var targets = new EffectTargets { source.Clone() }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + var requestedBounds = new Rect( + 0, + 0, + RenderScaleUtilities.MaxBufferDimension, + 1); + + float density = context.ResolveTargetDensity(requestedBounds); + (int width, int height) = CustomFilterEffectContext.DeviceBufferSize( + requestedBounds, + density); + + Assert.Multiple(() => + { + Assert.That(density, Is.EqualTo(1)); + Assert.That(width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(height, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void CustomFilterContext_AllowsInputsFromDifferentDeviceGrids() + { + var bounds = new Rect(0, 0, 8, 6); + var firstOffset = new Vector(0.25f, 0); + var secondOffset = new Vector(0.75f, 0); + var ambientOffset = new Vector(0.5f, 0.5f); + PixelRect firstDeviceBounds = PixelRect.FromRect(bounds.Translate(firstOffset), 1); + PixelRect secondDeviceBounds = PixelRect.FromRect(bounds.Translate(secondOffset), 1); + using RenderTarget firstBacking = RenderTarget.CreateNull( + firstDeviceBounds.Width, + firstDeviceBounds.Height); + using RenderTarget secondBacking = RenderTarget.CreateNull( + secondDeviceBounds.Width, + secondDeviceBounds.Height); + using var targets = new EffectTargets + { + new EffectTarget( + firstBacking, + bounds, + EffectiveScale.At(1), + firstDeviceBounds, + firstOffset), + new EffectTarget( + secondBacking, + bounds, + EffectiveScale.At(1), + secondDeviceBounds, + secondOffset), + }; + + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + deviceGridOffset: ambientOffset); + + Assert.Multiple(() => + { + Assert.That(context.DeviceGridOffset, Is.EqualTo(ambientOffset)); + Assert.That(context.Targets[0].DeviceGridOffset, Is.EqualTo(firstOffset)); + Assert.That(context.Targets[1].DeviceGridOffset, Is.EqualTo(secondOffset)); + }); + } + + [Test] + public void CustomFilterContext_WrapsReplacementOnTheSourceDeviceGrid() + { + var bounds = new Rect(10, 12, 8, 6); + var gridOffset = new Vector(0.25f, 0.75f); + PixelRect deviceBounds = PixelRect.FromRect(bounds.Translate(gridOffset), 1); + using RenderTarget sourceBacking = RenderTarget.CreateNull( + deviceBounds.Width, + deviceBounds.Height); + using var source = new EffectTarget( + sourceBacking, + bounds, + EffectiveScale.At(1), + deviceBounds, + gridOffset); + using var targets = new EffectTargets { source.Clone() }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + using RenderTarget replacementBacking = RenderTarget.CreateNull( + deviceBounds.Width, + deviceBounds.Height); + + using EffectTarget replacement = context.CreateReplacement( + source, + replacementBacking); + + Assert.Multiple(() => + { + Assert.That(replacement.DeviceGridOffset, Is.EqualTo(gridOffset)); + Assert.That(replacement.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(replacement.RasterBounds, Is.EqualTo(source.RasterBounds)); + Assert.That(replacement.Bounds, Is.EqualTo(source.Bounds)); + }); + } + + [Test] + public void CustomFilterContext_ScopesGpuBackedMappedInputShader() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var bounds = new Rect(0, 0, 8, 6); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + using RenderTarget sourceBacking = RenderTarget.Create(deviceBounds.Width, deviceBounds.Height) + ?? throw new AssertionException("Vulkan did not create the mapped-input source target."); + using RenderTarget destinationBacking = RenderTarget.Create(deviceBounds.Width, deviceBounds.Height) + ?? throw new AssertionException("Vulkan did not create the mapped-input destination target."); + using var source = new EffectTarget( + sourceBacking, + bounds, + EffectiveScale.At(1), + deviceBounds); + using var destination = new EffectTarget( + destinationBacking, + bounds, + EffectiveScale.At(1), + deviceBounds); + using var targets = new EffectTargets { source.Clone() }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + int[] callbackEntries = [0]; + + bool rendered = context.UseMappedInputShader( + source, + destination, + callbackEntries, + static (entries, shader) => + { + entries[0]++; + using SKShader remapped = shader.WithLocalMatrix(SKMatrix.Identity); + Assert.That(remapped, Is.Not.Null); + }, + SKShaderTileMode.Repeat, + SKShaderTileMode.Mirror); + + Assert.Multiple(() => + { + Assert.That(callbackEntries[0], Is.EqualTo(1)); + Assert.That(rendered, Is.True, "A successful readback must report that the callback ran."); + }); + + Assert.Multiple(() => + { + Assert.Throws(() => context.UseMappedInputShader( + source, + destination, + 0, + static (_, _) => { }, + (SKShaderTileMode)(-1))); + Assert.Throws(() => context.UseMappedInputShader( + source, + destination, + 0, + static (_, _) => { }, + y: (SKShaderTileMode)(-1))); + }); + }); + } + + [Test] + public void SkslShaderBuilder_RejectsCrossOwnerAndDisposedOwnerUse() + { + const string source = "half4 main(float2 coord) { return half4(1); }"; + using SKSLShader first = SKSLShader.Create(source); + using SKSLShader second = SKSLShader.Create(source); + using SKSLShaderBuilder secondBuilder = second.CreateBuilder(); + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + using var emptyTarget = new EffectTarget(); + + Assert.That( + () => first.RenderToTarget(context, secondBuilder, emptyTarget), + Throws.ArgumentException.With.Property("ParamName").EqualTo("builder")); + + using SKSLShader disposedOwner = SKSLShader.Create(source); + using SKSLShaderBuilder disposedOwnerBuilder = disposedOwner.CreateBuilder(); + disposedOwner.Dispose(); + + Assert.That( + () => disposedOwnerBuilder.Build(), + Throws.TypeOf()); + } + + [Test] + public void CustomFilterContext_ReplacementFootprintFailureNamesPublicArgument() + { + var bounds = new Rect(10, 12, 8, 6); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + using RenderTarget sourceBacking = RenderTarget.CreateNull( + deviceBounds.Width, + deviceBounds.Height); + using var source = new EffectTarget( + sourceBacking, + bounds, + EffectiveScale.At(1), + deviceBounds); + using var targets = new EffectTargets { source.Clone() }; + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Frame); + using RenderTarget wrongSize = RenderTarget.CreateNull( + deviceBounds.Width + 1, + deviceBounds.Height); + + ArgumentException exception = Assert.Throws( + () => context.CreateReplacement(source, wrongSize))!; + + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("renderTarget")); + Assert.That(exception.Message, Does.Contain("footprint")); + Assert.That(exception.Message, Does.Contain($"{deviceBounds.Width}x{deviceBounds.Height}")); + }); + } + + [Test] + public void MeasureAlphaBounds_IgnoresNonFiniteAndNonPositiveAlpha() + { + using var bitmap = new Bitmap( + 6, + 1, + BitmapColorType.RgbaF16, + BitmapAlphaType.Premul, + BitmapColorSpace.LinearSrgb); + Span pixels = bitmap.GetPixelSpan(); + float[] alphaValues = [float.NegativeInfinity, -1, 0, float.NaN, float.PositiveInfinity, 0.5f]; + for (int x = 0; x < alphaValues.Length; x++) + { + pixels[(x * 4) + 3] = BitConverter.HalfToUInt16Bits((Half)alphaValues[x]); + } + + Assert.That(MeasureAlphaBounds(bitmap), Is.EqualTo(new PixelRect(5, 0, 1, 1))); + } + + // EffectTarget.Draw picks the nearest-sampled point blit only while this gate holds, so the gate + // has to reject anything the blit would silently snap: a fractional destination or a scaled one. + [Test] + public void CanBlitLossless_AcceptsOnlyADestinationOnExactDevicePixels() + { + var dest = new Rect(0, 0, 12, 10); + var sourceSize = new PixelSize(12, 10); + using RenderTarget target = RenderTarget.CreateNull(40, 40); + using var canvas = new ImmediateCanvas(target, 1f, logicalSize: new Size(40, 40)); + + bool aligned; + bool fractional; + bool scaled; + using (canvas.PushTransform(Matrix.CreateTranslation(new Vector(5, 7)))) + { + aligned = canvas.CanBlitLossless(dest, sourceSize); + } + + using (canvas.PushTransform(Matrix.CreateTranslation(new Vector(5.5f, 7.5f)))) + { + fractional = canvas.CanBlitLossless(dest, sourceSize); + } + + using (canvas.PushTransform(Matrix.CreateScale(1.5f, 1.5f))) + { + scaled = canvas.CanBlitLossless(dest, sourceSize); + } + + Assert.Multiple(() => + { + Assert.That(aligned, Is.True, "an integral translation lands the buffer on device pixels"); + Assert.That(fractional, Is.False, "a fractional translation would snap the buffer to the grid"); + Assert.That(scaled, Is.False, "a scaled destination no longer matches the buffer's extent"); + }); + } + + [Test] + public void CanBlitLossless_OnAGuardedCallbackCanvas_IsRefusedLikeCanDrawPixelAligned() + { + const float density = 1; + var bounds = new Rect(0, 0, 8, 6); + PixelRect deviceBounds = PixelRect.FromRect(bounds, density); + var token = new RenderExecutionSessionToken(); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var canvas = new RenderCallbackCanvas( + token, + density, + bounds, + deviceBounds, + () => new ImmediateCanvas(target, density, logicalSize: bounds.Size), + CallbackCanvasCapability.Draw); + + canvas.Use(guarded => Assert.Multiple(() => + { + Assert.That( + () => guarded.CanBlitLossless(bounds, deviceBounds.Size), + Throws.InstanceOf() + .With.Message.Contains("render targets are not available")); + Assert.That( + () => guarded.CanDrawPixelAligned(bounds, density, deviceBounds.Size), + Throws.InstanceOf()); + })); + } + + private static PixelRect MeasureAlphaBounds(Bitmap bitmap) + { + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int left = bitmap.Width; + int top = bitmap.Height; + int right = 0; + int bottom = 0; + for (int y = 0; y < bitmap.Height; y++) + { + for (int x = 0; x < bitmap.Width; x++) + { + int offset = ((y * bitmap.Width) + x) * 4; + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[offset + 3]); + if (!float.IsFinite(alpha) || alpha <= 0) + continue; + left = Math.Min(left, x); + top = Math.Min(top, y); + right = Math.Max(right, x + 1); + bottom = Math.Max(bottom, y + 1); + } + } + + return new PixelRect(left, top, right - left, bottom - top); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/AbandonedRecordedSubtreeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/AbandonedRecordedSubtreeTests.cs new file mode 100644 index 0000000000..3c9b779e28 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/AbandonedRecordedSubtreeTests.cs @@ -0,0 +1,145 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Particles; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +// These bail-outs learn there is nothing to draw only after recording a child subtree, so they +// abandon a subtree that may already have published target-effect fragments of its own. +[TestFixture] +public sealed class AbandonedRecordedSubtreeTests +{ + private static readonly Rect s_ownerRect = new(0, 0, 32, 24); + + public enum DegenerateBrushContent + { + NoDrawable, + DisabledDrawable, + ZeroAreaDrawable, + } + + [Test] + public void ParticleRenderNode_WithParticlesScaledToZero_RendersNothingWithoutFailing() + { + var particle = new RectShape(); + particle.Width.CurrentValue = 20; + particle.Height.CurrentValue = 12; + particle.Fill.CurrentValue = Brushes.White; + + var emitter = new ParticleEmitter(); + emitter.ParticleDrawable.CurrentValue = particle; + emitter.MaxParticles.CurrentValue = 1; + emitter.Speed.CurrentValue = 0; + emitter.Gravity.CurrentValue = 0; + emitter.ParticleSize.CurrentValue = 0; + emitter.SizeRandom.CurrentValue = 0; + using var resource = (ParticleEmitter.Resource)emitter.ToResource( + new CompositionContext(TimeSpan.FromSeconds(1))); + + Assert.That(resource.GetAliveParticles().Length, Is.GreaterThanOrEqualTo(1), + "precondition: the emitter must still hold alive particles, only sized to zero"); + + using var node = new ParticleRenderNode(resource); + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(rasterization.IsEmpty, Is.True, + "a zero-sized particle set draws nothing, and the recorded particle-drawable subtree it " + + "abandoned must not fail the recording"); + } + + [TestCase(DegenerateBrushContent.NoDrawable)] + [TestCase(DegenerateBrushContent.DisabledDrawable)] + [TestCase(DegenerateBrushContent.ZeroAreaDrawable)] + public void DrawableBrush_WithDegenerateContent_DrawsTheOwnerAndFillsNothing( + DegenerateBrushContent content) + { + using Brush.Resource brushResource = CreateDegenerateDrawableBrush(content); + var pen = new Pen + { + Thickness = { CurrentValue = 4 }, + Brush = { CurrentValue = Brushes.White }, + StrokeAlignment = { CurrentValue = StrokeAlignment.Inside }, + }; + using Pen.Resource penResource = pen.ToResource(CompositionContext.Default); + using var node = new RectangleRenderNode(s_ownerRect, brushResource, penResource); + + using RenderNodeRasterization rasterization = Rasterize(node); + + Assert.That(rasterization.IsEmpty, Is.False, + "the owner still has a stroke to draw, so degenerate brush content must not erase it"); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("A non-empty rasterization must carry a bitmap."); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(s_ownerRect)); + Assert.That(AlphaAt(bitmap, 2, 2), Is.GreaterThan(0.99f), + "the owner's stroke must be drawn"); + Assert.That(AlphaAt(bitmap, 16, 12), Is.Zero, + "content that lowered to nothing must fill nothing, not a fallback colour"); + }); + } + + private static Brush.Resource CreateDegenerateDrawableBrush(DegenerateBrushContent content) + { + if (content == DegenerateBrushContent.NoDrawable) + return (Brush.Resource)new DrawableBrush().ToResource(CompositionContext.Default); + + var drawable = new RectShape(); + drawable.Width.CurrentValue = 18; + drawable.Height.CurrentValue = 12; + drawable.Fill.CurrentValue = Brushes.White; + if (content == DegenerateBrushContent.DisabledDrawable) + { + drawable.IsEnabled = false; + } + else + { + var collapse = new ScaleTransform(); + collapse.Scale.CurrentValue = 0; + drawable.Transform.CurrentValue = collapse; + } + + return (Brush.Resource)new DrawableBrush(drawable).ToResource(CompositionContext.Default); + } + + private static float AlphaAt(Bitmap bitmap, int x, int y) + => (float)BitConverter.UInt16BitsToHalf(bitmap.GetRow(y)[(x * 4) + 3]); + + private static RenderNodeRasterization Rasterize(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + return renderer.Rasterize(); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/BrushSourceRecordingTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/BrushSourceRecordingTests.cs new file mode 100644 index 0000000000..63607a5a8c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/BrushSourceRecordingTests.cs @@ -0,0 +1,79 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class BrushSourceRecordingTests +{ + private static readonly Rect s_shape = new(0, 0, 40, 30); + + [TestCaseSource(nameof(Fills))] + public void AFilledShapeRecordsOneRootFragmentOverItsOwnBounds(Func fill) + { + var shape = new RectShape(); + shape.Width.CurrentValue = (float)s_shape.Width; + shape.Height.CurrentValue = (float)s_shape.Height; + shape.Fill.CurrentValue = fill(); + using var resource = (Drawable.Resource)shape.ToResource(CompositionContext.Default); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, s_shape.Size)) + { + shape.Render(context, resource); + } + + using var owner = new RenderRequestOwner(); + using RenderRequest request = CreateRequest(owner); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + RenderFragmentReference rootFragment = GetSingleRoot(graph); + + Assert.Multiple(() => + { + Assert.That(graph.PublicationRoots, Has.Length.EqualTo(1), + "A single filled shape must not fan out into several published roots."); + Assert.That(graph.NestedRequests, Is.Empty, + "Brush content is lowered into the same request."); + Assert.That(rootFragment.Bounds, Is.EqualTo(s_shape)); + }); + } + + private static IEnumerable Fills() + { + yield return new TestCaseData(new Func(static () => new SolidColorBrush(Colors.White))) + .SetArgDisplayNames("solid"); + yield return new TestCaseData(new Func(static () => new LinearGradientBrush())) + .SetArgDisplayNames("gradient"); + yield return new TestCaseData(new Func(MakeDrawableBrush)).SetArgDisplayNames("drawable"); + } + + private static Brush MakeDrawableBrush() + { + var content = new RectShape(); + content.Width.CurrentValue = 10; + content.Height.CurrentValue = 10; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + return brush; + } + + private static RenderRequest CreateRequest(RenderRequestOwner owner) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + owner: owner)); + + private static RenderFragmentReference GetSingleRoot(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/DeclaredResourceOrderTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/DeclaredResourceOrderTests.cs new file mode 100644 index 0000000000..225bf53834 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/DeclaredResourceOrderTests.cs @@ -0,0 +1,170 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +/// +/// Pins that the order of a resources: argument survives recording and reaches the structural plan key. +/// +/// +/// StructuralPlanCache writes the binding count and then each slot's value type in declaration order, so +/// two nodes that declare the same bindings in a different order are different plans. Recording therefore must +/// not sort or canonicalize the list on the way there. +/// +[TestFixture] +public sealed class DeclaredResourceOrderTests +{ + private static readonly Rect s_domain = new(0, 0, 8, 8); + + [Test] + public void DeclaredResourcesReachTheDescriptionInTheirAuthoredOrder() + { + using var straight = new TwoResourceNode(swapped: false); + using var reversed = new TwoResourceNode(swapped: true); + + RenderResourceSlot[] straightSlots = []; + RenderResourceSlot[] reversedSlots = []; + WithOpaqueRoot(straight, payload => straightSlots = SlotsOf(payload)); + WithOpaqueRoot(reversed, payload => reversedSlots = SlotsOf(payload)); + + Assert.Multiple(() => + { + Assert.That( + straightSlots, + Is.EqualTo(new[] { TwoResourceNode.FirstSlot, TwoResourceNode.SecondSlot })); + Assert.That( + reversedSlots, + Is.EqualTo(new[] { TwoResourceNode.SecondSlot, TwoResourceNode.FirstSlot }), + "Recording must not sort or canonicalize a resources: argument."); + }); + } + + [Test] + public void SwappingTwoDeclaredResources_CompilesASeparateStructuralPlan() + { + using var node = new TwoResourceNode(swapped: false); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + long afterAuthoredOrder = CompilationsAfterFrame(renderer); + long afterUnchangedFrame = CompilationsAfterFrame(renderer); + node.SwapDeclarationOrder(); + long afterSwap = CompilationsAfterFrame(renderer); + + Assert.Multiple(() => + { + Assert.That(afterAuthoredOrder, Is.EqualTo(1)); + Assert.That(afterUnchangedFrame, Is.EqualTo(1), + "The control: an unchanged frame replays the compiled plan, so a later increment is the swap."); + Assert.That(afterSwap, Is.EqualTo(2), + "Two bindings swapped in the declaration list are a different plan, not the same one."); + }); + } + + private static long CompilationsAfterFrame(RenderNodeRenderer renderer) + { + renderer.Rasterize().Dispose(); + return renderer.StructuralPlanCacheStatistics.Compilations; + } + + private static RenderResourceSlot[] SlotsOf(OpaqueRenderFragmentPayload payload) + => [.. payload.Description.Resources.Select(static binding => binding.Slot)]; + + private static void WithOpaqueRoot(RenderNode node, Action assert) + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + assert((OpaqueRenderFragmentPayload)GetSingleRoot(graph).Payload!); + } + + private static RenderRequest CreateRequest(RenderRequestOwner owner) + => new(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + owner: owner)); + + private static RenderFragmentReference GetSingleRoot(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } + + private sealed class TwoResourceNode(bool swapped) : RenderNode + { + private static readonly RenderResourceSlot s_firstSlot = new(); + private static readonly RenderResourceSlot s_secondSlot = new(); + + private readonly FirstPayload _first = new(); + private readonly SecondPayload _second = new(); + private bool _swapped = swapped; + + internal static RenderResourceSlot FirstSlot => s_firstSlot; + + internal static RenderResourceSlot SecondSlot => s_secondSlot; + + public void SwapDeclarationOrder() + { + _swapped = !_swapped; + HasChanges = true; + } + + public override void Process(RenderNodeContext context) + { + RenderResource first = context.Borrow(_first); + RenderResource second = context.Borrow(_second); + OpaqueRenderDescription description = OpaqueRenderDescription.Create( + s_domain, + static (session, bounds) => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_domain), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + resources: _swapped + ? [s_secondSlot.Bind(second), s_firstSlot.Bind(first)] + : [s_firstSlot.Bind(first), s_secondSlot.Bind(second)]); + context.Publish(context.OpaqueSource(description)); + } + + internal sealed class FirstPayload; + + internal sealed class SecondPayload; + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/FilterEffectRecordingTransactionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/FilterEffectRecordingTransactionTests.cs new file mode 100644 index 0000000000..fdfa2c9894 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/FilterEffectRecordingTransactionTests.cs @@ -0,0 +1,281 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class FilterEffectRecordingTransactionTests +{ + private const string IdentityShader = "half4 apply(half4 color) { return color; }"; + + [Test] + public void ShaderAndGeometry_UpdateBoundsSynchronouslyInAuthoredOrder() + { + using var context = new FilterEffectContext(new Rect(10, 20, 30, 40)); + ShaderDescription currentPixel = ShaderDescription.CurrentPixel(IdentityShader); + ShaderDescription wholeSource = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Create( + static bounds => bounds.Inflate(new Thickness(2)), + static bounds => bounds.Inflate(new Thickness(2)))); + GeometryDescription geometry = GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Create( + static bounds => bounds.Translate(new Vector(3, 4)), + static bounds => bounds.Translate(new Vector(-3, -4))), + RenderHitTestContract.AnyInput); + + context.Shader(currentPixel); + Rect afterCurrentPixel = context.Bounds; + context.Shader(wholeSource); + Rect afterWholeSource = context.Bounds; + context.Geometry(geometry); + + Assert.Multiple(() => + { + Assert.That(afterCurrentPixel, Is.EqualTo(new Rect(10, 20, 30, 40))); + Assert.That(afterWholeSource, Is.EqualTo(new Rect(8, 18, 34, 44))); + Assert.That(context.Bounds, Is.EqualTo(new Rect(11, 22, 34, 44))); + Assert.That( + context.GetOrderedItems().Select(static item => item.GetType()), + Is.EqualTo(new[] + { + typeof(FEItem_Shader), + typeof(FEItem_Shader), + typeof(FEItem_Geometry), + })); + }); + } + + [Test] + public void InvalidOrThrowingDescriptorAppend_IsAtomic() + { + using var context = new FilterEffectContext(new Rect(0, 0, 20, 10)); + context.Saturate(0.5f); + int originalCount = context.GetOrderedItems().Count; + Rect originalBounds = context.Bounds; + ShaderDescription throwing = ShaderDescription.WholeSource( + "uniform shader src; half4 main(float2 coord) { return src.eval(coord); }", + RenderBoundsContract.Create( + static _ => throw new InvalidOperationException("bounds-failure"), + static bounds => bounds)); + GeometryDescription invalid = GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Create( + static _ => Rect.Invalid, + static bounds => bounds), + RenderHitTestContract.AnyInput); + + Assert.Multiple(() => + { + Assert.That(() => context.Shader(throwing), Throws.Exception.Message.EqualTo("bounds-failure")); + Assert.That(context.GetOrderedItems(), Has.Count.EqualTo(originalCount)); + Assert.That(context.Bounds, Is.EqualTo(originalBounds)); + Assert.That(() => context.Geometry(invalid), Throws.TypeOf()); + Assert.That(context.GetOrderedItems(), Has.Count.EqualTo(originalCount)); + Assert.That(context.Bounds, Is.EqualTo(originalBounds)); + }); + } + + [Test] + public void ThrowingLegacyTransformAppend_IsAtomic() + { + using var context = new FilterEffectContext(new Rect(0, 0, 20, 10)); + context.Saturate(0.5f); + int originalCount = context.GetOrderedItems().Count; + Rect originalBounds = context.Bounds; + + Assert.Multiple(() => + { + Assert.That( + () => context.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, _) => throw new InvalidOperationException("skia-bounds-failure")), + Throws.TypeOf().With.Message.EqualTo("skia-bounds-failure")); + Assert.That(context.GetOrderedItems(), Has.Count.EqualTo(originalCount)); + Assert.That(context.Bounds, Is.EqualTo(originalBounds)); + Assert.That( + () => context.CustomEffect( + 0, + static (_, _) => { }, + static (_, _) => throw new InvalidOperationException("custom-bounds-failure")), + Throws.TypeOf().With.Message.EqualTo("custom-bounds-failure")); + Assert.That(context.GetOrderedItems(), Has.Count.EqualTo(originalCount)); + Assert.That(context.Bounds, Is.EqualTo(originalBounds)); + }); + } + + [Test] + public void NestedApplyTransaction_RollsBackEarlierChildrenWhenLaterChildFails() + { + using var context = new FilterEffectContext(new Rect(0, 0, 10, 10)); + FilterEffect.Resource resource = new Blur().ToResource(CompositionContext.Default); + var first = new CallbackFilterEffect((recording, _) => + recording.Shader(ShaderDescription.CurrentPixel(IdentityShader))); + var second = new CallbackFilterEffect((recording, _) => + { + recording.Geometry(GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + throw new InvalidOperationException("nested-failure"); + }); + var group = new CallbackFilterEffect((recording, childResource) => + { + recording.ApplyTransactional(first, childResource); + recording.ApplyTransactional(second, childResource); + }); + + Assert.That( + () => context.ApplyTransactional(group, resource), + Throws.TypeOf().With.Message.EqualTo("nested-failure")); + Assert.Multiple(() => + { + Assert.That(context.GetOrderedItems(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(new Rect(0, 0, 10, 10))); + }); + } + + [Test] + public void FilterEffectGroup_DirectApplyRollsBackEarlierChildrenWhenLaterChildFails() + { + using var context = new FilterEffectContext(new Rect(0, 0, 10, 10)); + var firstResource = new TrackingDisposable(); + var secondResource = new TrackingDisposable(); + var first = new CallbackFilterEffect((recording, _) => + { + recording.Own(firstResource); + recording.Shader(ShaderDescription.CurrentPixel(IdentityShader)); + }); + var second = new CallbackFilterEffect((recording, _) => + { + recording.Own(secondResource); + recording.Geometry(GeometryDescription.CreateRequestLocal( + static _ => { }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + throw new InvalidOperationException("group-child-failure"); + }); + var group = new FilterEffectGroup { Children = { first, second } }; + FilterEffect.Resource groupResource = group.ToResource(CompositionContext.Default); + + Assert.That( + () => group.ApplyTo(context, groupResource), + Throws.TypeOf().With.Message.EqualTo("group-child-failure")); + Assert.Multiple(() => + { + Assert.That(context.GetOrderedItems(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(new Rect(0, 0, 10, 10))); + Assert.That(firstResource.DisposeCount, Is.EqualTo(1)); + Assert.That(secondResource.DisposeCount, Is.EqualTo(1)); + }); + + context.Dispose(); + Assert.Multiple(() => + { + Assert.That(firstResource.DisposeCount, Is.EqualTo(1)); + Assert.That(secondResource.DisposeCount, Is.EqualTo(1)); + }); + } + + [Test] + public void ApplyTransaction_RenderNodeBoundaryContinuesCleanupAndPreservesPrimaryFailure() + { + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + owner: owner); + using var request = new RenderRequest(options); + var recorder = new RenderRequestRecorder(request); + var transaction = new NodeRecordingTransaction(recorder, new object(), []); + var renderContext = new RenderNodeContext(transaction); + using var context = new FilterEffectContext(new Rect(0, 0, 10, 10), 1, 1, renderContext); + var earlier = new TrackingDisposable(); + var later = new ThrowingDisposable(); + var primary = new InvalidOperationException("primary-apply-failure"); + var effect = new CallbackFilterEffect((recording, _) => + { + recording.Own(earlier); + recording.Own(later); + recording.Shader(ShaderDescription.CurrentPixel(IdentityShader)); + throw primary; + }); + FilterEffect.Resource resource = new Blur().ToResource(CompositionContext.Default); + + InvalidOperationException? thrown = Assert.Throws( + () => context.ApplyTransactional(effect, resource)); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(primary)); + Assert.That(context.GetOrderedItems(), Is.Empty); + Assert.That(context.Bounds, Is.EqualTo(new Rect(0, 0, 10, 10))); + Assert.That(earlier.DisposeCount, Is.EqualTo(1)); + Assert.That(later.DisposeCount, Is.EqualTo(1)); + Assert.That( + primary.Data["FilterEffectResourceRollbackFailure"], + Is.TypeOf()); + Assert.That(owner.CleanupFailures, Has.Length.EqualTo(1)); + Assert.That(owner.CleanupFailures[0].Message, Is.EqualTo("cleanup-failure")); + }); + + Assert.That(() => transaction.Commit(), Throws.Nothing); + owner.Cleanup(); + Assert.Multiple(() => + { + Assert.That(earlier.DisposeCount, Is.EqualTo(1)); + Assert.That(later.DisposeCount, Is.EqualTo(1)); + }); + } + + private sealed class TrackingDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + private sealed class ThrowingDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + throw new InvalidOperationException("cleanup-failure"); + } + } +} + +[SuppressResourceClassGeneration] +internal sealed partial class CallbackFilterEffect( + Action apply) : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => apply(context, resource); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = true; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} + +internal static class FilterEffectRecordingTransactionSlots +{ + internal static readonly RenderResourceSlot Shared = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NestedEffectBrushLoweringTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NestedEffectBrushLoweringTests.cs new file mode 100644 index 0000000000..473d3543aa --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NestedEffectBrushLoweringTests.cs @@ -0,0 +1,91 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +/// +/// Pins that a brush owned by a nested filter effect is lowered into the effect's own segment. +/// +/// +/// A draws a whole drawable, so it is the case most likely to be hoisted out into a +/// second stream input or a nested request. Either would split one effect across two recorded units and change +/// what the planner may fuse, so the recorded shape has to stay the same as a brush-free effect's. +/// +[TestFixture] +[NonParallelizable] +public sealed class NestedEffectBrushLoweringTests +{ + private static IEnumerable NestedEffects() + { + yield return new TestCaseData(new Func(static () => new Blur())) + .SetArgDisplayNames("no brush"); + yield return new TestCaseData(new Func(MakeDrawableBrushShadow)) + .SetArgDisplayNames("drawable brush"); + } + + [TestCaseSource(nameof(NestedEffects))] + public void NestedEffect_RecordsOneSegmentOverOneStreamWithoutANestedRequest(Func factory) + { + RecordGraph(MakeDelayed(factory()), graph => + { + FilterEffectSegmentRenderFragmentPayload[] segments = SegmentsOf(graph); + Assert.Multiple(() => + { + Assert.That(segments, Has.Length.EqualTo(1), + "A nested effect must record as one segment however its brush draws."); + Assert.That(segments[0].StreamInputCount, Is.EqualTo(1), + "A brush must not become a second stream input."); + Assert.That(graph.NestedRequests, Is.Empty, + "A brush must not be hoisted into its own request."); + }); + }); + } + + private static void RecordGraph(FilterEffect effect, Action assert) + { + using var root = new FilterEffectRenderNode(effect.ToResource(CompositionContext.Default)); + root.AddChild(new RectangleRenderNode(new Rect(0, 0, 40, 30), Brushes.Resource.White, null)); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + owner: owner)); + + assert(new RenderRequestRecorder(request).Record(root)); + } + + private static FilterEffectSegmentRenderFragmentPayload[] SegmentsOf(RecordedRenderGraph graph) + => [.. graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Where(static reference => reference.Kind == RenderFragmentKind.FilterEffectSegment) + .Select(static reference => (FilterEffectSegmentRenderFragmentPayload)reference.Payload!)]; + + private static DelayAnimationEffect MakeDelayed(FilterEffect child) + { + var delay = new DelayAnimationEffect(); + delay.Delay.CurrentValue = 0f; + delay.Effect.CurrentValue = child; + return delay; + } + + private static FilterEffect MakeDrawableBrushShadow() + { + var content = new RectShape(); + content.Width.CurrentValue = 10; + content.Height.CurrentValue = 10; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + + var shadow = new FlatShadow(); + shadow.Length.CurrentValue = 4; + shadow.Brush.CurrentValue = brush; + return shadow; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NodeRecordingTransactionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NodeRecordingTransactionTests.cs new file mode 100644 index 0000000000..e9cd68c82c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/NodeRecordingTransactionTests.cs @@ -0,0 +1,622 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class NodeRecordingTransactionTests +{ + [Test] + public void Commit_PublishesFragmentsResourcesAndCachePolicyAtomically() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + var transaction = new NodeRecordingTransaction(host, new object(), []); + RenderFragmentHandle first = CreateSource(transaction, new Rect(0, 0, 10, 10)); + RenderFragmentHandle second = CreateSource(transaction, new Rect(10, 0, 10, 10)); + var resource = new TrackedDisposable("owned"); + RenderResource token = transaction.Own(resource); + + transaction.Publish(first); + transaction.Publish(second); + transaction.DisableRenderCache(); + + Assert.Multiple(() => + { + Assert.That(host.Commits, Is.Empty, "A checkpoint must not leak partial graph state before commit."); + Assert.That(host.IsRenderCacheEnabled, Is.True); + Assert.That(token.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Pending)); + }); + + IReadOnlyList publications = transaction.Commit(); + + Assert.Multiple(() => + { + Assert.That(host.Commits, Has.Count.EqualTo(1)); + Assert.That(host.Commits[0].Fragments, Has.Length.EqualTo(2)); + Assert.That(host.Commits[0].Publications, Has.Length.EqualTo(2)); + Assert.That(publications, Is.EqualTo(host.Commits[0].Publications)); + Assert.That(host.IsRenderCacheEnabled, Is.True, + "A node opting out of the cache must not move the request-wide policy."); + Assert.That(token.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Committed)); + Assert.That(resource.DisposeCount, Is.Zero, "A committed owned resource belongs to the request."); + }); + + owner.Cleanup(); + Assert.That(resource.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public void Commit_IgnoresUnpublishedFragmentsDuringFanOutValidation() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + var transaction = new NodeRecordingTransaction(host, new object(), []); + var context = new RenderNodeContext(transaction); + var publishedBounds = new Rect(0, 0, 10, 10); + RenderFragmentHandle published = CreateSource(transaction, publishedBounds); + RenderFragmentHandle discarded = context.Blend(published, BlendMode.SrcOver); + _ = context.Opacity(discarded, 0.25f); + _ = context.Opacity(discarded, 0.75f); + transaction.Publish(published); + + Assert.That(() => transaction.Commit(), Throws.Nothing); + Assert.Multiple(() => + { + Assert.That(host.Commits, Has.Count.EqualTo(1)); + Assert.That(host.Commits[0].Fragments, Has.Length.EqualTo(4), + "Committed but unpublished fragments remain available for skipped-outcome reconciliation."); + Assert.That(host.Commits[0].Fragments[0].Reference.Bounds, Is.EqualTo(publishedBounds)); + }); + } + + + // Recording allocates one opacity fragment per drawable per pass. The SkSL text is a compile-time constant, + // so a pass must neither re-tokenize it nor mint a description per fragment. + [Test] + public void Opacity_ReusesOneValidatedFusionDescriptionPerNormalizedValue() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + var transaction = new NodeRecordingTransaction(host, new object(), []); + var context = new RenderNodeContext(transaction); + var bounds = new Rect(0, 0, 10, 10); + RenderFragmentHandle source = CreateSource(transaction, bounds); + + for (int i = 0; i < 8; i++) + transaction.Publish(context.Opacity(source, 0.375f)); + + transaction.Publish(context.Opacity(source, 0.75f)); + transaction.Publish(context.Opacity(source, 4f)); + transaction.Publish(context.Opacity(source, 1f)); + transaction.Commit(); + + ShaderDescription[] descriptions = host.Commits[0].Fragments + .Select(static entry => entry.Reference.Payload) + .OfType() + .Select(static payload => payload.FusionDescription) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(descriptions, Has.Length.EqualTo(11)); + Assert.That( + descriptions, + Has.All.Matches( + item => ReferenceEquals(item.Source, descriptions[0].Source)), + "The constant fusion source must be normalized and validated once."); + Assert.That( + descriptions.Take(8), + Has.All.SameAs(descriptions[0]), + "Fragments sharing one opacity must share one immutable description."); + Assert.That(descriptions[8], Is.Not.SameAs(descriptions[0])); + Assert.That( + descriptions[9], + Is.SameAs(descriptions[10]), + "An out-of-range opacity clamps onto the description of its normalized value."); + Assert.That( + descriptions[0], + Is.SameAs(OpacityRenderNode.CreateFusionDescription(0.375f))); + }); + } + + [Test] + public void Rollback_DiscardsEveryPartialEffectAndRestoresCacheDisablement() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + var transaction = new NodeRecordingTransaction(host, new object(), []); + RenderFragmentHandle fragment = CreateSource(transaction, new Rect(0, 0, 10, 10)); + var resource = new TrackedDisposable("owned"); + RenderResource token = transaction.Own(resource); + var primary = new InvalidOperationException("process failed"); + + transaction.Publish(fragment); + transaction.DisableRenderCache(); + + InvalidOperationException? thrown = Assert.Throws(() => transaction.Rollback(primary)); + + Assert.Multiple(() => + { + Assert.That(thrown, Is.SameAs(primary)); + Assert.That(transaction.State, Is.EqualTo(NodeRecordingTransactionState.RolledBack)); + Assert.That(host.Commits, Is.Empty); + Assert.That(host.IsRenderCacheEnabled, Is.True, + "A rolled-back cache disablement must not escape its checkpoint."); + Assert.That(token.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + Assert.That(resource.DisposeCount, Is.EqualTo(1)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + }); + } + + [Test] + public void DisableRenderCache_DoesNotEscapeTheCheckpointThatRequestedIt() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request) { ChildAction = static context => context.DisableRenderCache() }; + var parent = new NodeRecordingTransaction(host, new object(), []); + using var childNode = new MemoryNode(0); + + _ = parent.RecordNode(childNode, [], subtree: false); + + Assert.Multiple(() => + { + Assert.That(parent.IsRenderCacheEnabled, Is.True, + "A committed child must not decide the cache policy for the checkpoint that recorded it."); + Assert.That(host.IsRenderCacheEnabled, Is.True); + }); + + parent.Commit(); + + Assert.That(host.IsRenderCacheEnabled, Is.True, + "Committing a checkpoint must leave the request-wide policy where it started."); + } + + [Test] + public void DisableRenderCache_ReachesTheNodesRecordedInsideIt() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + bool? childSawCacheEnabled = null; + var host = new RecordingHost(request) + { + ChildAction = context => childSawCacheEnabled = context.IsRenderCacheEnabled, + }; + var parent = new NodeRecordingTransaction(host, new object(), []); + using var childNode = new MemoryNode(0); + + parent.DisableRenderCache(); + _ = parent.RecordNode(childNode, [], subtree: false); + + Assert.That(childSawCacheEnabled, Is.False, + "A node recorded inside a disabled checkpoint is part of the output that must not be cached."); + } + + [Test] + public void NestedRecording_UsesFreshChildAndParentFacadeHandles() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request) + { + ChildAction = static context => context.PassThrough(), + }; + var parent = new NodeRecordingTransaction(host, new object(), []); + RenderFragmentHandle parentInput = CreateSource(parent, new Rect(1, 2, 30, 40)); + using var childNode = new MemoryNode(0); + + IReadOnlyList mapped = parent.RecordNode( + childNode, + [parentInput], + subtree: false); + + RenderFragmentHandle childFacade = host.LastChildInputs.Single(); + RenderFragmentHandle parentFacade = mapped.Single(); + Assert.Multiple(() => + { + Assert.That(childFacade, Is.Not.SameAs(parentInput)); + Assert.That(parentFacade, Is.Not.SameAs(parentInput)); + Assert.That(parentFacade, Is.Not.SameAs(childFacade)); + Assert.That(parentFacade.TryGetMetadata(out RenderFragmentMetadata parentFacadeMetadata), Is.True); + Assert.That(parentInput.TryGetMetadata(out RenderFragmentMetadata parentInputMetadata), Is.True); + Assert.That(parentFacadeMetadata, Is.EqualTo(parentInputMetadata)); + Assert.That(() => childFacade.TryGetMetadata(out _), Throws.TypeOf(), + "Child facades must seal when the child checkpoint ends."); + Assert.That(() => parentInput.TryGetMetadata(out _), Throws.Nothing, + "The original parent handle remains active."); + }); + } + + [Test] + public void NestedRequest_IsStagedUntilCommitAndDisposedOnRollback() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + using var root = new MemoryNode(0); + using var committedBinding = new NestedRenderTargetBinding(); + + var committedTransaction = new NodeRecordingTransaction(host, new object(), []); + RecordedNestedRenderRequest committedNested = committedTransaction.RecordNestedRequest( + root, + request.Options.CreateNested(committedBinding)); + + Assert.That(host.Commits, Is.Empty, + "A nested graph must remain checkpoint-local before the parent commits."); + + committedTransaction.Commit(); + + Assert.Multiple(() => + { + Assert.That(host.Commits, Has.Count.EqualTo(1)); + Assert.That(host.Commits[0].NestedRequests, Has.Length.EqualTo(1)); + Assert.That(host.Commits[0].NestedRequests[0], Is.SameAs(committedNested)); + Assert.That(committedNested.Request.State, Is.Not.EqualTo(RenderRequestState.Disposed)); + }); + committedNested.Request.Dispose(); + + using var rolledBackBinding = new NestedRenderTargetBinding(); + var rolledBackTransaction = new NodeRecordingTransaction(host, new object(), []); + RecordedNestedRenderRequest rolledBackNested = rolledBackTransaction.RecordNestedRequest( + root, + request.Options.CreateNested(rolledBackBinding)); + var primary = new InvalidOperationException("rollback nested request"); + + InvalidOperationException? failure = Assert.Throws( + () => rolledBackTransaction.Rollback(primary)); + + Assert.Multiple(() => + { + Assert.That(failure, Is.SameAs(primary)); + Assert.That(host.Commits, Has.Count.EqualTo(1), + "Rollback must not publish the staged nested graph."); + Assert.That(rolledBackNested.Request.State, Is.EqualTo(RenderRequestState.Disposed)); + }); + } + + [Test] + public void CommitAndRollback_RejectRetainedContextsAndHandles() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + + var committedTransaction = new NodeRecordingTransaction(host, new object(), []); + var committedContext = new RenderNodeContext(committedTransaction); + RenderFragmentHandle committedHandle = CreateSource(committedTransaction, new Rect(0, 0, 1, 1)); + committedTransaction.Publish(committedHandle); + committedTransaction.Commit(); + + var rolledBackTransaction = new NodeRecordingTransaction(host, new object(), []); + var rolledBackContext = new RenderNodeContext(rolledBackTransaction); + RenderFragmentHandle rolledBackHandle = CreateSource(rolledBackTransaction, new Rect(0, 0, 1, 1)); + var primary = new InvalidOperationException("rollback"); + InvalidOperationException? rollbackFailure = Assert.Throws( + () => rolledBackTransaction.Rollback(primary)); + Assert.That(rollbackFailure, Is.SameAs(primary)); + + Assert.Multiple(() => + { + Assert.That( + () => committedHandle.TryGetMetadata(out _), + Throws.TypeOf()); + Assert.That(() => _ = committedContext.Inputs, Throws.TypeOf()); + Assert.That(() => _ = committedContext.Intent, Throws.TypeOf()); + Assert.That(() => _ = committedContext.Purpose, Throws.TypeOf()); + Assert.That(() => _ = committedContext.OutputScale, Throws.TypeOf()); + Assert.That(() => _ = committedContext.MaxWorkingScale, Throws.TypeOf()); + Assert.That(() => _ = committedContext.IsRenderCacheEnabled, Throws.TypeOf()); + Assert.That( + () => committedContext.TryCalculateInputBounds(out _), + Throws.TypeOf()); + Assert.That(() => committedContext.DisableRenderCache(), Throws.TypeOf()); + Assert.That( + () => rolledBackHandle.TryGetMetadata(out _), + Throws.TypeOf()); + Assert.That(() => _ = rolledBackContext.Inputs, Throws.TypeOf()); + Assert.That(() => rolledBackContext.DisableRenderCache(), Throws.TypeOf()); + }); + } + + [Test] + public void Rollback_ReleasesOwnedResourcesInReverseOrderAndPreservesPrimaryFailure() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var host = new RecordingHost(request); + var transaction = new NodeRecordingTransaction(host, new object(), []); + var disposalOrder = new List(); + var first = new TrackedDisposable("first", disposalOrder); + var secondCleanupFailure = new InvalidOperationException("second cleanup failed"); + var second = new TrackedDisposable("second", disposalOrder, secondCleanupFailure); + var third = new TrackedDisposable("third", disposalOrder); + var primary = new InvalidOperationException("recording failed"); + + transaction.Own(first); + transaction.Own(second); + transaction.Own(third); + + InvalidOperationException? rollbackFailure = Assert.Throws( + () => transaction.Rollback(primary)); + Assert.That(rollbackFailure, Is.SameAs(primary)); + + Assert.Multiple(() => + { + Assert.That(disposalOrder, Is.EqualTo(new[] { "third", "second", "first" })); + Assert.That(first.DisposeCount, Is.EqualTo(1)); + Assert.That(second.DisposeCount, Is.EqualTo(1)); + Assert.That(third.DisposeCount, Is.EqualTo(1)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primary)); + Assert.That(owner.SecondaryFailures, Has.Length.EqualTo(1)); + Assert.That(owner.SecondaryFailures[0], Is.SameAs(secondCleanupFailure)); + Assert.That(owner.CleanupFailures, Is.EqualTo(new[] { secondCleanupFailure })); + }); + + owner.Cleanup(); + Assert.Multiple(() => + { + Assert.That(first.DisposeCount, Is.EqualTo(1)); + Assert.That(second.DisposeCount, Is.EqualTo(1)); + Assert.That(third.DisposeCount, Is.EqualTo(1)); + }); + } + + [Test] + public void RollbackResources_ContinuesAfterCleanupFailureAndReportsAllFailures() + { + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var transaction = new NodeRecordingTransaction(new RecordingHost(request), new object(), []); + var disposalOrder = new List(); + var first = new TrackedDisposable("first", disposalOrder); + var secondFailure = new InvalidOperationException("second failed"); + var second = new TrackedDisposable("second", disposalOrder, secondFailure); + var third = new TrackedDisposable("third", disposalOrder); + RenderResource firstToken = transaction.Own(first); + RenderResource secondToken = transaction.Own(second); + RenderResource thirdToken = transaction.Own(third); + + AggregateException? failure = Assert.Throws( + () => transaction.RollbackResources([firstToken, secondToken, thirdToken])); + + Assert.Multiple(() => + { + Assert.That(disposalOrder, Is.EqualTo(new[] { "third", "second", "first" })); + Assert.That(first.DisposeCount, Is.EqualTo(1)); + Assert.That(second.DisposeCount, Is.EqualTo(1)); + Assert.That(third.DisposeCount, Is.EqualTo(1)); + Assert.That(failure!.InnerExceptions, Is.EqualTo(new[] { secondFailure })); + Assert.That(firstToken.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + Assert.That(secondToken.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + Assert.That(thirdToken.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + }); + } + + [Test] + public void Recorder_RejectsDirectAndIndirectSubtreeCyclesWithAPath() + { + var direct = new ContainerRenderNode(); + direct.AddChild(direct); + try + { + using var directOwner = new RenderRequestOwner(); + using var directRequest = CreateRequest(directOwner); + var directRecorder = new RenderRequestRecorder(directRequest); + + InvalidOperationException? directFailure = Assert.Throws( + () => directRecorder.Record(direct)); + + Assert.That(directFailure!.Message, Does.Contain(nameof(ContainerRenderNode))); + Assert.That(directFailure.Message, Does.Contain("->")); + } + finally + { + direct.RemoveChild(direct); + direct.Dispose(); + } + + var first = new ContainerRenderNode(); + var second = new ContainerRenderNode(); + first.AddChild(second); + second.AddChild(first); + try + { + using var indirectOwner = new RenderRequestOwner(); + using var indirectRequest = CreateRequest(indirectOwner); + var indirectRecorder = new RenderRequestRecorder(indirectRequest); + + InvalidOperationException? indirectFailure = Assert.Throws( + () => indirectRecorder.Record(first)); + + Assert.That(indirectFailure!.Message, Does.Contain("->")); + Assert.That(indirectFailure.Message.Split(nameof(ContainerRenderNode)).Length - 1, + Is.GreaterThanOrEqualTo(3)); + } + finally + { + second.RemoveChild(first); + first.RemoveChild(second); + first.Dispose(); + second.Dispose(); + } + } + + [Test] + public void Recorder_RejectsDirectAndIndirectRecordNodeCyclesWithAPath() + { + using var directOwner = new RenderRequestOwner(); + using var directRequest = CreateRequest(directOwner); + using var direct = new MemoryNode(0); + var directRecorder = new RenderRequestRecorder(directRequest); + var directTransaction = new NodeRecordingTransaction(directRecorder, new object(), []); + + InvalidOperationException? directFailure; + using (directOwner.RecordingFamily.Enter(direct)) + { + directFailure = Assert.Throws( + () => directTransaction.RecordNode(direct, [], subtree: false)); + } + + using var indirectOwner = new RenderRequestOwner(); + using var indirectRequest = CreateRequest(indirectOwner); + using var first = new MemoryNode(1); + using var second = new MemoryNode(2); + var indirectRecorder = new RenderRequestRecorder(indirectRequest); + var indirectTransaction = new NodeRecordingTransaction(indirectRecorder, new object(), []); + + InvalidOperationException? indirectFailure; + using (indirectOwner.RecordingFamily.Enter(first)) + using (indirectOwner.RecordingFamily.Enter(second)) + { + indirectFailure = Assert.Throws( + () => indirectTransaction.RecordNode(first, [], subtree: false)); + } + + Assert.Multiple(() => + { + Assert.That(directFailure!.Message, Does.Contain(nameof(MemoryNode))); + Assert.That(directFailure.Message, Does.Contain("->")); + Assert.That(indirectFailure!.Message, Does.Contain(nameof(MemoryNode))); + Assert.That(indirectFailure.Message, Does.Contain("->")); + }); + } + + [Test] + public void Recorder_RejectsASeparateTargetCycleUsingTheRequestFamilyGuard() + { + using var owner = new RenderRequestOwner(); + RenderRequestOptions options = CreateOptions(owner); + using var request = new RenderRequest(options); + using var node = new MemoryNode(0); + using var binding = new NestedRenderTargetBinding(); + var recorder = new RenderRequestRecorder(request); + + InvalidOperationException? failure; + using (owner.RecordingFamily.Enter(node)) + { + failure = Assert.Throws( + () => recorder.RecordNestedRequest(node, options.CreateNested(binding))); + } + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain(nameof(MemoryNode))); + Assert.That(failure.Message, Does.Contain("->")); + }); + } + + [Test] + public void Recorder_AllowsSequentialReuseAfterTheActiveScopeEnds() + { + var root = new ContainerRenderNode(); + var repeated = new MemoryNode(0); + root.AddChild(repeated); + root.AddChild(repeated); + using var owner = new RenderRequestOwner(); + using var request = CreateRequest(owner); + var recorder = new RenderRequestRecorder(request); + + Assert.That(() => recorder.Record(root), Throws.Nothing); + + root.RemoveChild(repeated); + root.RemoveChild(repeated); + root.Dispose(); + repeated.Dispose(); + } + + private static RenderRequest CreateRequest(RenderRequestOwner owner) + { + return new RenderRequest(CreateOptions(owner)); + } + + private static RenderRequestOptions CreateOptions(RenderRequestOwner owner) + { + return new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + owner: owner); + } + + private static RenderFragmentHandle CreateSource(NodeRecordingTransaction transaction, Rect bounds) + { + return transaction.CreateFragment( + RenderFragmentKind.OpaqueSource, + bounds, + EffectiveScale.Unbounded, + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs: null, + payload: null, + hitTest: bounds.Contains); + } + + private sealed class RecordingHost(RenderRequest request) : IRenderRequestRecordingHost + { + public RenderRequest Request { get; } = request; + + public bool IsRenderCacheEnabled { get; init; } = true; + + public Action? ChildAction { get; init; } + + public IReadOnlyList LastChildInputs { get; private set; } = []; + + public List Commits { get; } = []; + + public IReadOnlyList RecordNode( + NodeRecordingTransaction parent, + RenderNode node, + IReadOnlyList inputs, + bool subtree) + { + var child = new NodeRecordingTransaction(this, node, inputs, parent); + LastChildInputs = child.Inputs; + ChildAction?.Invoke(new RenderNodeContext(child)); + return child.Commit(); + } + + public RecordedNestedRenderRequest RecordNestedRequest( + RenderNode root, + RenderRequestOptions options) + { + var nestedRequest = new RenderRequest(options, Request); + RecordedRenderGraph graph = new RecordedRenderGraphBuilder(nestedRequest.Id).Build(); + return new RecordedNestedRenderRequest(nestedRequest, graph); + } + + public void Commit(NodeRecordingCommit commit) + { + Commits.Add(commit); + foreach (RenderResource resource in commit.Resources) + { + Request.Options.Owner.ResourceRegistry.Commit(resource); + } + + } + } + + private sealed class TrackedDisposable( + string name, + List? disposalOrder = null, + Exception? disposeFailure = null) : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() + { + DisposeCount++; + disposalOrder?.Add(name); + if (disposeFailure is not null) + throw disposeFailure; + } + } + +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/OpaqueRenderDescriptionDirectReplayTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/OpaqueRenderDescriptionDirectReplayTests.cs new file mode 100644 index 0000000000..875be52c17 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/OpaqueRenderDescriptionDirectReplayTests.cs @@ -0,0 +1,128 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.Media.Source; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class OpaqueRenderDescriptionDirectReplayTests +{ + [Test] + public void VectorPaintedSource_WithPlainBrush_DeclaresDirectMaterialization() + { + using var node = new RectangleRenderNode( + new Rect(2, 3, 24, 16), + Brushes.Resource.White, + null); + + RecordSingleOpaqueSource(node, static (reference, description) => + { + Assert.Multiple(() => + { + Assert.That(description.DirectReplay, Is.Not.Null); + Assert.That(description.HasDirectReplayMaterializationContract, Is.True); + Assert.That(reference.HasOpaqueExternalWork, Is.False); + }); + }); + } + + [Test] + public void VectorPaintedSource_WithDrawableBrush_RequiresOpaqueExternalWork() + { + using Brush.Resource brush = CreateDrawableBrush(); + using var node = new RectangleRenderNode( + new Rect(2, 3, 24, 16), + brush, + null); + + RecordSingleOpaqueSource(node, static (reference, description) => + { + Assert.Multiple(() => + { + Assert.That(description.DirectReplay, Is.Null); + Assert.That(description.HasDirectReplayMaterializationContract, Is.False); + Assert.That(reference.HasOpaqueExternalWork, Is.True); + }); + }); + } + + [Test] + public void ConcreteImageSource_DirectReplayDoesNotDeclareDirectMaterialization() + { + var imageSource = new ImageSource(); + imageSource.ReadFrom(TestMediaHelper.CreateTestImageUri(24, 16, Colors.White)); + using ImageSource.Resource source = imageSource.ToResource(CompositionContext.Default); + using var node = new ImageSourceRenderNode(source, Brushes.Resource.White, null); + + RecordSingleOpaqueSource(node, static (reference, description) => + { + Assert.Multiple(() => + { + Assert.That(description.DirectReplay, Is.Not.Null); + Assert.That(description.HasDirectReplayMaterializationContract, Is.False); + Assert.That(reference.HasOpaqueExternalWork, Is.True); + }); + }); + } + + [Test] + public void WithoutDirectReplay_ClearsDirectMaterializationContract() + { + using var node = new RectangleRenderNode( + new Rect(2, 3, 24, 16), + Brushes.Resource.White, + null); + + RecordSingleOpaqueSource(node, static (_, description) => + { + OpaqueRenderDescription withoutDirectReplay = description.WithoutDirectReplay(); + + Assert.Multiple(() => + { + Assert.That(description.DirectReplay, Is.Not.Null); + Assert.That(description.HasDirectReplayMaterializationContract, Is.True); + Assert.That(withoutDirectReplay, Is.Not.SameAs(description)); + Assert.That(withoutDirectReplay.DirectReplay, Is.Null); + Assert.That(withoutDirectReplay.HasDirectReplayMaterializationContract, Is.False); + }); + }); + } + + private static Brush.Resource CreateDrawableBrush() + { + var content = new RectShape(); + content.Width.CurrentValue = 8; + content.Height.CurrentValue = 8; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + return brush.ToResource(CompositionContext.Default); + } + + private static void RecordSingleOpaqueSource( + RenderNode node, + Action assert) + { + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: new Rect(0, 0, 64, 64), + outputScale: 1, + maxWorkingScale: 1, + owner: owner)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentId rootId = graph.PublicationRoots.Single(); + RenderFragmentReference reference = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(fragment => fragment.Id == rootId); + var payload = (OpaqueRenderFragmentPayload)reference.Payload!; + + Assert.That(reference.Kind, Is.EqualTo(RenderFragmentKind.OpaqueSource)); + assert(reference, payload.Description); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RawSessionSlotResourceTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RawSessionSlotResourceTests.cs new file mode 100644 index 0000000000..6b68b399e3 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RawSessionSlotResourceTests.cs @@ -0,0 +1,157 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +/// +/// Pins that a raw definition can reach its bound resource through the slot it declared. +/// +/// +/// A raw definition declares slots and binds a different token on each call, and its callback is static, so +/// without slot addressing the only way in is to carry the exact token in the call state. That makes the +/// declared binding validation-only and leaves two places the callback's resource can come from, which can +/// disagree. +/// +[TestFixture] +public sealed class RawSessionSlotResourceTests +{ + private static readonly Rect s_domain = new(0, 0, 8, 8); + + [Test] + public void ARawScopeReachesTheResourceBoundToItsDeclaredSlot() + { + using var node = new SlotAddressingNode(throughScope: true); + + Render(node); + + Assert.That(node.Reached, Is.EqualTo(new[] { "bound" })); + } + + [Test] + public void ARawCommandReachesTheResourceBoundToItsDeclaredSlot() + { + using var node = new SlotAddressingNode(throughScope: false); + + Render(node); + + Assert.That(node.Reached, Is.EqualTo(new[] { "bound" })); + } + + [Test] + public void RebindingTheSlotChangesWhatTheSameDefinitionReaches() + { + using var node = new SlotAddressingNode(throughScope: true); + + Render(node); + node.RebindToTheOtherPayload(); + Render(node); + + Assert.That( + node.Reached, + Is.EqualTo(new[] { "bound", "rebound" }), + "The slot is what the callback names, so a new binding has to reach it without the callback " + + "changing."); + } + + private static void Render(RenderNode node) + { + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = s_domain, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + renderer.Rasterize().Dispose(); + } + + private sealed class SlotAddressingNode(bool throughScope) : RenderNode + { + private static readonly RenderResourceSlot s_slot = new(); + + private static readonly RawTargetScopeDefinition s_scope = + RawTargetScopeDefinition.Create( + static (session, _) => + { + session.UseResource(s_slot, static payload => payload.Reach()); + session.ReplayInput(); + }, + RenderBoundsContract.FullInput, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + resources: [s_slot]); + + private static readonly RawTargetCommandDefinition s_command = + RawTargetCommandDefinition.Create( + static (session, _) => session.UseResource(s_slot, static payload => payload.Reach()), + s_domain, + RenderHitTestContract.OutputBounds, + resources: [s_slot]); + + private readonly Payload _bound = new("bound"); + private readonly Payload _rebound = new("rebound"); + private bool _useRebound; + + public List Reached { get; } = []; + + public void RebindToTheOtherPayload() + { + _useRebound = true; + HasChanges = true; + } + + public override void Process(RenderNodeContext context) + { + Payload payload = _useRebound ? _rebound : _bound; + payload.Reached = Reached; + RenderResource token = context.Borrow(payload); + + if (!throughScope) + { + context.Publish(context.RawTargetCommand(s_command.Call(s_domain, [s_slot.Bind(token)]))); + return; + } + + // A scope needs something to replay, and this inner command must not reach the slot itself or + // the assertion could not tell which of the two sessions addressed it. + RenderFragmentHandle inert = context.RawTargetCommand( + RawTargetCommandDescription.CreateRequestLocal( + static _ => { }, + s_domain, + RenderHitTestContract.OutputBounds)); + context.Publish(context.RawTargetScope(inert, s_scope.Call(s_domain, [s_slot.Bind(token)]))); + } + } + + private sealed class Payload(string name) + { + public List? Reached { get; set; } + + public void Reach() => Reached?.Add(name); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs new file mode 100644 index 0000000000..0737a85bf9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs @@ -0,0 +1,756 @@ +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class RecordingSideEffectTests +{ + private static readonly MetadataReference[] s_semanticReferences = CreateSemanticReferences(); + + private static readonly string[] s_forbiddenEagerInvocations = + [ + "Acquire", + "CreateRenderTarget", + "CreateSkiaSurface", + "Decode", + "Flush", + "GetFrame", + "GetRenderTarget", + "Initialize", + "Pull", + "PullToRoot", + "Rasterize", + "Read", + "ReadAudio", + "ReadFrame", + "ReadPixels", + "ReadVideo", + "Render", + "RenderDrawableToTarget", + "RenderFallbackEllipse", + "Resize", + "Submit", + "Synchronize", + "UseSnapshot", + "Wait", + ]; + + private static readonly string[] s_forbiddenEagerConstructions = + [ + "ImmediateCanvas", + "Renderer", + "Renderer3D", + "RenderNodeProcessor", + "RenderNodeRenderer", + "RenderTarget", + ]; + + [Test] + public void EveryProductionProcessOverride_DefersGpuMediaAndNestedExecution() + { + string repositoryRoot = FindRepositoryRoot(); + SourceMethod[] overrides = EnumerateProductionProcessOverrides(repositoryRoot).ToArray(); + SourceFinding[] findings = overrides + .SelectMany(FindEagerExecution) + .OrderBy(static finding => finding.RelativePath, StringComparer.Ordinal) + .ThenBy(static finding => finding.Line) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(overrides, Has.Length.EqualTo(31), + "The recording probe must cover the 28 surviving baseline overrides, both new request facades, " + + "and the group's content-isolation node."); + Assert.That(findings, Is.Empty, + "RenderNode.Process must only capture immutable CPU state and descriptions; execution belongs in " + + $"deferred callbacks.{Environment.NewLine}{FormatFindings(findings)}"); + }); + } + + [Test] + public void RecordingDeferredShapes_LeavesEveryExecutionProbeAtZero() + { + var tripwire = new SideEffectTripwire(); + var targetFactory = new CountingTargetFactory(); + using var node = new DeferredShapeProbeNode(tripwire); + using var renderer = new RenderNodeRenderer(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = DeferredShapeProbeNode.Bounds, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = targetFactory, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(tripwire.Counts.Values, Is.All.Zero, + "Recording and metadata resolution must not execute any deferred callback."); + Assert.That(targetFactory.CreateCalls, Is.Zero); + }); + } + + private static IEnumerable EnumerateProductionProcessOverrides(string repositoryRoot) + { + string sourceRoot = Path.Combine(repositoryRoot, "src"); + foreach (string path in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)) + { + string relativePath = NormalizePath(Path.GetRelativePath(repositoryRoot, path)); + if (HasPathSegment(relativePath, "bin") || HasPathSegment(relativePath, "obj")) + continue; + + SourceText text = SourceText.From(File.ReadAllText(path)); + SyntaxTree tree = CSharpSyntaxTree.ParseText( + text, + CSharpParseOptions.Default.WithDocumentationMode(DocumentationMode.Parse), + relativePath); + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(); + SemanticModel semanticModel = CSharpCompilation.Create( + $"RecordingSideEffectProbe_{Path.GetFileNameWithoutExtension(path)}", + [tree], + s_semanticReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .GetSemanticModel(tree, ignoreAccessibility: true); + foreach (MethodDeclarationSyntax method in root.DescendantNodes() + .OfType() + .Where(IsRenderNodeProcessOverride)) + { + yield return new SourceMethod(relativePath, text, method, semanticModel); + } + } + } + + private static IEnumerable FindEagerExecution(SourceMethod source) + { + var invocationNames = new HashSet(s_forbiddenEagerInvocations, StringComparer.Ordinal); + var constructionNames = new HashSet(s_forbiddenEagerConstructions, StringComparer.Ordinal); + SyntaxNode[] eagerNodes = EnumerateSynchronousBodies(source) + .SelectMany(EnumerateExecutableNodes) + .ToArray(); + + foreach (InvocationExpressionSyntax invocation in eagerNodes.OfType()) + { + string? name = GetInvokedName(invocation); + if (name is not null + && invocationNames.Contains(name) + && !IsCpuNodeDescriptionRender(invocation, name)) + { + yield return source.ToFinding( + invocation, + $"eager invocation '{name}'"); + } + + if (name == "Snapshot" && IsNativeSnapshotInvocation(invocation, source.SemanticModel)) + { + yield return source.ToFinding(invocation, "eager surface/target snapshot"); + } + + if ((name is null || !invocationNames.Contains(name)) + && IsTargetFactoryInvocation(invocation, source.SemanticModel)) + { + yield return source.ToFinding(invocation, "eager target-factory access"); + } + } + + foreach (ObjectCreationExpressionSyntax creation in eagerNodes.OfType()) + { + if (creation.Type is null) continue; + string? name = creation.Type.DescendantTokens() + .LastOrDefault(static token => token.IsKind(SyntaxKind.IdentifierToken)) + .ValueText; + if (name is not null && constructionNames.Contains(name)) + { + yield return source.ToFinding(creation, $"eager construction '{name}'"); + } + } + foreach (ImplicitObjectCreationExpressionSyntax creation in eagerNodes.OfType()) + { + ITypeSymbol? type = source.SemanticModel.GetTypeInfo(creation).Type; + string? name = type?.ToDisplayString().Split('.').LastOrDefault(); + if (name is not null && constructionNames.Contains(name)) + { + yield return source.ToFinding(creation, $"eager construction '{name}'"); + } + } + + foreach (IdentifierNameSyntax identifier in eagerNodes.OfType()) + { + if (identifier.Identifier.ValueText == "GraphicsContextFactory") + { + yield return source.ToFinding(identifier, "eager GPU-context access"); + } + + } + + Dictionary delegateTargets = new(StringComparer.Ordinal); + foreach (LocalDeclarationStatementSyntax declaration in eagerNodes.OfType()) + { + foreach (VariableDeclaratorSyntax variable in declaration.Declaration.Variables) + { + if (variable.Initializer?.Value is IdentifierNameSyntax methodGroup + && source.SemanticModel.GetSymbolInfo(methodGroup).Symbol is IMethodSymbol target) + { + delegateTargets[variable.Identifier.ValueText] = target.Name; + } + } + } + if (delegateTargets.Count > 0) + { + foreach (InvocationExpressionSyntax invocation in eagerNodes.OfType()) + { + if (invocation.Expression is IdentifierNameSyntax identifier + && delegateTargets.TryGetValue(identifier.Identifier.ValueText, out string? targetName) + && targetName is not null + && invocationNames.Contains(targetName) + && !IsCpuNodeDescriptionRender(invocation, targetName)) + { + yield return source.ToFinding( + invocation, + $"eager delegate invocation '{targetName}'"); + } + } + } + } + + private static IEnumerable EnumerateSynchronousBodies(SourceMethod source) + { + var pending = new Queue(); + var visited = new HashSet<(SyntaxTree Tree, int Start, int Length)>(); + pending.Enqueue(source.Method); + while (pending.TryDequeue(out SyntaxNode? body)) + { + var key = (body.SyntaxTree, body.SpanStart, body.Span.Length); + if (!visited.Add(key)) + continue; + yield return body; + + IEnumerable invocations = EnumerateExecutableNodes(body) + .OfType(); + foreach (InvocationExpressionSyntax invocation in invocations) + { + SymbolInfo symbolInfo = source.SemanticModel.GetSymbolInfo(invocation); + IEnumerable targets = symbolInfo.Symbol is IMethodSymbol target + ? [target] + : symbolInfo.CandidateSymbols.OfType(); + foreach (SyntaxReference reference in targets.SelectMany( + static method => method.DeclaringSyntaxReferences)) + { + SyntaxNode declaration = reference.GetSyntax(); + if (declaration.SyntaxTree != source.Method.SyntaxTree) + continue; + if (declaration is MethodDeclarationSyntax or LocalFunctionStatementSyntax) + pending.Enqueue(declaration); + } + + string? invokedName = GetInvokedName(invocation); + if (invokedName is null) + continue; + MethodDeclarationSyntax? declaringMethod = + invocation.FirstAncestorOrSelf(); + foreach (LocalFunctionStatementSyntax localFunction in (declaringMethod?.DescendantNodes() + .OfType() + .Where(local => !local.Ancestors() + .TakeWhile(ancestor => ancestor != declaringMethod) + .OfType() + .Any()) + ?? []) + .Where(local => string.Equals( + local.Identifier.ValueText, + invokedName, + StringComparison.Ordinal))) + { + pending.Enqueue(localFunction); + } + } + + IEnumerable identifiers = EnumerateExecutableNodes(body) + .OfType(); + foreach (IdentifierNameSyntax identifier in identifiers) + { + if (source.SemanticModel.GetSymbolInfo(identifier).Symbol is not IPropertySymbol + { + GetMethod: { } getter, + } property) + { + continue; + } + + foreach (SyntaxReference reference in getter.DeclaringSyntaxReferences + .Concat(property.DeclaringSyntaxReferences)) + { + SyntaxNode declaration = reference.GetSyntax(); + if (declaration.SyntaxTree == source.Method.SyntaxTree + && declaration is AccessorDeclarationSyntax or PropertyDeclarationSyntax) + { + pending.Enqueue(declaration); + } + } + } + } + } + + private static IEnumerable EnumerateExecutableNodes(SyntaxNode declaration) + { + SyntaxNode? root = declaration switch + { + MethodDeclarationSyntax { Body: { } body } => body, + MethodDeclarationSyntax { ExpressionBody.Expression: { } expression } => expression, + LocalFunctionStatementSyntax { Body: { } body } => body, + LocalFunctionStatementSyntax { ExpressionBody.Expression: { } expression } => expression, + AccessorDeclarationSyntax { Body: { } body } => body, + AccessorDeclarationSyntax { ExpressionBody.Expression: { } expression } => expression, + PropertyDeclarationSyntax { ExpressionBody.Expression: { } expression } => expression, + _ => null, + }; + if (root is null) + yield break; + + foreach (SyntaxNode node in root.DescendantNodesAndSelf( + descendIntoChildren: static child => + child is not AnonymousFunctionExpressionSyntax + && child is not LocalFunctionStatementSyntax)) + { + yield return node; + } + } + + private static bool IsRenderNodeProcessOverride(MethodDeclarationSyntax method) + { + if (method.Identifier.ValueText != "Process" + || !method.Modifiers.Any(SyntaxKind.OverrideKeyword) + || method.ParameterList.Parameters.Count != 1) + { + return false; + } + + TypeSyntax? type = method.ParameterList.Parameters[0].Type; + return type?.DescendantTokens() + .LastOrDefault(static token => token.IsKind(SyntaxKind.IdentifierToken)) + .ValueText == "RenderNodeContext"; + } + + private static string? GetInvokedName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + GenericNameSyntax generic => generic.Identifier.ValueText, + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText, + MemberBindingExpressionSyntax binding => binding.Name.Identifier.ValueText, + _ => null, + }; + } + + private static bool IsNativeSnapshotInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + IMethodSymbol? method = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + ITypeSymbol? type = method?.ContainingType; + if (type is null && invocation.Expression is MemberAccessExpressionSyntax member) + type = semanticModel.GetTypeInfo(member.Expression).Type; + + for (INamedTypeSymbol? current = type as INamedTypeSymbol; + current is not null; + current = current.BaseType) + { + string name = current.ToDisplayString(); + if (name is "Beutl.Graphics.Rendering.RenderTarget" + or "Beutl.Graphics.Rendering.Renderer" + or "SkiaSharp.SKSurface") + { + return true; + } + } + + return false; + } + + private static MetadataReference[] CreateSemanticReferences() + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string trustedPlatformAssemblies) + { + foreach (string path in trustedPlatformAssemblies.Split(Path.PathSeparator)) + paths.Add(path); + } + + paths.Add(typeof(RenderNode).Assembly.Location); + paths.Add(typeof(SKSurface).Assembly.Location); + return paths + .Where(static path => !string.IsNullOrEmpty(path) && File.Exists(path)) + .Select(static path => MetadataReference.CreateFromFile(path)) + .ToArray(); + } + + [Test] + public void TargetFactoryProbe_ResolvesReceiversByType() + { + const string source = """ + using Beutl.Graphics.Rendering; + + public sealed class Probe + { + public void Run(IRenderTargetFactory allocator) + { + allocator.Create(default); + } + } + """; + SyntaxTree tree = CSharpSyntaxTree.ParseText(source); + CSharpCompilation compilation = CSharpCompilation.Create( + "TargetFactoryReceiverProbe", + [tree], + s_semanticReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + SemanticModel semanticModel = compilation.GetSemanticModel(tree); + InvocationExpressionSyntax invocation = tree.GetRoot() + .DescendantNodes() + .OfType() + .Single(); + + Assert.That(IsTargetFactoryInvocation(invocation, semanticModel), Is.True, + "An IRenderTargetFactory receiver named 'allocator' must remain inside the recording gate."); + } + + [Test] + public void EagerExecutionCensus_FollowsSynchronousMethodsAndLocalFunctions() + { + const string sourceText = """ + public sealed class Probe + { + public void Process() + { + Helper(); + Local(); + void Local() => ReadFrame(); + } + + private static void Helper() => Render(); + private static void Render() { } + private static void ReadFrame() { } + } + """; + SourceText text = SourceText.From(sourceText); + SyntaxTree tree = CSharpSyntaxTree.ParseText(text); + CSharpCompilation compilation = CSharpCompilation.Create( + "SynchronousHelperProbe", + [tree], + s_semanticReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + SemanticModel semanticModel = compilation.GetSemanticModel(tree); + MethodDeclarationSyntax process = tree.GetRoot() + .DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Process"); + var source = new SourceMethod("Probe.cs", text, process, semanticModel); + + SourceFinding[] findings = FindEagerExecution(source).ToArray(); + + Assert.Multiple(() => + { + Assert.That(findings.Select(static finding => finding.Detail), + Has.Some.Contains("'Render'")); + Assert.That(findings.Select(static finding => finding.Detail), + Has.Some.Contains("'ReadFrame'")); + }); + } + + [Test] + public void EagerExecutionCensus_FollowsExpressionBodiedPropertyGetters() + { + const string sourceText = """ + public sealed class Probe + { + public object Process() => Current; + + private static object Current => ReadFrame(); + private static object ReadFrame() => new(); + } + """; + SourceText text = SourceText.From(sourceText); + SyntaxTree tree = CSharpSyntaxTree.ParseText(text); + CSharpCompilation compilation = CSharpCompilation.Create( + "ExpressionBodiedPropertyProbe", + [tree], + s_semanticReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + SemanticModel semanticModel = compilation.GetSemanticModel(tree); + MethodDeclarationSyntax process = tree.GetRoot() + .DescendantNodes() + .OfType() + .Single(static method => method.Identifier.ValueText == "Process"); + var source = new SourceMethod("Probe.cs", text, process, semanticModel); + + SourceFinding[] findings = FindEagerExecution(source).ToArray(); + + Assert.Multiple(() => + { + Assert.That( + findings.Select(static finding => finding.Detail), + Is.EqualTo(["eager invocation 'ReadFrame'"])); + Assert.That( + findings.Select(static finding => finding.Snippet), + Is.EqualTo(["private static object Current => ReadFrame();"])); + }); + } + + private static bool IsTargetFactoryInvocation( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel) + { + IMethodSymbol? method = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + ITypeSymbol? receiverType = method?.ContainingType; + if (receiverType is null && invocation.Expression is MemberAccessExpressionSyntax member) + receiverType = semanticModel.GetTypeInfo(member.Expression).Type; + + return receiverType is INamedTypeSymbol named + && IsRenderTargetFactoryType(named); + } + + private static bool IsRenderTargetFactoryType(INamedTypeSymbol type) + => IsRenderTargetFactoryInterface(type) + || type.AllInterfaces.Any(IsRenderTargetFactoryInterface); + + private static bool IsRenderTargetFactoryInterface(INamedTypeSymbol type) + => type.ToDisplayString() == "Beutl.Graphics.Rendering.IRenderTargetFactory"; + + private static bool IsCpuNodeDescriptionRender( + InvocationExpressionSyntax invocation, + string invokedName) + { + if (invokedName != "Render" + || invocation.Expression is not MemberAccessExpressionSyntax member + || Unsuppress(member.Expression) is not InvocationExpressionSyntax getOriginal + || GetInvokedName(getOriginal) is not "GetOriginal" + || invocation.ArgumentList.Arguments.Count < 1) + { + return false; + } + + string contextName = invocation.ArgumentList.Arguments[0].Expression.ToString(); + return invocation.FirstAncestorOrSelf()? + .DescendantNodes() + .OfType() + .Any(variable => variable.Identifier.ValueText == contextName + && variable.Initializer?.Value is ObjectCreationExpressionSyntax creation + && creation.Type.DescendantTokens() + .LastOrDefault(static token => token.IsKind(SyntaxKind.IdentifierToken)) + .ValueText == "GraphicsContext2D") == true; + } + + // GetOriginal() is nullable, so a call site that knows its resource is attached writes GetOriginal()!, + // which wraps the invocation in a suppression before the member access reaches it. + private static ExpressionSyntax Unsuppress(ExpressionSyntax expression) + => expression is PostfixUnaryExpressionSyntax + { + RawKind: (int)SyntaxKind.SuppressNullableWarningExpression, + } suppression + ? Unsuppress(suppression.Operand) + : expression; + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Beutl.slnx"))) + directory = directory.Parent; + + return directory?.FullName + ?? throw new DirectoryNotFoundException( + $"Could not locate the Beutl repository root above {AppContext.BaseDirectory}."); + } + + private static bool HasPathSegment(string path, string segment) + => path.Split('/').Contains(segment, StringComparer.Ordinal); + + private static string NormalizePath(string path) + => path.Replace(Path.DirectorySeparatorChar, '/'); + + private static string FormatFindings(IReadOnlyList findings) + { + return string.Join(Environment.NewLine, findings.Take(30).Select(static finding => + $" {finding.RelativePath}:{finding.Line}: {finding.Detail}: {finding.Snippet}")); + } + + private sealed record SourceMethod( + string RelativePath, + SourceText Text, + MethodDeclarationSyntax Method, + SemanticModel SemanticModel) + { + public SourceFinding ToFinding(SyntaxNode node, string detail) + { + LinePosition position = Text.Lines.GetLinePosition(node.SpanStart); + string snippet = Text.Lines[position.Line].ToString().Trim(); + return new SourceFinding(RelativePath, position.Line + 1, detail, snippet); + } + } + + private sealed record SourceFinding( + string RelativePath, + int Line, + string Detail, + string Snippet); + + private sealed class DeferredShapeProbeNode(SideEffectTripwire tripwire) : RenderNode + { + public static Rect Bounds { get; } = new(0, 0, 64, 36); + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle source = context.OpaqueSource(CreateOpaque( + OpaqueRenderBoundsContract.Source(Bounds), + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + "source", + inputCount: 0)); + RenderFragmentHandle mapped = context.OpaqueMap( + source, + CreateOpaque( + OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + RenderValueCardinality.Single, + RenderScaleContract.PreserveInputSupply, + "map", + inputCount: 1)); + RenderFragmentHandle combined = context.OpaqueCombine( + [source, mapped], + CreateOpaque( + OpaqueRenderBoundsContract.Combine( + static inputs => inputs.Aggregate(static (left, right) => left.Union(right)), + static (output, inputs) => inputs.Select(_ => output).ToArray()), + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale, + "combine", + inputCount: 2)); + RenderFragmentHandle expanded = context.OpaqueExpand( + [source], + CreateOpaque( + OpaqueRenderBoundsContract.FullInputs( + static inputs => inputs.Aggregate(static (left, right) => left.Union(right))), + RenderValueCardinality.Dynamic, + RenderScaleContract.MaterializeAtWorkingScale, + "expand", + inputCount: 1)); + RenderFragmentHandle shader = context.Shader( + source, + ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + RenderFragmentHandle geometry = context.Geometry( + source, + GeometryDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + requiresReadback: true)); + RenderFragmentHandle capture = context.TargetCapture( + TargetCaptureDescription.Create( + TargetRegion.Full, + Bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle command = context.TargetCommand( + [source], + TargetCommandDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + TargetRegion.Full, + Bounds, + RenderHitTestContract.OutputBounds, + TargetAccess.Readback, + inputReadbacks: [RenderInputReadback.All])); + RenderFragmentHandle scope = context.TargetScope( + source, + TargetScopeDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.PhaseDependent)); + RenderFragmentHandle rawScope = context.RawTargetScope( + source, + RawTargetScopeDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply)); + RenderFragmentHandle rawCommand = context.RawTargetCommand( + RawTargetCommandDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + Bounds, + RenderHitTestContract.OutputBounds)); + + context.PublishRange( + [source, mapped, combined, expanded, shader, geometry, capture, command, scope, rawScope, rawCommand]); + } + + private OpaqueRenderDescription CreateOpaque( + OpaqueRenderBoundsContract bounds, + RenderValueCardinality cardinality, + RenderScaleContract scale, + string key, + int inputCount) + { + return OpaqueRenderDescription.CreateRequestLocal( + _ => tripwire.TouchAll(), + bounds, + RenderHitTestContract.OutputBounds, + cardinality, + scale, + inputReadbacks: Enumerable.Repeat(RenderInputReadback.All, inputCount)); + } + } + + private sealed class CountingTargetFactory : IRenderTargetFactory + { + public int CreateCalls { get; private set; } + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + CreateCalls++; + throw new AssertionException( + $"Recording unexpectedly requested a {deviceSize.Width}x{deviceSize.Height} render target."); + } + } + + private sealed class SideEffectTripwire + { + public IReadOnlyDictionary Counts => _counts; + + private readonly Dictionary _counts = Enum + .GetValues() + .ToDictionary(static value => value, static _ => 0); + + public void TouchAll() + { + foreach (RecordingSideEffect sideEffect in Enum.GetValues()) + { + _counts[sideEffect]++; + } + } + } + + private enum RecordingSideEffect + { + GpuContext, + TargetFactory, + Snapshot, + MediaRead, + MediaDecode, + NestedRenderer, + Flush, + Synchronization, + Readback, + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderContractPrimitiveTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderContractPrimitiveTests.cs new file mode 100644 index 0000000000..b500651d2e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderContractPrimitiveTests.cs @@ -0,0 +1,310 @@ +using System.Collections.Immutable; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class RenderContractPrimitiveTests +{ + [Test] + public void RenderValueCardinality_ProvidesInitializedCanonicalValues() + { + Assert.Multiple(() => + { + Assert.That(RenderValueCardinality.None.Minimum, Is.Zero); + Assert.That(RenderValueCardinality.None.Maximum, Is.Zero); + Assert.That(RenderValueCardinality.Single.Minimum, Is.EqualTo(1)); + Assert.That(RenderValueCardinality.Single.Maximum, Is.EqualTo(1)); + Assert.That(RenderValueCardinality.ZeroOrOne.Minimum, Is.Zero); + Assert.That(RenderValueCardinality.ZeroOrOne.Maximum, Is.EqualTo(1)); + Assert.That(RenderValueCardinality.Dynamic.Minimum, Is.Zero); + Assert.That(RenderValueCardinality.Dynamic.Maximum, Is.Null); + Assert.That(RenderValueCardinality.Exactly(3), Is.EqualTo(RenderValueCardinality.Range(3, 3))); + }); + } + + [Test] + public void RenderValueCardinality_RejectsInvalidRangesAndDefault() + { + Assert.Multiple(() => + { + Assert.That(() => RenderValueCardinality.Exactly(-1), Throws.TypeOf()); + Assert.That(() => RenderValueCardinality.Range(-1, null), Throws.TypeOf()); + Assert.That(() => RenderValueCardinality.Range(2, 1), Throws.TypeOf()); + Assert.That( + () => default(RenderValueCardinality).ThrowIfUninitialized("cardinality"), + Throws.TypeOf().With.Property("ParamName").EqualTo("cardinality")); + }); + } + + [Test] + public void TargetRegion_SeparatesFullEmptyAndFiniteRegion() + { + var region = new Rect(10, 20, 30, 40); + + Assert.Multiple(() => + { + Assert.That(TargetRegion.Full.Kind, Is.EqualTo(TargetRegionKind.Full)); + Assert.That(TargetRegion.Empty.Kind, Is.EqualTo(TargetRegionKind.Empty)); + Assert.That(TargetRegion.Region(region).Kind, Is.EqualTo(TargetRegionKind.Region)); + Assert.That(TargetRegion.Region(region).Value, Is.EqualTo(region)); + Assert.That(TargetRegion.Region(new Rect(10, 20, 0, 40)), Is.EqualTo(TargetRegion.Empty)); + Assert.That(TargetRegion.Region(new Rect(10, 20, 30, 0)), Is.EqualTo(TargetRegion.Empty)); + }); + } + + [Test] + public void TargetRegion_RejectsInvalidNonFiniteNegativeAndDefault() + { + Assert.Multiple(() => + { + Assert.That(() => TargetRegion.Region(Rect.Invalid), Throws.TypeOf()); + Assert.That(() => TargetRegion.Region(new Rect(0, 0, float.PositiveInfinity, 1)), Throws.TypeOf()); + Assert.That(() => TargetRegion.Region(new Rect(0, 0, -1, 1)), Throws.TypeOf()); + Assert.That( + () => default(TargetRegion).ThrowIfUninitialized("region"), + Throws.TypeOf().With.Property("ParamName").EqualTo("region")); + }); + } + + [Test] + public void RenderBoundsContract_IdentityAndFullInputHaveDistinctBackwardPolicy() + { + var bounds = new Rect(1, 2, 30, 40); + + Assert.Multiple(() => + { + Assert.That(RenderBoundsContract.Identity.TransformBounds(bounds), Is.EqualTo(bounds)); + Assert.That(RenderBoundsContract.Identity.GetRequiredInputBounds(bounds), Is.EqualTo(bounds)); + Assert.That(RenderBoundsContract.Identity.RequiresFullInput, Is.False); + Assert.That(RenderBoundsContract.FullInput.TransformBounds(bounds), Is.EqualTo(bounds)); + Assert.That(RenderBoundsContract.FullInput.GetRequiredInputBounds(bounds), Is.EqualTo(bounds)); + Assert.That(RenderBoundsContract.FullInput.RequiresFullInput, Is.True); + }); + } + + [Test] + public void RenderBoundsContract_CustomMapsAreValidated() + { + RenderBoundsContract contract = RenderBoundsContract.Create( + static input => input.Inflate(new Thickness(2, 3)), + static output => output.Inflate(new Thickness(4, 5))); + var bounds = new Rect(10, 20, 30, 40); + + Assert.Multiple(() => + { + Assert.That(contract.TransformBounds(bounds), Is.EqualTo(bounds.Inflate(new Thickness(2, 3)))); + Assert.That(contract.GetRequiredInputBounds(bounds), Is.EqualTo(bounds.Inflate(new Thickness(4, 5)))); + Assert.That(contract.RequiresFullInput, Is.False); + Assert.That( + RenderBoundsContract.CreateFullInput(static input => input.Translate(new Vector(3, 4))) + .RequiresFullInput, + Is.True); + Assert.That( + () => RenderBoundsContract.Create(static _ => Rect.Invalid, static value => value) + .TransformBounds(bounds), + Throws.TypeOf()); + Assert.That( + () => RenderBoundsContract.Create(static value => value, static _ => Rect.Invalid) + .GetRequiredInputBounds(bounds), + Throws.TypeOf()); + }); + } + + [Test] + public void RenderBoundsContract_RejectsNullDelegatesAndDefault() + { + Assert.Multiple(() => + { + Assert.That( + () => RenderBoundsContract.Create(null!, static value => value), + Throws.TypeOf()); + Assert.That( + () => RenderBoundsContract.Create(static value => value, null!), + Throws.TypeOf()); + Assert.That( + () => RenderBoundsContract.CreateFullInput(null!), + Throws.TypeOf()); + Assert.That( + () => default(RenderBoundsContract).TransformBounds(Rect.Empty), + Throws.TypeOf()); + Assert.That( + () => default(RenderBoundsContract).ThrowIfUninitialized("bounds"), + Throws.TypeOf().With.Property("ParamName").EqualTo("bounds")); + }); + } + + /// + /// A metadata callback is evaluated repeatedly and its structural identity is only its MethodInfo, so a + /// capture the author can still change makes one identity stand for different bounds. The named mutable + /// collections were the only shape rejected; an ordinary class with a settable field does it too. + /// + [Test] + public void RenderBoundsContract_RejectsAMetadataCallbackThatCapturesAnAssignableField() + { + var box = new MutableBox { Value = new Rect(0, 0, 4, 4) }; + + Assert.That( + () => RenderBoundsContract.Create(_ => box.Value, static value => value), + Throws.TypeOf().With.InnerException.Message.Contains("MutableBox.Value")); + } + + [Test] + public void RenderBoundsContract_RejectsACaptureWhoseReadOnlyFieldHoldsSomethingAssignable() + { + var nested = new FixedBox(new MutableBox { Value = new Rect(0, 0, 4, 4) }); + + Assert.That( + () => RenderBoundsContract.Create(_ => nested.Inner.Value, static value => value), + Throws.TypeOf()); + } + + [Test] + public void RenderBoundsContract_AcceptsACaptureNothingCanReassign() + { + var fixedValue = new FixedRect(new Rect(0, 0, 4, 4)); + + Assert.That( + () => RenderBoundsContract.Create(_ => fixedValue.Value, static value => value), + Throws.Nothing); + } + + [Test] + public void RenderBoundsContract_AcceptsACaptureHoldingAnImmutableCollection() + { + var fixedValue = new FixedRects([new Rect(0, 0, 4, 4)]); + + Assert.That( + () => RenderBoundsContract.Create(_ => fixedValue.Values[0], static value => value), + Throws.Nothing); + } + + /// + /// What an immutable collection fixes is the collection, not what it holds. An element the author can + /// still assign reads through the array exactly as it reads through a field. + /// + [Test] + public void RenderBoundsContract_RejectsACaptureHoldingAnImmutableCollectionOfMutableElements() + { + var boxes = ImmutableArray.Create(new MutableBox { Value = new Rect(0, 0, 4, 4) }); + + Assert.That( + () => RenderBoundsContract.Create(_ => boxes[0].Value, static value => value), + Throws.TypeOf()); + } + + /// + /// A ReadOnlyMemory is a read-only view, not an immutable value: the array it ordinarily wraps stays in + /// the author's hands and can be written after the callback is recorded. + /// + [Test] + public void RenderBoundsContract_RejectsACaptureViewingAnArrayItCannotOwn() + { + ReadOnlyMemory view = new float[] { 4f }.AsMemory(); + + Assert.That( + () => RenderBoundsContract.Create( + _ => new Rect(0, 0, view.Span[0], view.Span[0]), + static value => value), + Throws.TypeOf()); + } + + /// + /// The same view over a string is accepted, because what it points at cannot be written either. + /// + [Test] + public void RenderBoundsContract_AcceptsACaptureViewingSomethingFixed() + { + ReadOnlyMemory view = "4".AsMemory(); + + Assert.That( + () => RenderBoundsContract.Create( + _ => new Rect(0, 0, view.Span[0], view.Span[0]), + static value => value), + Throws.Nothing); + } + + /// + /// Roslyn caches an inner lambda in the closure it shares with the enclosing one, so a contract recorded + /// from inside any other lambda reaches validation with a delegate field the author never wrote. + /// + [Test] + public void RenderBoundsContract_AcceptsAContractRecordedInsideAnotherLambda() + { + var fixedValue = new FixedRect(new Rect(0, 0, 4, 4)); + Func record = + () => RenderBoundsContract.Create(_ => fixedValue.Value, static value => value); + + Assert.That(() => record(), Throws.Nothing); + } + + /// + /// The compiler's own cache is recognised by pointing back at the closure being validated, so a delegate + /// the author really did capture - one built elsewhere, over state this closure cannot show - still fails. + /// + [Test] + public void RenderBoundsContract_RejectsADelegateCapturedFromAnotherClosure() + { + Func elsewhere = ReadFrom(new MutableBox { Value = new Rect(0, 0, 4, 4) }); + + Assert.That( + () => RenderBoundsContract.Create(value => elsewhere(value), static value => value), + Throws.TypeOf()); + } + + private static Func ReadFrom(MutableBox box) => _ => box.Value; + + [Test] + public void RenderBoundsContract_RejectsMetadataCallbacksThatCaptureLifetimeState() + { + using var retained = new MemoryStream(); + Func capturing = value => + { + _ = retained.Position; + return value; + }; + + Assert.Multiple(() => + { + Assert.That( + () => RenderBoundsContract.Create( + capturing, + static value => value), + Throws.TypeOf()); + Assert.That( + () => RenderBoundsContract.Create( + static value => value, + capturing), + Throws.TypeOf()); + Assert.That( + () => RenderBoundsContract.CreateFullInput( + capturing), + Throws.TypeOf()); + }); + } + + private sealed class MutableBox + { + public Rect Value; + } + + private sealed class FixedBox(MutableBox inner) + { + public readonly MutableBox Inner = inner; + } + + private sealed class FixedRect(Rect value) + { + public readonly Rect Value = value; + } + + private sealed class FixedRects(ImmutableArray values) + { + public readonly ImmutableArray Values = values; + } + + private sealed class DerivedMutableKey : List; + + private sealed record ImmutableIdentity(string Name, int Version); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderDescriptionAndExecutionContractTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderDescriptionAndExecutionContractTests.cs new file mode 100644 index 0000000000..153d9e7def --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderDescriptionAndExecutionContractTests.cs @@ -0,0 +1,433 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class RenderDescriptionAndExecutionContractTests +{ + + [Test] + public void OperationBounds_ValidateTopologyAndMultiInputBackwardMapping() + { + Rect first = new(0, 0, 10, 20); + Rect second = new(30, 5, 10, 10); + Rect requested = new(4, 5, 6, 7); + OpaqueRenderBoundsContract source = OpaqueRenderBoundsContract.Source(first); + OpaqueRenderBoundsContract map = OpaqueRenderBoundsContract.Map( + RenderBoundsContract.Create( + static value => value.Translate(new Vector(3, 4)), + static value => value.Translate(new Vector(-3, -4)))); + OpaqueRenderBoundsContract combine = OpaqueRenderBoundsContract.Combine( + static inputs => inputs.Aggregate(static (left, right) => left.Union(right)), + static (output, inputs) => inputs.Select(_ => output).ToArray()); + OpaqueRenderBoundsContract full = OpaqueRenderBoundsContract.FullInputs( + static inputs => inputs.Aggregate(static (left, right) => left.Union(right))); + + Assert.Multiple(() => + { + Assert.That(source.TransformBounds([]), Is.EqualTo(first)); + Assert.That(map.TransformBounds([first]), Is.EqualTo(first.Translate(new Vector(3, 4)))); + Assert.That(map.GetRequiredInputBounds(requested, [first]), Is.EqualTo(new[] + { + requested.Translate(new Vector(-3, -4)), + })); + Assert.That(combine.TransformBounds([first, second]), Is.EqualTo(first.Union(second))); + Assert.That(combine.GetRequiredInputBounds(requested, [first, second]), + Is.EqualTo(new[] { requested, requested })); + Assert.That(full.GetRequiredInputBounds(requested, [first, second]), + Is.EqualTo(new[] { first, second })); + Assert.That( + () => combine.GetRequiredInputBounds( + requested, + [first]), + Throws.Nothing); + }); + + OpaqueRenderBoundsContract badCount = OpaqueRenderBoundsContract.Combine( + static inputs => inputs.Aggregate(static (left, right) => left.Union(right)), + static (_, _) => [Rect.Empty]); + Assert.That( + () => badCount.GetRequiredInputBounds(requested, [first, second]), + Throws.TypeOf()); + + Assert.Multiple(() => + { + Assert.That(() => source.ThrowIfIncompatible(OpaqueRenderTopology.Source, "bounds"), Throws.Nothing); + Assert.That(() => source.ThrowIfIncompatible(OpaqueRenderTopology.Map, "bounds"), Throws.TypeOf()); + Assert.That(() => map.ThrowIfIncompatible(OpaqueRenderTopology.Map, "bounds"), Throws.Nothing); + Assert.That(() => combine.ThrowIfIncompatible(OpaqueRenderTopology.Combine, "bounds"), Throws.Nothing); + Assert.That(() => full.ThrowIfIncompatible(OpaqueRenderTopology.Expand, "bounds"), Throws.Nothing); + }); + } + + [Test] + public void HitTestContracts_EvaluateOnlyDeclaredCpuMetadata() + { + var output = new Rect(10, 20, 30, 40); + RenderHitTestInput[] inputs = + [ + new(new Rect(0, 0, 5, 5), static point => point == new Point(2, 3)), + new(new Rect(20, 20, 5, 5), static _ => false), + ]; + RenderHitTestContract custom = RenderHitTestContract.Custom( + static (context, point) => context.OutputBounds.Contains(point) && context.Inputs.Count == 2); + + Assert.Multiple(() => + { + Assert.That(RenderHitTestContract.None.Evaluate(output, inputs, [], new Point(12, 24)), Is.False); + Assert.That(RenderHitTestContract.OutputBounds.Evaluate(output, inputs, [], new Point(12, 24)), Is.True); + Assert.That(RenderHitTestContract.OutputBounds.Evaluate(output, inputs, [], new Point(1, 1)), Is.False); + Assert.That(RenderHitTestContract.AnyInput.Evaluate(output, inputs, [], new Point(2, 3)), Is.True); + Assert.That(custom.Evaluate(output, inputs, [], new Point(12, 24)), Is.True); + Assert.That(inputs[0].Bounds, Is.EqualTo(new Rect(0, 0, 5, 5))); + Assert.That(inputs[0].HitTest(new Point(2, 3)), Is.True); + Assert.That(() => default(RenderHitTestContract).Evaluate(output, inputs, [], default), + Throws.TypeOf()); + }); + } + + [Test] + public void ScaleContracts_ResolveConcreteSupplyAndRejectInvalidCustomResults() + { + EffectiveScale[] inputs = [EffectiveScale.At(1.5f), EffectiveScale.At(2.5f)]; + var bounds = new Rect(0, 0, 100, 100); + RenderScaleContract custom = RenderScaleContract.Custom( + static context => context.OutputScale * 3); + + Assert.Multiple(() => + { + Assert.That(RenderScaleContract.Vector.Resolve(inputs, bounds, 2, 4), Is.EqualTo(EffectiveScale.Unbounded)); + Assert.That(RenderScaleContract.MaterializeAtWorkingScale.Resolve(inputs, bounds, 2, 4), + Is.EqualTo(EffectiveScale.At(2.5f))); + Assert.That(custom.Resolve(inputs, bounds, 2, 4), Is.EqualTo(EffectiveScale.At(4))); + Assert.That( + RenderScaleContract.PreserveInputSupply.Resolve([EffectiveScale.At(3)], bounds, 2, 4), + Is.EqualTo(EffectiveScale.At(3))); + Assert.That( + () => RenderScaleContract.PreserveInputSupply.Resolve(inputs, bounds, 2, 4), + Throws.TypeOf()); + Assert.That( + () => RenderScaleContract.Custom(static _ => float.NaN).Resolve(inputs, bounds, 2, 4), + Throws.TypeOf()); + Assert.That( + () => RenderScaleContract.Custom(static _ => float.PositiveInfinity).Resolve(inputs, bounds, 2, 4), + Throws.TypeOf()); + Assert.That( + () => default(RenderScaleContract).Resolve(inputs, bounds, 2, 4), + Throws.TypeOf()); + }); + } + + [Test] + public void ScaleContracts_ClampTheExactFractionalDeviceFootprint() + { + var positiveOrigin = new Rect( + 0.25f, + 0, + RenderScaleUtilities.MaxBufferDimension, + 1); + var exactFitAtNegativeOrigin = new Rect( + -0.5f, + 0, + RenderScaleUtilities.MaxBufferDimension - 0.5f, + 1); + EffectiveScale[] resolved = + [ + RenderScaleContract.MaterializeAtWorkingScale.Resolve( + [EffectiveScale.At(1)], + positiveOrigin, + outputScale: 1, + maxWorkingScale: 1), + RenderScaleContract.Custom( + static _ => 1) + .Resolve([], positiveOrigin, outputScale: 1, maxWorkingScale: 1), + RenderScaleContract.MapInputSupplyPreservingDemand( + static _ => EffectiveScale.At(1)) + .Resolve([EffectiveScale.At(1)], positiveOrigin, outputScale: 1, maxWorkingScale: 1), + ]; + + Assert.Multiple(() => + { + foreach (EffectiveScale scale in resolved) + { + Assert.That(scale.Value, Is.LessThan(1)); + Assert.That( + PixelRect.FromRect(positiveOrigin, scale.Value).Width, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + } + + Assert.That( + PixelRect.FromRect(exactFitAtNegativeOrigin, 1).Width, + Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That( + RenderScaleContract.MaterializeAtWorkingScale.Resolve( + [EffectiveScale.At(1)], + exactFitAtNegativeOrigin, + outputScale: 1, + maxWorkingScale: 1), + Is.EqualTo(EffectiveScale.At(1))); + }); + } + + [Test] + public void MaterializedInput_RequiresConcreteMatchingBackingAndSourceHitTest() + { + using var registry = new RenderRequestResourceRegistry(); + var bounds = new Rect(10.25f, 20.25f, 10, 20); + var deviceGridOffset = new Vector(0.25f, 0.5f); + PixelRect deviceBounds = PixelRect.FromRect(bounds.Translate(deviceGridOffset), 2); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + using RenderTarget wrongSize = RenderTarget.CreateNull(deviceBounds.Width + 1, deviceBounds.Height); + RenderResource token = registry.RegisterBorrowed(target); + + MaterializedInputDescription description = MaterializedInputDescription.FromRenderTarget( + token, + bounds, + EffectiveScale.At(2), + deviceBounds, + deviceGridOffset, + RenderHitTestContract.OutputBounds); + + Assert.Multiple(() => + { + Assert.That(description.Bounds, Is.EqualTo(bounds)); + Assert.That(description.EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(description.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(description.DeviceGridOffset, Is.EqualTo(deviceGridOffset)); + Assert.That( + description.RasterBounds, + Is.EqualTo(deviceBounds.ToRect(2).Translate(-deviceGridOffset))); + Assert.That(description.Target, Is.SameAs(token)); + Assert.That(description.HitTest, Is.EqualTo(RenderHitTestContract.OutputBounds)); + Assert.That( + () => MaterializedInputDescription.FromRenderTarget( + token, + bounds, + EffectiveScale.Unbounded, + deviceBounds, + deviceGridOffset, + RenderHitTestContract.None), + Throws.TypeOf()); + Assert.That( + () => description.ValidateTargetDeviceSize(wrongSize), + Throws.TypeOf()); + Assert.That( + () => MaterializedInputDescription.FromRenderTarget( + token, + bounds, + EffectiveScale.At(2), + deviceBounds, + deviceGridOffset, + RenderHitTestContract.AnyInput), + Throws.TypeOf()); + Assert.That( + () => MaterializedInputDescription.FromRenderTarget( + token, + bounds, + EffectiveScale.At(2), + new PixelRect(0, 0, deviceBounds.Width, deviceBounds.Height), + deviceGridOffset, + RenderHitTestContract.None), + Throws.TypeOf()); + }); + } + + + + [Test] + public void CallbackCanvas_MapsCompositionGlobalOriginAndEnforcesOneShotCapabilities() + { + var token = new RenderExecutionSessionToken(); + var logicalBounds = new Rect(10.25f, 20.25f, 8, 8); + PixelRect deviceBounds = PixelRect.FromRect(logicalBounds, 2); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var facade = new RenderCallbackCanvas( + token, + density: 2, + logicalBounds, + () => new ImmediateCanvas(target, 2, logicalSize: deviceBounds.Size.ToSize(2)), + CallbackCanvasCapability.Draw); + ImmediateCanvas? retainedCanvas = null; + + facade.Use(canvas => + { + retainedCanvas = canvas; + Assert.Multiple(() => + { + Assert.That(facade.DeviceBounds, Is.EqualTo(deviceBounds)); + Assert.That(facade.RasterBounds, Is.EqualTo(deviceBounds.ToRect(2))); + Assert.That(facade.LogicalOrigin, + Is.EqualTo(new Point(deviceBounds.X / 2f, deviceBounds.Y / 2f))); + Assert.That(canvas.Transform.Transform(facade.LogicalOrigin), Is.EqualTo(default(Point))); + Assert.That(() => canvas.Clear(Colors.Red), Throws.Nothing); + canvas.Pop(0); + Assert.That(canvas.Transform.Transform(facade.LogicalOrigin), Is.EqualTo(default(Point))); + Assert.That(() => canvas.PushLayer(), Throws.TypeOf()); + Assert.That(() => canvas.DrawNode(null!), Throws.TypeOf()); + Assert.That(() => RenderTarget.GetRenderTarget(canvas), Throws.TypeOf()); + Assert.That(() => canvas.Dispose(), Throws.TypeOf()); + }); + }); + + Assert.Multiple(() => + { + Assert.That(retainedCanvas, Is.Not.Null); + Assert.That(retainedCanvas!.IsDisposed, Is.True); + Assert.That(() => retainedCanvas.Clear(), Throws.TypeOf()); + Assert.That(() => facade.Use(static _ => { }), Throws.TypeOf()); + }); + + token.Complete(); + Assert.That(() => _ = facade.Density, Throws.TypeOf()); + } + + [Test] + public void ExecutionInput_RequiresActiveSameSessionCanvasAndUsesShiftedDevicePlacement() + { + var token = new RenderExecutionSessionToken(); + var inputBounds = new Rect(4, 6, 10, 12); + Rect? logicalPlacement = null; + Point? devicePlacement = null; + var input = new RenderExecutionInput( + token, + inputBounds, + EffectiveScale.At(2), + draw: (_, destination, _, _) => logicalPlacement = destination, + drawDeviceSpace: (_, point) => devicePlacement = point, + createShader: null, + createSnapshot: null, + readbackDeclared: false); + var callbackBounds = new Rect(10.25f, 20.25f, 8, 8); + PixelRect callbackDeviceBounds = PixelRect.FromRect(callbackBounds, 2); + using RenderTarget callbackTarget = RenderTarget.CreateNull( + callbackDeviceBounds.Width, + callbackDeviceBounds.Height); + var facade = new RenderCallbackCanvas( + token, + 2, + callbackBounds, + () => new ImmediateCanvas(callbackTarget, 2, logicalSize: callbackDeviceBounds.Size.ToSize(2)), + CallbackCanvasCapability.Draw); + using RenderTarget externalTarget = RenderTarget.CreateNull(8, 8); + using var externalCanvas = new ImmediateCanvas(externalTarget); + + Assert.That(() => input.Draw(externalCanvas), Throws.TypeOf()); + + facade.Use(canvas => + { + input.Draw(canvas); + input.DrawDeviceSpace( + canvas, + new Point(callbackDeviceBounds.X + 3, callbackDeviceBounds.Y + 5)); + }); + + Assert.Multiple(() => + { + Assert.That(logicalPlacement, Is.EqualTo(input.DeviceBounds.ToRect(2))); + Assert.That(devicePlacement, Is.EqualTo(new Point(3, 5))); + Assert.That(input.DeviceBounds, Is.EqualTo(PixelRect.FromRect(inputBounds, 2))); + Assert.That(input.DeviceSize, Is.EqualTo(input.DeviceBounds.Size)); + Assert.That(input.RasterBounds, Is.EqualTo(input.DeviceBounds.ToRect(2))); + Assert.That(input.LogicalOrigin, + Is.EqualTo(new Point(input.DeviceBounds.X / 2f, input.DeviceBounds.Y / 2f))); + }); + + token.Complete(); + Assert.That(() => _ = input.Bounds, Throws.TypeOf()); + } + + [Test] + public void ExecutionInput_ReadbackIsDeclaredOneShotAndDisposesOnCallbackFailure() + { + var token = new RenderExecutionSessionToken(); + Bitmap? supplied = null; + var input = new RenderExecutionInput( + token, + new Rect(0, 0, 2, 2), + EffectiveScale.At(1), + draw: static (_, _, _, _) => { }, + drawDeviceSpace: static (_, _) => { }, + createShader: null, + createSnapshot: () => supplied = new Bitmap(2, 2), + readbackDeclared: true); + var expected = new InvalidOperationException("callback failed"); + + InvalidOperationException? actual = Assert.Throws( + () => input.UseSnapshot(bitmap => + { + Assert.That(bitmap, Is.SameAs(supplied)); + throw expected; + })); + + Assert.Multiple(() => + { + Assert.That(actual, Is.SameAs(expected)); + Assert.That(supplied, Is.Not.Null); + Assert.That(supplied!.IsDisposed, Is.True); + Assert.That(() => input.UseSnapshot(static _ => { }), Throws.TypeOf()); + }); + + token.Complete(); + } + + [Test] + public void TargetScopeCanvas_AllowsOnlyStateAroundExactlyOneReplay() + { + var token = new RenderExecutionSessionToken(); + var bounds = new Rect(5, 7, 10, 12); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + using RenderTarget target = RenderTarget.CreateNull(deviceBounds.Width, deviceBounds.Height); + var facade = new RenderCallbackCanvas( + token, + 1, + bounds, + () => new ImmediateCanvas(target, logicalSize: deviceBounds.Size.ToSize(1)), + CallbackCanvasCapability.TargetScope); + int replayCount = 0; + var session = new TargetScopeSession( + token, + bounds, + bounds, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + facade, + [], + canvas => + { + replayCount++; + using (canvas.PushLayer()) + { + canvas.Clear(Colors.Blue); + } + }); + + Assert.That(() => session.ReplayInput(), Throws.TypeOf()); + facade.Use(canvas => + { + Assert.That(() => canvas.Clear(), Throws.TypeOf()); + Assert.That(() => canvas.PushLayer(), Throws.TypeOf()); + using (canvas.PushTransform(Matrix.CreateTranslation(2, 3))) + { + session.ReplayInput(); + } + + Assert.That(() => session.ReplayInput(), Throws.TypeOf()); + }); + + Assert.Multiple(() => + { + Assert.That(replayCount, Is.EqualTo(1)); + Assert.That(() => session.ValidateCompletion(), Throws.Nothing); + }); + + token.Complete(); + } +} + +internal static class RenderDescriptionAndExecutionContractSlots +{ + internal static readonly RenderResourceSlot Resource = new(); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderPipelineMigrationCensusTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderPipelineMigrationCensusTests.cs new file mode 100644 index 0000000000..9c1451cdef --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderPipelineMigrationCensusTests.cs @@ -0,0 +1,755 @@ +using System.Text.RegularExpressions; + +using Beutl.UnitTests.Engine.Graphics.Rendering.Baseline; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class RenderPipelineMigrationCensusTests +{ + private const string HistoricalEvidencePatch = + "docs/specs/004-gpu-pass-fusion/evidence/target-baseline-generator.patch"; + + private static readonly Lazy s_corpus = new(SourceCorpus.Discover); + + private static readonly IReadOnlyDictionary s_productionOverrideBaseline = + new Dictionary(StringComparer.Ordinal) + { + ["src/Beutl.Engine/Graphics/AudioVisualizers/AudioVisualizerRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/DrawableGroup.cs"] = 3, + ["src/Beutl.Engine/Graphics/Particles/ParticleRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/BlendModeRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/ClearRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/ContainerRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/DrawBackdropRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/EllipseRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/FilterEffectRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/GeometryClipRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/GeometryRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/ImageSourceRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/LayerRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/MemoryNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/OpacityMaskRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/OpacityRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/PushRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/RectClipRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/RectangleRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/ReferencesChildRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/Renderer.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/SnapshotBackdropRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/TextRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics/Rendering/VideoSourceRenderNode.cs"] = 1, + ["src/Beutl.Engine/Graphics3D/Scene3DRenderNode.cs"] = 1, + ["src/Beutl.NodeGraph/NodeGraphFilterEffectRenderNode.cs"] = 1, + ["src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs"] = 1, + ["src/Beutl.ProjectSystem/ProjectSystem/SceneDrawable.cs"] = 1, + }; + + // The starting-SHA baseline is a historical fact about 83e63689d; overrides that first + // appeared during the migration are excluded from the derivation. + private static readonly IReadOnlyDictionary s_startingProductionOverrideBaseline = + s_productionOverrideBaseline + .Where(static item => + item.Key != "src/Beutl.Engine/Graphics/Rendering/Renderer.cs" + && item.Key != "src/Beutl.Engine/Graphics/DrawableGroup.cs" + && item.Key != "src/Beutl.NodeGraph/Nodes/FilterEffectInputNode.cs") + .Append(new KeyValuePair( + "src/Beutl.Engine/Graphics/DrawableGroup.cs", + 2)) + .Append(new KeyValuePair( + "src/Beutl.Engine/Graphics/Rendering/OperationWrapperRenderNode.cs", + 1)) + .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal); + + private static readonly IReadOnlyDictionary s_testOverrideBaseline = + new Dictionary(StringComparer.Ordinal) + { + ["tests/Beutl.Benchmarks/Rendering/RenderPipelineBenchmarks.cs"] = 6, + ["tests/Beutl.Graphics3DTests/GpuPassFusion3DBoundaryTests.cs"] = 1, + ["tests/Beutl.Graphics3DTests/ShaderDescriptionSpirvEquivalenceTests.cs"] = 1, + ["tests/Beutl.PublicApiContractTests/CapturedResourceBorrowContractTests.cs"] = 1, + ["tests/Beutl.PublicApiContractTests/DeclaredPlannerTraitContractTests.cs"] = 2, + ["tests/Beutl.PublicApiContractTests/DeclaredResourceAddressingContractTests.cs"] = 1, + ["tests/Beutl.PublicApiContractTests/FilterEffectCompatibilityContractTests.cs"] = 5, + ["tests/Beutl.PublicApiContractTests/GeometryAuthoringContractTests.cs"] = 2, + ["tests/Beutl.PublicApiContractTests/OrphanedTargetEffectContractTests.cs"] = 2, + ["tests/Beutl.PublicApiContractTests/RenderNodeAuthoringContractTests.cs"] = 2, + ["tests/Beutl.PublicApiContractTests/RenderNodeRendererContractTests.cs"] = 1, + ["tests/Beutl.PublicApiContractTests/RenderScaleMappingContractTests.cs"] = 7, + ["tests/Beutl.PublicApiContractTests/ShaderAuthoringContractTests.cs"] = 1, + ["tests/Beutl.PublicApiContractTests/TargetAuthoringContractTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/BrushIntermediateAllocationIntentTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/DegradedPreviewCachePurityTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/OutputIdentityFanOutCostTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/ContributeValuesCacheHitExecutionTests.cs"] = 4, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheIdentityChannelTests.cs"] = 5, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderCacheResolutionTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/RenderNodeCacheHelperTest.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Cache/StructuralAndProgramCacheTests.cs"] = 6, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/ContainerRenderNodeTest.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/DirectSkiaFilterReplayTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/EngineResourceIdentityRoutingTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/DeferredCallbackFailureTests.cs"] = 7, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/NestedTargetAndCleanupFailureTests.cs"] = 16, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RecordingAndPlanningFailureTests.cs"] = 6, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/RenderNodeRendererLifetimeTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Failure/ShaderAndAllocationFailureTests.cs"] = 4, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/CrossNodeShaderFusionTests.cs"] = 5, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ExecutionIslandAuthorityTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/FusionBoundaryExecutionTestSupport.cs"] = 6, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Fusion/ShaderFallbackTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/DirectBlurFiniteOutputTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionFeature003RegressionTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/GpuPassFusionScaleRegionTests.cs"] = 7, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/LosslessCompositeCoverageTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/ShaderMatrixUniformTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/TargetCaptureValueWrapperTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Golden/WholeSourceFragmentOriginTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/GraphicsContext2DTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/HitTestDomainAgreementTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/ImageSourceRenderNodeTest.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/LegacyFilterTypedSuffixExecutionTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/BackdropOrderingTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/MaterializedInputCompositeTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/ProductionResourceLifetimeTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RawScopeNestingAndCaptureOffsetTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/RendererWideRecordingTests.cs"] = 7, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicOwningDomainTests.cs"] = 3, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/SymbolicSupplyMappingTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Planning/TargetScopeLoweringTests.cs"] = 9, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/DeclaredResourceOrderTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RawSessionSlotResourceTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RecordingSideEffectTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/ValueReplaySafetyTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererAllocationFailureTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererDeviceBoundsTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererExceptionSafetyTests.cs"] = 2, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererSnapshotFastPathTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/SlotBackedHitTestTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs"] = 13, + ["tests/Beutl.UnitTests/NodeGraph/ConfigureNodeOwnershipTests.cs"] = 2, + ["tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs"] = 6, + ["tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs"] = 1, + }; + + private static readonly IReadOnlyDictionary s_startingTestOverrideBaseline = + new Dictionary(StringComparer.Ordinal) + { + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/NodeCacheScaleTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeProcessorExceptionSafetyTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs"] = 1, + ["tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs"] = 3, + ["tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs"] = 1, + }; + + [Test] + public void SourceScope_IsOnlyCheckedInCSharpAndExcludesHistoricalEvidence() + { + SourceCorpus corpus = s_corpus.Value; + string[] outsideScope = corpus.Documents + .Where(document => + !document.RelativePath.StartsWith("src/", StringComparison.Ordinal) + && !document.RelativePath.StartsWith("tests/", StringComparison.Ordinal)) + .Select(document => document.RelativePath) + .ToArray(); + string[] buildOutputs = corpus.Documents + .Where(document => HasPathSegment(document.RelativePath, "bin") + || HasPathSegment(document.RelativePath, "obj")) + .Select(document => document.RelativePath) + .ToArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(corpus.Documents, Is.Not.Empty); + Assert.That(outsideScope, Is.Empty); + Assert.That(buildOutputs, Is.Empty); + Assert.That(corpus.Documents.Select(document => document.RelativePath), + Does.Not.Contain(HistoricalEvidencePatch)); + } + } + + + [Test] + public void ProcessOverrideInventory_PinsStartingBaselineAndMigratedOverrides() + { + IReadOnlyList overrides = s_corpus.Value.FindRenderNodeProcessOverrides(); + + using (Assert.EnterMultipleScope()) + { + AssertDeclaredBaseline("production", 29, s_startingProductionOverrideBaseline); + AssertDeclaredBaseline("test", 7, s_startingTestOverrideBaseline); + AssertAllOverridesAreMapped(overrides); + AssertBaselineInventory("production", 31, s_productionOverrideBaseline, overrides); + AssertBaselineInventory("test", 202, s_testOverrideBaseline, overrides); + } + } + + [Test] + public void ProcessOverrides_UseTheVoidRecordingContract() + { + IEnumerable findings = s_corpus.Value.FindRenderNodeProcessOverrides() + .Where(sourceMethod => !ReturnsVoid(sourceMethod.Method)) + .Select(sourceMethod => sourceMethod.ToFinding( + $"returns '{sourceMethod.Method.ReturnType}'")); + + AssertNoFindings("Every render-node Process override must return void.", findings); + } + + [Test] + public void ExecutableOperationTypeAndFactories_AreRemoved() + { + string operationType = BuildName("Render", "Node", "Operation"); + string[] factoryNames = + [ + BuildName("Create", "Lambda"), + BuildName("Create", "Decorator"), + BuildName("Create", "From", "Render", "Target"), + BuildName("Create", "From", "Surface"), + ]; + + using (Assert.EnterMultipleScope()) + { + AssertNoFindings($"The executable type '{operationType}' must be absent.", + s_corpus.Value.FindWord(operationType)); + AssertNoFindings("Executable operation factories must be absent.", + factoryNames.SelectMany(s_corpus.Value.FindWord)); + } + } + + [Test] + public void ProcessorPullApis_AreRemoved() + { + string[] pullNames = + [ + BuildName("Pu", "ll"), + BuildName("Pu", "ll", "To", "Root"), + ]; + + AssertNoFindings("Processor pull APIs must be absent.", + pullNames.SelectMany(s_corpus.Value.FindWord)); + } + + [Test] + public void ListRasterizationCompatibility_IsRemoved() + { + string[] compatibilityNames = + [ + BuildName("Rasterize", "To", "Render", "Targets"), + BuildName("Rasterize", "And", "Concat"), + ]; + IEnumerable findings = s_corpus.Value.FindLegacyRasterizers() + .Concat(compatibilityNames.SelectMany(s_corpus.Value.FindWord)); + + AssertNoFindings("Rasterization must return one owned RenderNodeRasterization, not a list or compatibility result.", + findings); + } + + [Test] + public void OperationRetentionAndBackedEffectTargets_AreRemoved() + { + string setterName = BuildName("Set", "Operations"); + string operationPropertyName = BuildName("Node", "Operation"); + string operationType = BuildName("Render", "Node", "Operation"); + + using (Assert.EnterMultipleScope()) + { + AssertNoFindings("Operation wrappers must not retain executable results.", + s_corpus.Value.FindWord(setterName)); + AssertNoFindings("Effect targets must not expose an operation-backed property.", + s_corpus.Value.FindWord(operationPropertyName)); + AssertNoFindings("Effect targets must have only materialized-target construction paths.", + s_corpus.Value.FindOperationBackedEffectTargets(operationType)); + } + } + + [Test] + public void ProcessMethods_DoNotCreateIsolatedNestedRenderers() + { + string[] rendererTypes = + [ + BuildName("Render", "Node", "Processor"), + BuildName("Render", "Node", "Renderer"), + ]; + + AssertNoFindings("Nested nodes must record through the current context instead of creating an isolated renderer.", + s_corpus.Value.FindNamedTokensInsideRenderNodeProcess(rendererTypes)); + } + + [Test] + public void CacheGeneration_DoesNotStartAnIndependentPullOrRasterization() + { + string[] forbiddenNames = + [ + BuildName("Render", "Node", "Processor"), + BuildName("Render", "Node", "Renderer"), + BuildName("Pu", "ll"), + BuildName("Pu", "ll", "To", "Root"), + BuildName("Rasterize"), + BuildName("Rasterize", "To", "Render", "Targets"), + BuildName("Rasterize", "And", "Concat"), + ]; + + AssertNoFindings("Cache generation must be resolved inside the current request.", + s_corpus.Value.FindForbiddenCacheExecution(forbiddenNames)); + } + + [Test] + public void RawCanvasCallbacks_AreExplicitlyClassified() + { + string[] callbackFactoryNames = + [ + BuildName("Create", "Lambda"), + BuildName("Create", "Decorator"), + ]; + + AssertNoFindings( + "Raw callbacks must use a typed, guarded opaque, or explicitly raw description.", + s_corpus.Value.FindInvocations(callbackFactoryNames)); + } + + [Test] + public void ContextScaleHelpers_AreMovedWithoutForwardingMembers() + { + string contextType = BuildName("Render", "Node", "Context"); + string[] helperNames = + [ + BuildName("Max", "Buffer", "Dimension"), + BuildName("Sanitize", "Max", "Working", "Scale"), + BuildName("Resolve", "Working", "Scale"), + BuildName("Clamp", "Working", "Scale", "To", "Buffer", "Budget"), + ]; + IEnumerable findings = s_corpus.Value.FindQualifiedReferences(contextType, helperNames) + .Concat(s_corpus.Value.FindMembersDeclaredByType(contextType, helperNames)); + + AssertNoFindings("Scale helpers must be owned only by RenderScaleUtilities.", findings); + } + + private static void AssertBaselineInventory( + string label, + int expectedCount, + IReadOnlyDictionary expected, + IReadOnlyList allOverrides) + { + SourceMethod[] baselineOverrides = allOverrides + .Where(sourceMethod => expected.ContainsKey(sourceMethod.Document.RelativePath)) + .ToArray(); + string[] expectedInventory = FormatInventory(expected); + string[] actualInventory = FormatInventory(baselineOverrides + .GroupBy(sourceMethod => sourceMethod.Document.RelativePath, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal)); + + Assert.That(expected.Values.Sum(), Is.EqualTo(expectedCount), + $"The checked-in {label} baseline declaration is inconsistent."); + Assert.That(baselineOverrides, Has.Length.EqualTo(expectedCount), + $"The {label} Process override baseline changed.{Environment.NewLine}{FormatMethods(baselineOverrides)}"); + Assert.That(actualInventory, Is.EqualTo(expectedInventory), + $"The {label} Process override inventory changed."); + } + + private static void AssertAllOverridesAreMapped(IReadOnlyList allOverrides) + { + var mappedPaths = new HashSet(s_productionOverrideBaseline.Keys, StringComparer.Ordinal); + mappedPaths.UnionWith(s_testOverrideBaseline.Keys); + SourceMethod[] unmapped = allOverrides + .Where(sourceMethod => !mappedPaths.Contains(sourceMethod.Document.RelativePath)) + .ToArray(); + + Assert.That(unmapped, Is.Empty, + $"Every RenderNode.Process override must appear in the production or test inventory." + + $"{Environment.NewLine}{FormatMethods(unmapped)}"); + } + + private static void AssertDeclaredBaseline( + string label, + int expectedCount, + IReadOnlyDictionary expected) + { + Assert.That(expected.Values.Sum(), Is.EqualTo(expectedCount), + $"The checked-in starting-SHA {label} baseline declaration is inconsistent."); + } + + private static string[] FormatInventory(IReadOnlyDictionary inventory) + { + return inventory + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}#{pair.Value}") + .ToArray(); + } + + private static string FormatMethods(IEnumerable methods) + { + return string.Join(Environment.NewLine, methods + .OrderBy(sourceMethod => sourceMethod.Document.RelativePath, StringComparer.Ordinal) + .ThenBy(sourceMethod => sourceMethod.Line) + .Select(sourceMethod => $" {sourceMethod.Document.RelativePath}:{sourceMethod.Line}")); + } + + private static void AssertNoFindings(string requirement, IEnumerable findings) + { + SourceFinding[] materialized = findings + .Distinct() + .OrderBy(finding => finding.RelativePath, StringComparer.Ordinal) + .ThenBy(finding => finding.Line) + .ThenBy(finding => finding.Detail, StringComparer.Ordinal) + .ToArray(); + + Assert.That(materialized, Is.Empty, $"{requirement}{Environment.NewLine}{FormatFindings(materialized)}"); + } + + private static string FormatFindings(IReadOnlyList findings) + { + const int maximumReportedFindings = 30; + IEnumerable lines = findings.Take(maximumReportedFindings) + .Select(finding => + $" {finding.RelativePath}:{finding.Line}: {finding.Detail}: {finding.Snippet}"); + string result = string.Join(Environment.NewLine, lines); + if (findings.Count > maximumReportedFindings) + { + result += Environment.NewLine + + $" ... {findings.Count - maximumReportedFindings} more finding(s)"; + } + + return result; + } + + private static bool ReturnsVoid(MethodDeclarationSyntax method) + { + return method.ReturnType is PredefinedTypeSyntax predefined + && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword); + } + + private static bool IsNamedType(TypeSyntax? type, string expectedName) + { + return type?.DescendantTokens() + .LastOrDefault(token => token.IsKind(SyntaxKind.IdentifierToken)) + .ValueText == expectedName; + } + + private static bool IsRenderNodeProcess(MethodDeclarationSyntax method) + { + return method.Identifier.ValueText == "Process" + && method.ParameterList.Parameters.Count == 1 + && IsNamedType(method.ParameterList.Parameters[0].Type, "RenderNodeContext"); + } + + private static string BuildName(params string[] parts) + { + return string.Concat(parts); + } + + private static bool HasPathSegment(string path, string segment) + { + return path.Split('/').Contains(segment, StringComparer.Ordinal); + } + + private sealed class SourceCorpus + { + private SourceCorpus(string repositoryRoot, IReadOnlyList documents) + { + RepositoryRoot = repositoryRoot; + Documents = documents; + } + + public string RepositoryRoot { get; } + + public IReadOnlyList Documents { get; } + + public static SourceCorpus Discover() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Beutl.slnx"))) + directory = directory.Parent; + + if (directory is null) + { + throw new DirectoryNotFoundException( + $"Could not locate the Beutl repository root above {AppContext.BaseDirectory}."); + } + + string repositoryRoot = directory.FullName; + var documents = new List(); + foreach (string sourceRootName in new[] { "src", "tests" }) + { + string sourceRoot = Path.Combine(repositoryRoot, sourceRootName); + foreach (string path in Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)) + { + string relativePath = NormalizePath(Path.GetRelativePath(repositoryRoot, path)); + if (HasPathSegment(relativePath, "bin") || HasPathSegment(relativePath, "obj")) + continue; + + SourceText text = SourceText.From(File.ReadAllText(path)); + var tree = CSharpSyntaxTree.ParseText( + text, + CSharpParseOptions.Default.WithDocumentationMode(DocumentationMode.Parse), + relativePath); + documents.Add(new SourceDocument( + relativePath, + text, + tree.GetCompilationUnitRoot())); + } + } + + documents.Sort((left, right) => + StringComparer.Ordinal.Compare(left.RelativePath, right.RelativePath)); + return new SourceCorpus(repositoryRoot, documents); + } + + public IReadOnlyList FindRenderNodeProcessOverrides() + { + return Documents.SelectMany(document => document.Root.DescendantNodes() + .OfType() + .Where(method => method.Modifiers.Any(SyntaxKind.OverrideKeyword) && IsRenderNodeProcess(method)) + .Select(method => new SourceMethod(document, method))) + .ToArray(); + } + + public IEnumerable FindWord(string value) + { + foreach (SourceDocument document in Documents) + { + foreach (SyntaxToken token in document.Root.DescendantTokens() + .Where(token => token.IsKind(SyntaxKind.IdentifierToken) + && token.ValueText == value)) + { + yield return document.ToFinding(token, $"reference to '{value}'"); + } + } + } + + public IEnumerable FindQualifiedReferences( + string containingType, + IReadOnlyList memberNames) + { + string alternatives = string.Join("|", memberNames.Select(Regex.Escape)); + var pattern = new Regex( + $@"(? FindInvocations(IReadOnlyCollection names) + { + var nameSet = new HashSet(names, StringComparer.Ordinal); + foreach (SourceDocument document in Documents) + { + foreach (InvocationExpressionSyntax invocation in document.Root.DescendantNodes() + .OfType()) + { + string? name = GetInvokedName(invocation); + if (name is not null && nameSet.Contains(name)) + yield return document.ToFinding(invocation, $"invocation of '{name}'"); + } + } + } + + public IEnumerable FindLegacyRasterizers() + { + foreach (SourceDocument document in Documents) + { + foreach (MethodDeclarationSyntax method in document.Root.DescendantNodes() + .OfType() + .Where(method => method.Identifier.ValueText == "Rasterize" + && !IsNamedType(method.ReturnType, "RenderNodeRasterization"))) + { + yield return document.ToFinding(method, + $"Rasterize returns '{method.ReturnType}'"); + } + } + } + + public IEnumerable FindOperationBackedEffectTargets(string operationType) + { + foreach (SourceDocument document in Documents) + { + foreach (ConstructorDeclarationSyntax constructor in document.Root.DescendantNodes() + .OfType() + .Where(constructor => constructor.Identifier.ValueText == "EffectTarget" + && constructor.ParameterList.Parameters.Count == 1 + && IsNamedType(constructor.ParameterList.Parameters[0].Type, operationType))) + { + yield return document.ToFinding(constructor, + "operation-backed EffectTarget constructor"); + } + + foreach (ObjectCreationExpressionSyntax creation in document.Root.DescendantNodes() + .OfType() + .Where(creation => IsNamedType(creation.Type, "EffectTarget") + && creation.ArgumentList?.Arguments.Count == 1)) + { + yield return document.ToFinding(creation, + "one-argument EffectTarget construction"); + } + } + } + + public IEnumerable FindNamedTokensInsideRenderNodeProcess( + IReadOnlyCollection names) + { + var nameSet = new HashSet(names, StringComparer.Ordinal); + foreach (SourceDocument document in Documents) + { + foreach (MethodDeclarationSyntax method in document.Root.DescendantNodes() + .OfType() + .Where(IsRenderNodeProcess)) + { + foreach (SyntaxToken token in method.DescendantTokens() + .Where(token => token.IsKind(SyntaxKind.IdentifierToken) + && nameSet.Contains(token.ValueText))) + { + yield return document.ToFinding(token, + $"isolated renderer '{token.ValueText}' inside Process"); + } + } + } + } + + public IEnumerable FindForbiddenCacheExecution(IReadOnlyCollection names) + { + var nameSet = new HashSet(names, StringComparer.Ordinal); + string cacheHelperType = BuildName("Render", "Node", "Cache", "Helper"); + string[] cacheMethodNames = + [ + BuildName("Make", "Cache"), + BuildName("Create", "Default", "Cache"), + ]; + + foreach (SourceDocument document in Documents) + { + IEnumerable roots = document.Root.DescendantNodes() + .OfType() + .Where(type => type.Identifier.ValueText == cacheHelperType) + .Cast() + .Concat(document.Root.DescendantNodes() + .OfType() + .Where(method => cacheMethodNames.Contains(method.Identifier.ValueText, StringComparer.Ordinal))); + + foreach (SyntaxNode root in roots) + { + foreach (SyntaxToken token in root.DescendantTokens() + .Where(token => token.IsKind(SyntaxKind.IdentifierToken) + && nameSet.Contains(token.ValueText))) + { + yield return document.ToFinding(token, + $"independent cache execution '{token.ValueText}'"); + } + } + } + } + + public IEnumerable FindMembersDeclaredByType( + string typeName, + IReadOnlyCollection memberNames) + { + var memberNameSet = new HashSet(memberNames, StringComparer.Ordinal); + foreach (SourceDocument document in Documents) + { + foreach (TypeDeclarationSyntax type in document.Root.DescendantNodes() + .OfType() + .Where(type => type.Identifier.ValueText == typeName)) + { + foreach (MemberDeclarationSyntax member in type.Members) + { + foreach (SyntaxToken identifier in GetDeclaredIdentifiers(member) + .Where(token => memberNameSet.Contains(token.ValueText))) + { + yield return document.ToFinding(identifier, + $"forwarding member '{identifier.ValueText}' on '{typeName}'"); + } + } + } + } + } + + private IEnumerable FindText(Regex pattern, string detail) + { + foreach (SourceDocument document in Documents) + { + foreach (Match match in pattern.Matches(document.Text.ToString())) + yield return document.ToFinding(match.Index, detail); + } + } + + private static string? GetInvokedName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + GenericNameSyntax generic => generic.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + MemberBindingExpressionSyntax memberBinding => memberBinding.Name.Identifier.ValueText, + _ => null, + }; + } + + private static IEnumerable GetDeclaredIdentifiers(MemberDeclarationSyntax member) + { + return member switch + { + MethodDeclarationSyntax method => [method.Identifier], + PropertyDeclarationSyntax property => [property.Identifier], + EventDeclarationSyntax eventDeclaration => [eventDeclaration.Identifier], + FieldDeclarationSyntax field => field.Declaration.Variables.Select(variable => variable.Identifier), + EventFieldDeclarationSyntax eventField => + eventField.Declaration.Variables.Select(variable => variable.Identifier), + _ => [], + }; + } + + private static string NormalizePath(string path) + { + return path.Replace(Path.DirectorySeparatorChar, '/'); + } + } + + private sealed record SourceDocument( + string RelativePath, + SourceText Text, + CompilationUnitSyntax Root) + { + public SourceFinding ToFinding(SyntaxNode node, string detail) + { + return ToFinding(node.SpanStart, detail); + } + + public SourceFinding ToFinding(SyntaxToken token, string detail) + { + return ToFinding(token.SpanStart, detail); + } + + public SourceFinding ToFinding(int position, string detail) + { + LinePosition linePosition = Text.Lines.GetLinePosition(position); + string snippet = Text.Lines[linePosition.Line].ToString().Trim(); + if (snippet.Length > 180) + snippet = snippet[..177] + "..."; + + return new SourceFinding(RelativePath, linePosition.Line + 1, detail, snippet); + } + } + + private sealed record SourceMethod(SourceDocument Document, MethodDeclarationSyntax Method) + { + public int Line => Method.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + + public SourceFinding ToFinding(string detail) + { + return Document.ToFinding(Method, detail); + } + } + + private sealed record SourceFinding( + string RelativePath, + int Line, + string Detail, + string Snippet); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderScaleContractTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderScaleContractTests.cs new file mode 100644 index 0000000000..887c283ae6 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/RenderScaleContractTests.cs @@ -0,0 +1,90 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class RenderScaleContractTests +{ + private static readonly Rect s_bounds = new(0, 0, 100, 80); + + [Test] + public void MapInputSupplyPreservingDemand_MapsConcreteSupplyAndPreservesUnbounded() + { + RenderScaleContract contract = RenderScaleContract.MapInputSupplyPreservingDemand( + DoubleSupply); + + Assert.Multiple(() => + { + Assert.That( + contract.Resolve([EffectiveScale.At(1.5f)], s_bounds, outputScale: 1, maxWorkingScale: 10), + Is.EqualTo(EffectiveScale.At(3))); + Assert.That( + contract.Resolve([EffectiveScale.Unbounded], s_bounds, outputScale: 1, maxWorkingScale: 10), + Is.EqualTo(EffectiveScale.Unbounded)); + Assert.That( + contract.Resolve([EffectiveScale.At(3)], s_bounds, outputScale: 1, maxWorkingScale: 4), + Is.EqualTo(EffectiveScale.At(4))); + }); + } + + [Test] + public void MapInputSupplyPreservingDemand_RequiresAnElementWiseSingleInputTopology() + { + RenderScaleContract contract = RenderScaleContract.MapInputSupplyPreservingDemand( + static input => input); + + Assert.Multiple(() => + { + Assert.That( + () => contract.Resolve([], s_bounds, outputScale: 1, maxWorkingScale: 4), + Throws.TypeOf()); + Assert.That( + () => contract.Resolve( + [EffectiveScale.At(1), EffectiveScale.At(2)], + s_bounds, + outputScale: 1, + maxWorkingScale: 4), + Throws.TypeOf()); + Assert.That( + () => contract.ThrowIfIncompatible(OpaqueRenderTopology.Map, "scale"), + Throws.Nothing); + Assert.That( + () => contract.ThrowIfIncompatible(OpaqueRenderTopology.Source, "scale"), + Throws.TypeOf()); + }); + } + + [Test] + public void MapInputSupplyPreservingDemand_HasStableKindSpecificStructuralIdentity() + { + RenderScaleContract first = RenderScaleContract.MapInputSupplyPreservingDemand(DoubleSupply); + RenderScaleContract second = RenderScaleContract.MapInputSupplyPreservingDemand(DoubleSupply); + RenderScaleContract custom = RenderScaleContract.Custom( + static _ => 2); + + Assert.Multiple(() => + { + Assert.That(first.StructuralIdentity, Is.EqualTo(second.StructuralIdentity)); + Assert.That(first.StructuralIdentity, Is.Not.EqualTo(custom.StructuralIdentity)); + }); + } + + [Test] + public void MapInputSupplyPreservingDemand_RejectsMutableCallbackCapture() + { + var mutable = new List { 2 }; + + Assert.That( + () => RenderScaleContract.MapInputSupplyPreservingDemand( + input => input.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(input.Value * mutable[0])), + Throws.TypeOf()); + } + + private static EffectiveScale DoubleSupply(EffectiveScale input) + => input.IsUnbounded + ? EffectiveScale.Unbounded + : EffectiveScale.At(input.Value * 2); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/ValueReplaySafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/ValueReplaySafetyTests.cs new file mode 100644 index 0000000000..e6618f87ed --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/Recording/ValueReplaySafetyTests.cs @@ -0,0 +1,237 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering.Recording; + +[TestFixture] +public sealed class ValueReplaySafetyTests +{ + [TestCase(TransformOperator.Prepend, true)] + [TestCase(TransformOperator.Append, false)] + [TestCase(TransformOperator.Set, false)] + public void Transform_OnlyPrependPublishesAValueReplayMap( + TransformOperator transformOperator, + bool expectedValueReplay) + { + using var transform = new TransformRenderNode( + Matrix.CreateTranslation(3.25f, 4.5f), + transformOperator); + transform.AddChild(new RectangleRenderNode( + new Rect(2, 3, 12, 8), + Brushes.Resource.White, + null)); + using var request = CreateRequest(cacheEnabled: false); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(transform); + RenderFragmentReference root = GetSingleRoot(graph); + var payload = (TargetScopeRenderFragmentPayload)root.Payload!; + + Assert.Multiple(() => + { + Assert.That(payload.Description.IsValueReplayMap, Is.EqualTo(expectedValueReplay)); + Assert.That(root.CanBeUsedAsValueInput, Is.EqualTo(expectedValueReplay)); + }); + } + + [Test] + public void ValueReplayMap_RejectsContributingTargetCapture() + { + using var node = new TargetCaptureReplayNode(); + using var request = CreateRequest(cacheEnabled: false); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentReference root = GetSingleRoot(graph); + + Assert.Multiple(() => + { + Assert.That(root.ContributesValuesToTarget, Is.True); + Assert.That(root.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + Assert.That(root.CanBeUsedAsValueInput, Is.False, + "A target capture must not be replayed against a fresh transparent value target."); + Assert.That(RenderFragmentTargetDependency.HasExternalTargetDependency(root), Is.True); + }); + } + + [Test] + public void ValueReplayMap_AllowsSelfContainedFiniteLayerCacheCandidate() + { + using var node = new FiniteLayerReplayNode(); + node.Cache.RecordStableRequests(); + using var request = CreateRequest(cacheEnabled: true, RenderRequestPurpose.Frame); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentReference root = GetSingleRoot(graph); + var cacheContext = new RenderCacheResolutionContext( + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + new RenderCacheDeviceContextIdentity("device", "context")); + using CompiledRenderRequest compiled = new RenderRequestCompiler( + renderCacheContext: cacheContext).Compile(request, graph); + RenderCacheDecision decision = compiled.CacheResolution.Decisions.Single(); + + Assert.Multiple(() => + { + Assert.That(root.HasTargetEffects, Is.True, + "A finite Layer still owns target-scoped execution metadata."); + Assert.That(RenderFragmentTargetDependency.HasExternalTargetDependency(root), Is.False); + Assert.That(root.CanBeUsedAsValueInput, Is.True); + Assert.That(graph.CacheCandidates, Has.Length.EqualTo(1)); + Assert.That(decision.Kind, Is.EqualTo(RenderCacheResolutionKind.MissCapture)); + }); + } + + [Test] + public void AppendTransform_LayerMaterializesAtPlannedDestinationDensity() + { + var requestedSizes = new List(); + using RenderNode root = CreateTransformedLayer( + Matrix.CreateScale(4, 4), + TransformOperator.Append, + new Rect(0, 0, 8, 6)); + using var renderer = CreateRenderer(root, new RecordingCpuTargetFactory(requestedSizes)); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(requestedSizes, Does.Contain(new PixelSize(8, 6)), + "The executor must use the planner's unscaled Layer demand across an Append scope."); + }); + } + + [Test] + public void PerspectiveTransform_LayerRendersWithoutScalarDensity() + { + var perspective = new Rotation3DTransform(0, 55, 0, 0, 0, 0) + { + Depth = { CurrentValue = 400 }, + }; + using RenderNode root = CreateTransformedLayer( + perspective.CreateMatrix(Composition.CompositionContext.Default), + TransformOperator.Prepend, + new Rect(0, 0, 24, 16)); + using var renderer = CreateRenderer(root, new RecordingCpuTargetFactory([])); + + Assert.That(() => renderer.Rasterize().Dispose(), Throws.Nothing); + } + + private static RenderRequest CreateRequest( + bool cacheEnabled, + RenderRequestPurpose purpose = RenderRequestPurpose.Auxiliary) + => new(new RenderRequestOptions( + RenderIntent.Preview, + purpose, + targetDomain: new Rect(0, 0, 64, 64), + outputScale: 1, + maxWorkingScale: 1, + cachePolicy: cacheEnabled ? RenderCacheOptions.Enabled : RenderCacheOptions.Disabled)); + + private static RenderNodeRenderer CreateRenderer( + RenderNode root, + IRenderTargetFactory targetFactory) + => new( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = 8, + CacheOptions = RenderCacheOptions.Disabled, + }, + TargetFactory = targetFactory, + }); + + private static RenderNode CreateTransformedLayer( + Matrix transform, + TransformOperator transformOperator, + Rect bounds) + { + var root = new TransformRenderNode(transform, transformOperator); + var layer = new LayerRenderNode(bounds); + layer.AddChild(new RectangleRenderNode(bounds, Brushes.Resource.White, null)); + root.AddChild(layer); + return root; + } + + private static RenderFragmentReference GetSingleRoot(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } + + private static TargetScopeDescription CreateIdentityValueReplayDescription(string key) + => TargetScopeDescription.CreateValueReplayMap( + session => session.Canvas.Use(_ => session.ReplayInput()), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + + private sealed class TargetCaptureReplayNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + var bounds = new Rect(0, 0, 24, 16); + RenderFragmentHandle capture = context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.OutputBounds, + TargetCaptureScaleContract.MaterializeAtWorkingScale)); + RenderFragmentHandle contributing = context.ContributeValues(capture); + context.Publish(context.TargetScope( + contributing, + CreateIdentityValueReplayDescription(nameof(TargetCaptureReplayNode)))); + } + } + + private sealed class FiniteLayerReplayNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + var bounds = new Rect(0, 0, 24, 16); + RenderFragmentHandle source = context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.White)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector)); + RenderFragmentHandle layer = context.Layer([source], bounds); + context.Publish(context.TargetScope( + layer, + CreateIdentityValueReplayDescription(nameof(FiniteLayerReplayNode)))); + } + } + + private sealed class RecordingCpuTargetFactory(List requestedSizes) : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize size = allocation.DeviceSize; + requestedSizes.Add(size); + SKSurface surface = SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU test surface."); + return new CpuRenderTarget(surface, size); + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs index 0093094ec6..656e027157 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectClipRenderNodeTest.cs @@ -1,5 +1,7 @@ using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -27,26 +29,289 @@ public void Update_ShouldReturnTrue_WhenPropertiesDoNotMatch() } [Test] - public void Process_WithoutInput_ShouldReturnEmptyRenderNodeOperation() + public void Update_ShouldNotMarkChanges_WhenAllPropertiesMatch() { - var context = new RenderNodeContext([]); + var rect = new Rect(0, 0, 100, 100); + var operation = ClipOperation.Intersect; + using var node = new RectClipRenderNode(rect, operation); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(rect, operation), Is.False); + Assert.That(node.HasChanges, Is.False); + }); + } + + [Test] + public void Update_ShouldMarkChanges_WhenPropertiesDoNotMatch() + { + var rect = new Rect(0, 0, 100, 100); + var operation = ClipOperation.Intersect; + using var node = new RectClipRenderNode(rect, operation); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(default, operation), Is.True); + Assert.That(node.HasChanges, Is.True); + }); + } + + [Test] + public void UnchangedReRecording_ShouldAdmitTheClipScopeToTheCache() + { + var rect = new Rect(0, 0, 100, 100); + var operation = ClipOperation.Intersect; + using var node = new RectClipRenderNode(rect, operation); + + for (int frame = 0; frame < RenderNodeCache.StableRequestCount; frame++) + { + node.Update(rect, operation); + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } + + Assert.That(node.Cache.CanCapture, Is.True); + } + + [Test] + public void UnchangedClipScope_ShouldNotBlockAnAncestorCache() + { + var rect = new Rect(0, 0, 100, 100); + var operation = ClipOperation.Intersect; + using var parent = new ContainerRenderNode(); + var node = new RectClipRenderNode(rect, operation); + parent.AddChild(node); + parent.SettleConstruction(); + + for (int frame = 0; frame < RenderNodeCache.StableRequestCount; frame++) + { + node.Update(rect, operation); + RenderNodeCacheHelper.BeginLifecycle(parent).CompleteSuccessfully(advanceWarmup: true); + } + + Assert.Multiple(() => + { + Assert.That(parent.Cache.CanCapture, Is.True); + Assert.That(node.Cache.CanCapture, Is.True); + }); + } + + [Test] + public void Measure_WithoutChild_ShouldReportNoFragments() + { + using var node = new RectClipRenderNode(new Rect(0, 0, 100, 100), ClipOperation.Intersect); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.HasFragments, Is.False); + } + + [Test] + public void Measure_WithChild_ShouldReportScopedFragment() + { + using var node = new RectClipRenderNode(new Rect(0, 0, 100, 100), ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode( + new Rect(10, 20, 30, 40), + Brushes.Resource.White, + null)); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(10, 20, 30, 40))); + }); + } + + [Test] + public void Intersect_ClipsOutputBoundsAndHitTesting() + { + var clip = new Rect(20, 10, 30, 40); + using var node = new RectClipRenderNode(clip, ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + using var renderer = CreateRenderer(node); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(clip)); + Assert.That(measurement.QueryBounds, Is.EqualTo(clip)); + Assert.That(renderer.HitTest(new Point(25, 25)), Is.True); + Assert.That(renderer.HitTest(new Point(10, 25)), Is.False); + }); + } - var node = new RectClipRenderNode(new Rect(0, 0, 100, 100), ClipOperation.Intersect); - var operations = node.Process(context); + [Test] + public void ClipStateChanges_ReuseTheStructuralPlan() + { + using var cache = new StructuralPlanCache(); + using var node = new RectClipRenderNode( + new Rect(10, 10, 40, 40), + ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + + using (Compile(cache, node)) + { + } - Assert.That(operations, Is.Empty); + node.Update(new Rect(20, 20, 30, 30), ClipOperation.Difference); + using CompiledRenderRequest compiled = Compile(cache, node); + + Assert.Multiple(() => + { + Assert.That(compiled.Measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 100, 100))); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); } [Test] - public void Process_WithInput_ShouldReturnExpectedRenderNodeOperation() + public void EquivalentIndependentlyConstructedScopeDefinitions_ReuseTheStructuralPlan() { - var context = new RenderNodeContext([ - RenderNodeOperation.CreateLambda(default, _ => { }) - ]); + using var cache = new StructuralPlanCache(); + using var node = new EquivalentScopeDefinitionNode(); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + + using (Compile(cache, node)) + { + } + using (Compile(cache, node)) + { + } + + Assert.Multiple(() => + { + Assert.That(node.DefinitionCreations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(1)); + Assert.That(cache.Statistics.Hits, Is.EqualTo(1)); + }); + } + + [Test] + public void DifferentScopeDefinitionContracts_RecompileTheStructuralPlan() + { + using var cache = new StructuralPlanCache(); + using var node = new ContractChangingScopeDefinitionNode(); + node.AddChild(new RectangleRenderNode( + new Rect(0, 0, 100, 100), + Brushes.Resource.White, + null)); + + using (Compile(cache, node)) + { + } + + node.UseFullInputContract = true; + using (Compile(cache, node)) + { + } + + Assert.Multiple(() => + { + Assert.That(node.DefinitionCreations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Compilations, Is.EqualTo(2)); + Assert.That(cache.Statistics.Hits, Is.Zero); + }); + } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + private static CompiledRenderRequest Compile(StructuralPlanCache cache, RenderNode node) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + cachePolicy: RenderCacheOptions.Disabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler(cache).Compile(request, graph); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class EquivalentScopeDefinitionNode : ContainerRenderNode + { + public int DefinitionCreations { get; private set; } + + public override void Process(RenderNodeContext context) + { + DefinitionCreations++; + TargetScopeDefinition definition = TargetScopeDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => + { + using (canvas.Push()) + { + session.ReplayInput(); + } + }), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + context.PublishMappedInputs( + definition.Call(default), + static (current, input, call) => current.TargetScope(input, call)); + } + + private readonly record struct ScopeState; + } + + private sealed class ContractChangingScopeDefinitionNode : ContainerRenderNode + { + public bool UseFullInputContract { get; set; } + + public int DefinitionCreations { get; private set; } - var node = new RectClipRenderNode(new Rect(0, 0, 100, 100), ClipOperation.Intersect); - var operations = node.Process(context); + public override void Process(RenderNodeContext context) + { + DefinitionCreations++; + RenderBoundsContract bounds = UseFullInputContract + ? RenderBoundsContract.FullInput + : RenderBoundsContract.Identity; + TargetScopeDefinition definition = TargetScopeDefinition.Create( + static (session, _) => session.Canvas.Use(canvas => + { + using (canvas.Push()) + { + session.ReplayInput(); + } + }), + bounds, + RenderHitTestContract.AnyInput, + RenderScaleContract.PreserveInputSupply, + deviceGridSensitivity: RenderDeviceGridSensitivity.Insensitive, + deviceGridMapping: RenderDeviceGridMapping.Preserved); + context.PublishMappedInputs( + definition.Call(default), + static (current, input, call) => current.TargetScope(input, call)); + } - Assert.That(operations, Is.Not.Empty); + private readonly record struct ScopeState; } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectangleRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectangleRenderNodeTest.cs index 30d0fc1c3b..f7b71ec620 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectangleRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RectangleRenderNodeTest.cs @@ -1,6 +1,7 @@ using Beutl.Composition; using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; using Beutl.Media; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -47,25 +48,79 @@ public void Update_ShouldReturnTrue_WhenPropertiesDoNotMatch() } [Test] - public void Process_ShouldReturnCorrectRenderNodeOperation() + public void Update_ShouldNotMarkChanges_WhenAllPropertiesMatch() { var rect = new Rect(0, 0, 100, 100); var fill = Brushes.Resource.Red; + using var node = new RectangleRenderNode(rect, fill, null); + node.HasChanges = false; + + Assert.Multiple(() => + { + Assert.That(node.Update(rect, fill, null), Is.False); + Assert.That(node.HasChanges, Is.False); + }); + } + + [Test] + public void Update_ShouldMarkChanges_WhenPropertiesDoNotMatch() + { + var rect1 = new Rect(0, 0, 100, 100); + var rect2 = new Rect(0, 0, 200, 200); + var fill1 = Brushes.Resource.Red; + var fill2 = Brushes.Resource.Blue; var pen = new Pen(); pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 1; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); + using var node = new RectangleRenderNode(rect1, fill1, null); + + node.HasChanges = false; + bool rectChanged = node.Update(rect2, fill1, null); + bool rectMarked = node.HasChanges; + + node.HasChanges = false; + bool fillChanged = node.Update(rect2, fill2, null); + bool fillMarked = node.HasChanges; + + node.HasChanges = false; + bool penChanged = node.Update(rect2, fill2, penResource); + bool penMarked = node.HasChanges; + + Assert.Multiple(() => + { + Assert.That(rectChanged, Is.True); + Assert.That(rectMarked, Is.True); + Assert.That(fillChanged, Is.True); + Assert.That(fillMarked, Is.True); + Assert.That(penChanged, Is.True); + Assert.That(penMarked, Is.True); + }); + } - var node = new RectangleRenderNode(rect, fill, penResource); - var operations = node.Process(context); + [Test] + public void ChangedParameters_ShouldRevokeAnAdmittedCache() + { + var rect = new Rect(0, 0, 100, 100); + var fill = Brushes.Resource.Red; + using var node = new RectangleRenderNode(rect, fill, null); + + for (int frame = 0; frame < RenderNodeCache.StableRequestCount; frame++) + { + node.Update(rect, fill, null); + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } + + Assert.That(node.Cache.CanCapture, Is.True, "a stable rectangle must become a cache candidate"); + + node.Update(new Rect(0, 0, 200, 200), fill, null); + RenderNodeCacheHelper.BeginLifecycle(node); - Assert.That(operations, Is.Not.Null); - Assert.That(operations.Length, Is.EqualTo(1)); + Assert.That(node.Cache.CanCapture, Is.False); } [Test] - public void HitTest_ShouldReturnTrue_WhenPointIsInsideRectangle() + public void Measure_ShouldReportRecordedFragment() { var rect = new Rect(0, 0, 100, 100); var fill = Brushes.Resource.Red; @@ -73,13 +128,32 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideRectangle() pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 1; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); + using var node = new RectangleRenderNode(rect, fill, penResource); + using var renderer = CreateRenderer(node); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.ValueCardinality, Is.EqualTo(RenderValueCardinality.Single)); + }); + } - var node = new RectangleRenderNode(rect, fill, penResource); - var operations = node.Process(context); + [Test] + public void HitTest_ShouldReturnTrue_WhenPointIsInsideRectangle() + { + var rect = new Rect(0, 0, 100, 100); + var fill = Brushes.Resource.Red; + var pen = new Pen(); + pen.Brush.CurrentValue = Brushes.Black; + pen.Thickness.CurrentValue = 1; + var penResource = pen.ToResource(CompositionContext.Default); + using var node = new RectangleRenderNode(rect, fill, penResource); + using var renderer = CreateRenderer(node); var point = new Point(50, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } [Test] @@ -91,13 +165,11 @@ public void HitTest_ShouldReturnFalse_WhenPointIsOutsideRectangle() pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 1; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new RectangleRenderNode(rect, fill, penResource); - var operations = node.Process(context); + using var node = new RectangleRenderNode(rect, fill, penResource); + using var renderer = CreateRenderer(node); var point = new Point(150, 150); - Assert.That(operations[0].HitTest(point), Is.False); + Assert.That(renderer.HitTest(point), Is.False); } [Test] @@ -108,12 +180,19 @@ public void HitTest_ShouldReturnTrue_WhenPointIsInsideRectangleStroke() pen.Brush.CurrentValue = Brushes.Black; pen.Thickness.CurrentValue = 50; var penResource = pen.ToResource(CompositionContext.Default); - var context = new RenderNodeContext([]); - - var node = new RectangleRenderNode(rect, null, penResource); - var operations = node.Process(context); + using var node = new RectangleRenderNode(rect, null, penResource); + using var renderer = CreateRenderer(node); var point = new Point(30, 50); - Assert.That(operations[0].HitTest(point), Is.True); + Assert.That(renderer.HitTest(point), Is.True); } + + private static RenderNodeRenderer CreateRenderer(RenderNode node) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ReferencedChildRevalidationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ReferencedChildRevalidationTests.cs new file mode 100644 index 0000000000..26faf3e46c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ReferencedChildRevalidationTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Immutable; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.Threading; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[NonParallelizable] +[TestFixture] +public class ReferencedChildRevalidationTests +{ + [Test] + public void ReferencedChild_ReportsAChangeOnlyWhenTheReferencePointsSomewhereElse() + { + RenderThread.Dispatcher.Invoke(() => + { + using var first = new RectangleRenderNode(new Rect(0, 0, 4, 4), Brushes.Resource.White, null); + using var second = new RectangleRenderNode(new Rect(0, 0, 4, 4), Brushes.Resource.White, null); + var drawable = new ReferencingDrawable(first); + using Renderer renderer = CreateRenderer(); + + renderer.Render(CreateFrame(drawable)); + bool steadyState = drawable.LastRecordingObservedChange; + + drawable.Child = second; + renderer.Render(CreateFrame(drawable)); + bool afterSwap = drawable.LastRecordingObservedChange; + + Assert.Multiple(() => + { + Assert.That(steadyState, Is.False, + "Re-recording an unchanged reference must not invalidate what depends on it."); + Assert.That(afterSwap, Is.True, + "Pointing the reference at another node must revalidate it."); + Assert.That(FindReference(renderer, drawable).Child, Is.SameAs(second)); + }); + }); + } + + [Test] + public void ARecordingFailure_LeavesTheRendererAbleToRecordTheNextFrame() + { + RenderThread.Dispatcher.Invoke(() => + { + var drawable = new SwitchableFaultingDrawable(); + using Renderer renderer = CreateRenderer(); + renderer.Render(CreateFrame(drawable)); + + drawable.ShouldFault = true; + Assert.Throws(() => renderer.Render(CreateFrame(drawable))); + + drawable.ShouldFault = false; + Assert.DoesNotThrow( + () => renderer.Render(CreateFrame(drawable)), + "A failed recording must not poison the retained node tree."); + }); + } + + private static ReferencesChildRenderNode FindReference(Renderer renderer, Drawable drawable) + { + DrawableRenderNode node = renderer.FindRenderNode(drawable) + ?? throw new InvalidOperationException("The drawable was never recorded."); + return node.Children.OfType().Single(); + } + + private static Renderer CreateRenderer() + => new( + width: 16, + height: 16, + intent: RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(16, 16)); + + private static CompositionFrame CreateFrame(params Drawable[] drawables) + => new( + [.. drawables.Select(static drawable => + (EngineObject.Resource)drawable.ToResource(CompositionContext.Default))], + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(16, 16), + null); + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} + +// Top-level partial because EngineObjectResourceGenerator does not support nested types. +internal sealed partial class ReferencingDrawable : Drawable +{ + public ReferencingDrawable(RenderNode child) + { + Child = child; + } + + public RenderNode Child { get; set; } + + public bool LastRecordingObservedChange { get; private set; } + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + => context.DrawNode( + Child, + static node => new ReferencesChildRenderNode(node), + (reference, node) => LastRecordingObservedChange = reference.Update(node)); + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(4, 4); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} + +internal sealed partial class SwitchableFaultingDrawable : Drawable +{ + public bool ShouldFault { get; set; } + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + context.DrawRectangle(new Rect(0, 0, 4, 4), Brushes.Resource.White, null); + if (ShouldFault) + { + throw new InvalidOperationException("recording failed"); + } + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(4, 4); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderCacheTestSupport.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderCacheTestSupport.cs new file mode 100644 index 0000000000..3bf951165f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderCacheTestSupport.cs @@ -0,0 +1,83 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +internal static class RenderCacheTestSupport +{ + public static RenderOutputCacheIdentity CreateCacheIdentity( + Rect bounds, + string name = "test-cache", + string device = "test-device", + string context = "test-context") + { + var fragment = new RenderFragmentReference( + RenderFragmentKind.Layer, + bounds, + EffectiveScale.At(1), + RenderValueCardinality.Single, + contributesValuesToTarget: true, + canBeUsedAsValueInput: true, + hasTargetEffects: false, + hasOpaqueExternalWork: false, + inputs: null, + payload: null, + hitTest: null); + return new RenderOutputCacheIdentity( + name, + RenderFragmentOutputIdentity.Create(fragment, new RenderRequestId(1)), + bounds, + RequiredRegion.Region(bounds), + density: 1, + RenderCacheFormatIdentity.LinearPremultipliedRgba16Float, + RenderIntent.Preview, + RenderRequestPurpose.Frame, + FusionMode.Enabled, + new RenderCacheDeviceContextIdentity(device, context)); + } + + public static RenderNodeCachePublication CreatePublication( + RenderNodeCache cache, + RenderTarget target, + Rect bounds, + string name = "test-cache", + string device = "test-device", + string context = "test-context") + { + return new RenderNodeCachePublication( + cache, + CreateCacheIdentity(bounds, name, device, context), + [new RenderNodeCachedValue(target, bounds, EffectiveScale.At(1))]); + } + + /// + /// Drives a cache to the stable-request count that lets it capture, standing in for the manual + /// render-count control the pipeline no longer exposes. + /// + public static void RecordStableRequests( + this RenderNodeCache cache, + int count = RenderNodeCache.StableRequestCount) + { + for (int index = 0; index < count; index++) + cache.RecordSuccessfulStableRequest(); + } + + /// + /// Clears the change a subtree reports from having just been built, so a fixture starts from the settled + /// state a running renderer reaches one frame after assembling the same tree. + /// + /// + /// Attaching a child changes what its container composes, so a freshly assembled tree is dirty. A test + /// that wants to observe cache warmup, or that pre-warms a cache directly, is not interested in that + /// first frame. + /// + public static void SettleConstruction(this RenderNode node) + { + ArgumentNullException.ThrowIfNull(node); + node.ClearChanges(node.ChangeVersion); + foreach (RenderNode child in node.ChildNodes.ToArray()) + child.SettleConstruction(); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderDescriptionAllocationTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderDescriptionAllocationTests.cs new file mode 100644 index 0000000000..928b82ed3c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderDescriptionAllocationTests.cs @@ -0,0 +1,354 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RenderDescriptionAllocationTests +{ + private const int Iterations = 20000; + private const int SceneFrames = 40; + private const int SceneWarmupFrames = 8; + + // Measured steady state is ~267,700 bytes/frame, leaving about 12% headroom. That headroom is for the + // platform-dependent part of the scene - font fallback for its TextBlock - rather than for measurement + // noise, so a whole-frame regression fails here while per-call regressions are caught by the comparative + // tests in this fixture. + private const long SceneBytesPerFrameCeiling = 300_000; + + // The same scene with the render cache warm allocates about 321,500 bytes/frame, leaving about 7% + // headroom. Each machine reports one deterministic value, but not the same one: a Linux runner and a + // macOS machine measured 261 bytes apart on the same commit, so the figure is platform-specific rather + // than a property of the scene, and the spread is far below the headroom either budget keeps. + private const long WarmCacheSceneBytesPerFrameCeiling = 345_000; + + private static readonly object s_explicitKey = new(); + private static readonly PixelSize s_frameSize = new(240, 160); + + [Test] + public void DefaultStructuralKey_AllocatesNoMoreThanAnExplicitOne() + { + Warm(); + + long withDefaultKey = MeasureBytesPerCall(structuralKey: null); + long withExplicitKey = MeasureBytesPerCall(s_explicitKey); + + TestContext.Out.WriteLine($"default key: {withDefaultKey} bytes/call"); + TestContext.Out.WriteLine($"explicit key: {withExplicitKey} bytes/call"); + Assert.That( + withDefaultKey, + Is.LessThanOrEqualTo(withExplicitKey), + "resolving the default structural key runs once per node per frame and must not allocate"); + } + + [Test] + public void StatePassing_AllocatesNoMoreThanTheCapturingRequestLocalOptOut() + { + WarmState(); + + long statePassing = MeasureBytesPerCall(static () => CreateWithState(new Rect(0, 0, 4, 4))); + long requestLocal = MeasureBytesPerCall(static () => CreateRequestLocalCapturing(new Rect(0, 0, 4, 4))); + + TestContext.Out.WriteLine($"state-passing: {statePassing} bytes/call"); + TestContext.Out.WriteLine($"capturing request-local: {requestLocal} bytes/call"); + Assert.That( + statePassing, + Is.LessThanOrEqualTo(requestLocal), + "a static callback plus a state binding must not cost more than the closure it replaced"); + } + + [Test] + public void NestedTupleState_CostsNoMoreThanTheFlatTupleItValidatesAs() + { + WarmState(); + + long flat = MeasureBytesPerCall( + static () => TargetCommandDescription.Create( + (1, 2, 3, 4), + static (_, _) => { }, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None)); + long nested = MeasureBytesPerCall( + static () => TargetCommandDescription.Create( + ((1, 2), (3, 4)), + static (_, _) => { }, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None)); + + TestContext.Out.WriteLine($"flat tuple state: {flat} bytes/call"); + TestContext.Out.WriteLine($"nested tuple state: {nested} bytes/call"); + Assert.That( + nested, + Is.LessThanOrEqualTo(flat), + "descending through tuple element types happens once per closed state type, not per call"); + } + + /// + /// A pure metadata callback is validated once per node per frame, and every transform in a scene hands + /// the walk a matrix. Reading a fixed struct to accept it boxes each of its numbers, which is a per-frame + /// cost for a verdict its declared type already settles. + /// + [Test] + public void ValidatingACallbackOverFixedStructCaptures_DoesNotAllocate() + { + var metadata = new MatrixMetadata(Matrix.CreateScale(2, 2)); + Func callback = metadata.TransformBounds; + for (int index = 0; index < 200; index++) + RenderDescriptionValidation.ValidatePureMetadataCallback(callback, nameof(callback)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < Iterations; index++) + RenderDescriptionValidation.ValidatePureMetadataCallback(callback, nameof(callback)); + long after = GC.GetAllocatedBytesForCurrentThread(); + + long bytesPerCall = (after - before) / Iterations; + TestContext.Out.WriteLine($"validating a fixed-struct capture: {bytesPerCall} bytes/call"); + Assert.That( + bytesPerCall, + Is.Zero, + "a capture whose declared type already settles the verdict must not be read to reach it"); + } + + [Test] + [NonParallelizable] + public void RepresentativeScene_AllocatesWithinItsPerFrameBudget_WithTheCacheDisabled() + { + long bytesPerFrame = RenderThread.Dispatcher.Invoke( + static () => MeasureSceneBytesPerFrame(warmCache: false)); + + TestContext.Out.WriteLine($"representative scene, cache disabled: {bytesPerFrame} bytes/frame"); + Assert.That( + bytesPerFrame, + Is.LessThan(SceneBytesPerFrameCeiling), + "recording one frame of the representative scene must stay within its allocation budget"); + } + + [Test] + [NonParallelizable] + public void RepresentativeScene_AllocatesWithinItsPerFrameBudget_WithTheCacheActive() + { + long bytesPerFrame = RenderThread.Dispatcher.Invoke( + static () => MeasureSceneBytesPerFrame(warmCache: true)); + + TestContext.Out.WriteLine($"representative scene, cache active: {bytesPerFrame} bytes/frame"); + Assert.That( + bytesPerFrame, + Is.LessThan(WarmCacheSceneBytesPerFrameCeiling), + "recording one frame with cache candidates recorded must stay within its allocation budget"); + } + + private static long MeasureSceneBytesPerFrame(bool warmCache) + { + Drawable.Resource[] resources = CreateSceneResources(); + try + { + using var root = new DrawableRenderNode(resources[0]); + using (var context = new GraphicsContext2D(root, s_frameSize.ToSize(1))) + { + context.Clear(); + foreach (Drawable.Resource resource in resources) + context.DrawDrawable(resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(default, s_frameSize.ToSize(1)), + CacheOptions = warmCache + ? RenderCacheOptions.Enabled + : RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + var revalidated = new HashSet(ReferenceEqualityComparer.Instance); + for (int frame = 0; frame < SceneWarmupFrames; frame++) + { + if (warmCache) + IncrementRenderCounts(root, revalidated); + renderer.Rasterize().Dispose(); + } + + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int frame = 0; frame < SceneFrames; frame++) + { + if (warmCache) + IncrementRenderCounts(root, revalidated); + renderer.Rasterize().Dispose(); + } + + long after = GC.GetAllocatedBytesForCurrentThread(); + return (after - before) / SceneFrames; + } + finally + { + foreach (Drawable.Resource resource in resources) + resource.Dispose(); + } + } + + /// + /// Mirrors the per-frame walk in Renderer.RevalidateAll. Without it + /// never reaches , so no + /// cache candidate is recorded and the whole cache-resolution path stays out of the measurement. + /// + private static void IncrementRenderCounts(RenderNode root, HashSet revalidated) + { + revalidated.Clear(); + Visit(root); + return; + + void Visit(RenderNode current) + { + if (current.IsDisposed || !revalidated.Add(current)) + return; + + ReadOnlySpan children = current.ChildNodes; + for (int index = 0; index < children.Length; index++) + Visit(children[index]); + + current.HasChanges = false; + } + } + + private static Drawable.Resource[] CreateSceneResources() + { + var background = new RectShape + { + Width = { CurrentValue = s_frameSize.Width }, + Height = { CurrentValue = s_frameSize.Height }, + Fill = { CurrentValue = Brushes.CornflowerBlue }, + }; + + var accent = new EllipseShape + { + Width = { CurrentValue = 76 }, + Height = { CurrentValue = 76 }, + Fill = { CurrentValue = Brushes.OrangeRed }, + FilterEffect = { CurrentValue = new Brightness { Amount = { CurrentValue = 78 } } }, + Transform = { CurrentValue = new TranslateTransform(44, -18) }, + }; + + var label = new TextBlock + { + FontFamily = { CurrentValue = FontFamily.Default }, + Size = { CurrentValue = 28 }, + Fill = { CurrentValue = Brushes.White }, + Text = { CurrentValue = "CACHE" }, + Transform = { CurrentValue = new TranslateTransform(-28, 30) }, + }; + + CompositionContext context = CompositionContext.Default; + return + [ + background.ToResource(context), + accent.ToResource(context), + label.ToResource(context), + ]; + } + + private static void Warm() + { + for (int index = 0; index < 200; index++) + { + _ = Create(null); + _ = Create(s_explicitKey); + } + } + + private static void WarmState() + { + for (int index = 0; index < 200; index++) + { + _ = CreateWithState(new Rect(0, 0, 4, 4)); + _ = CreateRequestLocalCapturing(new Rect(0, 0, 4, 4)); + } + } + + private static TargetCommandDescription CreateWithState(Rect bounds) + => TargetCommandDescription.Create( + bounds, + static (session, state) => session.Canvas.Use(canvas => canvas.ReplaceAffectedRegion(default)), + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None); + + private static TargetCommandDescription CreateRequestLocalCapturing(Rect bounds) + => TargetCommandDescription.CreateRequestLocal( + session => session.Canvas.Use(canvas => canvas.ReplaceAffectedRegion( + bounds.Width > 0 ? Colors.White : Colors.Black)), + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None); + + private static long MeasureBytesPerCall(object? structuralKey) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < Iterations; index++) + _ = Create(structuralKey); + long after = GC.GetAllocatedBytesForCurrentThread(); + return (after - before) / Iterations; + } + + private static long MeasureBytesPerCall(Func create) + { + for (int index = 0; index < 200; index++) + _ = create(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < Iterations; index++) + _ = create(); + long after = GC.GetAllocatedBytesForCurrentThread(); + return (after - before) / Iterations; + } + + private static TargetCommandDescription Create(object? structuralKey) + => TargetCommandDescription.CreateRequestLocal( + Execute, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None); + + private static void Execute(TargetCommandSession session) + { + } + + private sealed class MatrixMetadata(Matrix transform) + { + private readonly Matrix _transform = transform; + private readonly bool _hasInverse = transform.HasInverse; + + public Rect TransformBounds(Rect bounds) => bounds.TransformToAABB(_transform); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeHasChangesTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeHasChangesTests.cs new file mode 100644 index 0000000000..427cdafacc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeHasChangesTests.cs @@ -0,0 +1,124 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Graphics3D; +using Beutl.Media; +using Beutl.Media.Source; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public class RenderNodeHasChangesTests +{ + [OneTimeSetUp] + public void OneTimeSetUp() + { + TestMediaHelper.RegisterTestDecoder(); + } + + public static IEnumerable NoOpUpdateCases() + { + yield return new TestCaseData((Func)EllipseCase).SetName("EllipseRenderNode"); + yield return new TestCaseData((Func)RectangleCase).SetName("RectangleRenderNode"); + yield return new TestCaseData((Func)GeometryCase).SetName("GeometryRenderNode"); + yield return new TestCaseData((Func)TransformCase).SetName("TransformRenderNode"); + yield return new TestCaseData((Func)CustomTransformCase).SetName("CustomTransformRenderNode"); + yield return new TestCaseData((Func)ImageSourceCase).SetName("ImageSourceRenderNode"); + yield return new TestCaseData((Func)VideoSourceCase).SetName("VideoSourceRenderNode"); + yield return new TestCaseData((Func)Scene3DCase).SetName("Scene3DRenderNode"); + } + + [TestCaseSource(nameof(NoOpUpdateCases))] + public void Update_ShouldNotClearAMarkLeftByAnotherWriter(Func factory) + { + NoOpUpdateCase testCase = factory(); + using RenderNode node = testCase.Node; + node.HasChanges = true; + + bool changed = testCase.NoOpUpdate(); + + Assert.Multiple(() => + { + Assert.That(changed, Is.False, "the update was supposed to be a no-op"); + Assert.That(node.HasChanges, Is.True); + }); + } + + private static NoOpUpdateCase EllipseCase() + { + var rect = new Rect(0, 0, 100, 100); + var fill = Brushes.Resource.White; + var node = new EllipseRenderNode(rect, fill, null); + return new NoOpUpdateCase(node, () => node.Update(rect, fill, null)); + } + + private static NoOpUpdateCase RectangleCase() + { + var rect = new Rect(0, 0, 100, 100); + var fill = Brushes.Resource.White; + var node = new RectangleRenderNode(rect, fill, null); + return new NoOpUpdateCase(node, () => node.Update(rect, fill, null)); + } + + private static NoOpUpdateCase GeometryCase() + { + var geometry = new EllipseGeometry(); + geometry.Width.CurrentValue = 100; + geometry.Height.CurrentValue = 100; + var resource = (Geometry.Resource)geometry.ToResource(CompositionContext.Default); + var fill = Brushes.Resource.White; + var node = new GeometryRenderNode(resource, fill, null); + return new NoOpUpdateCase(node, () => node.Update(resource, fill, null)); + } + + private static NoOpUpdateCase TransformCase() + { + Matrix matrix = Matrix.CreateRotation(45); + var node = new TransformRenderNode(matrix, TransformOperator.Prepend); + return new NoOpUpdateCase(node, () => node.Update(matrix, TransformOperator.Prepend)); + } + + private static NoOpUpdateCase CustomTransformCase() + { + var bounds = new MemoryNode(new Rect(0, 0, 100, 100)); + var screenSize = new Size(1920, 1080); + var node = new DrawableGroup.CustomTransformRenderNode( + null, RelativePoint.Center, screenSize, AlignmentX.Center, AlignmentY.Center, bounds); + return new NoOpUpdateCase( + node, + () => node.Update(null, RelativePoint.Center, screenSize, AlignmentX.Center, AlignmentY.Center, bounds)); + } + + private static NoOpUpdateCase ImageSourceCase() + { + var imageSource = new ImageSource(); + imageSource.ReadFrom(TestMediaHelper.CreateTestImageUri(100, 100, Colors.White)); + var resource = (ImageSource.Resource)imageSource.ToResource(CompositionContext.Default); + var fill = Brushes.Resource.White; + var node = new ImageSourceRenderNode(resource, fill, null); + return new NoOpUpdateCase(node, () => node.Update(resource, fill, null)); + } + + private static NoOpUpdateCase VideoSourceCase() + { + var videoSource = new VideoSource(); + videoSource.ReadFrom(new Uri(TestMediaHelper.CreateTestVideoFile(100, 100, new Rational(30), 300))); + var resource = (VideoSource.Resource)videoSource.ToResource(CompositionContext.Default); + var fill = Brushes.Resource.White; + var node = new VideoSourceRenderNode(resource, 0, fill, null); + return new NoOpUpdateCase(node, () => node.Update(resource, 0, fill, null)); + } + + private static NoOpUpdateCase Scene3DCase() + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 32; + var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + var node = new Scene3DRenderNode(resource); + return new NoOpUpdateCase(node, () => node.Update(resource)); + } + + public sealed record NoOpUpdateCase(RenderNode Node, Func NoOpUpdate); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeProcessorExceptionSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeProcessorExceptionSafetyTests.cs deleted file mode 100644 index 1edaa88f0d..0000000000 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeProcessorExceptionSafetyTests.cs +++ /dev/null @@ -1,383 +0,0 @@ -using Beutl.Graphics; -using Beutl.Graphics.Rendering; -using SkiaSharp; - -namespace Beutl.UnitTests.Engine.Graphics.Rendering; - -[TestFixture] -public class RenderNodeProcessorExceptionSafetyTests -{ - // The three rasterize entry points share one disposal contract, so every scenario below runs - // against all of them. Rasterize and RasterizeToRenderTargets dispose per-op through RasterizeAt; - // RasterizeAndConcat renders into a single shared canvas and disposes through its catch sweep. - private static IEnumerable RasterizeMethods() - { - yield return new TestCaseData((Action)(p => p.Rasterize())) - .SetName("{m}(Rasterize)"); - yield return new TestCaseData((Action)(p => p.RasterizeAndConcat())) - .SetName("{m}(RasterizeAndConcat)"); - yield return new TestCaseData((Action)(p => p.RasterizeToRenderTargets())) - .SetName("{m}(RasterizeToRenderTargets)"); - } - - [TestCaseSource(nameof(RasterizeMethods))] - public void DisposesFaultingAndRemainingOperations_WhenRenderThrows(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnRender: true), - CreateOperation("remaining", disposed)); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("fault")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "fault", "remaining" })); - } - - [TestCaseSource(nameof(RasterizeMethods))] - public void DoesNotDoubleDisposeFaultingOperation_WhenDisposeThrows(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnDispose: true), - CreateOperation("remaining", disposed)); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - // A double-dispose would re-run the faulting op's OnDispose (use-after-free for GPU-backed - // ops) and skip the remaining op, so the faulting op must be disposed exactly once. - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("fault")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "fault", "remaining" })); - } - - [TestCaseSource(nameof(RasterizeMethods))] - public void ContinuesCleanupAndPreservesOriginalException_WhenSweepDisposeThrows( - Action rasterize) - { - var disposed = new List(); - using var node = CreateRenderThrowWithThrowingRemainingOps(disposed); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("render-fault")); - Assert.That(disposed, Is.EqualTo(new[] - { - "first", - "render-fault", - "throwing-remaining-1", - "throwing-remaining-2", - "remaining" - })); - } - - // The faulting op throws on both render and dispose: the render throw must propagate while the - // dispose throw is swallowed during cleanup (RasterizeAt's DisposeBestEffort, or the DisposeAll - // sweep in RasterizeAndConcat's catch). - [TestCaseSource(nameof(RasterizeMethods))] - public void PreservesRenderException_WhenFaultingOpAlsoThrowsOnDispose(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("render-fault", disposed, throwOnRender: true, throwOnDispose: true, - disposeFaultMessage: "dispose-fault"), - CreateOperation("remaining", disposed)); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("render-fault")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "render-fault", "remaining" })); - } - - // A throwing RenderTarget.Dispose() during faulting-op cleanup must not mask the render exception. - [TestCaseSource(nameof(RasterizeMethods))] - public void PreservesRenderException_WhenRenderTargetDisposeThrows(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("render-fault", disposed, throwOnRender: true)); - var processor = new FakeRenderNodeProcessor(node, _ => true); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("render-fault")); - Assert.That(processor.CreatedTargets, Has.Count.EqualTo(1)); - Assert.That(processor.CreatedTargets[0].DisposeWasCalled, Is.True); - Assert.That(disposed, Is.EqualTo(new[] { "render-fault" })); - } - - [TestCaseSource(nameof(RasterizeMethods))] - public void DisposesPulledOperations_WhenRenderTargetCreateThrows(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("second", disposed)); - var processor = new FakeRenderNodeProcessor(node, _ => false, throwOnCreate: true); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("rt-create-fault")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "second" })); - } - - [TestCaseSource(nameof(RasterizeMethods))] - public void DisposesPulledOperations_WhenRenderTargetCreateReturnsNull(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("second", disposed)); - var processor = new FakeRenderNodeProcessor(node, _ => false, returnNullOnCreate: true); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("RenderTarget is null")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "second" })); - } - - // When the null-allocation path also hits a throwing op.Dispose(), the "RenderTarget is null" - // failure must still surface rather than the op's dispose throw. - [TestCaseSource(nameof(RasterizeMethods))] - public void PreservesNullAllocationFailure_WhenOpDisposeAlsoThrows(Action rasterize) - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed, throwOnDispose: true), - CreateOperation("second", disposed)); - var processor = new FakeRenderNodeProcessor(node, _ => false, returnNullOnCreate: true); - - var ex = Assert.Throws(() => rasterize(processor)); - - Assert.That(ex!.Message, Is.EqualTo("RenderTarget is null")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "second" })); - } - - [Test] - public void Render_DisposesFaultingAndRemainingOperations_WhenRenderThrows() - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnRender: true), - CreateOperation("remaining", disposed)); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - using var renderTarget = RenderTarget.CreateNull(4, 4); - using var canvas = new ImmediateCanvas(renderTarget); - - var ex = Assert.Throws(() => processor.Render(canvas)); - - Assert.That(ex!.Message, Is.EqualTo("fault")); - // A mid-loop render throw must still dispose the faulting op and every op after it, or those - // ops' GPU handles leak. - Assert.That(disposed, Is.EqualTo(new[] { "first", "fault", "remaining" })); - } - - [Test] - public void Render_DoesNotDoubleDisposeFaultingOperation_WhenDisposeThrows() - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnDispose: true), - CreateOperation("remaining", disposed)); - var processor = new RenderNodeProcessor(node, useRenderCache: false); - - using var renderTarget = RenderTarget.CreateNull(4, 4); - using var canvas = new ImmediateCanvas(renderTarget); - - var ex = Assert.Throws(() => processor.Render(canvas)); - - Assert.That(ex!.Message, Is.EqualTo("fault")); - Assert.That(disposed, Is.EqualTo(new[] { "first", "fault", "remaining" })); - } - - [Test] - public void DisposeAll_DisposesEveryOperation_EvenWhenAnOperationThrowsOnDispose() - { - var disposed = new List(); - RenderNodeOperation[] ops = - [ - CreateOperation("first", disposed), - CreateOperation("throws", disposed, throwOnDispose: true), - CreateOperation("remaining", disposed), - ]; - - Assert.DoesNotThrow(() => RenderNodeOperation.DisposeAll(ops)); - Assert.That(disposed, Is.EqualTo(new[] { "first", "throws", "remaining" })); - } - - // RasterizeToRenderTargets keeps successfully-rendered targets in a list, so a list-resident - // target that throws on Dispose during cleanup must not stop the sweep or mask the render - // exception. Rasterize snapshots and disposes each target immediately and RasterizeAndConcat - // uses a single target, so neither has list-resident targets — this path is theirs alone. - [Test] - public void RasterizeToRenderTargets_ContinuesCleanupAndPreservesException_WhenBuiltRenderTargetDisposeThrows() - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("first", disposed), // renders OK; its RT enters the list - CreateOperation("second", disposed), // also list-resident - CreateOperation("render-fault", disposed, throwOnRender: true)); // faults; its RT disposed in RasterizeAt - // i => i == 0: only the first list-resident RT throws on Dispose, exercising the cleanup sweep. - var processor = new FakeRenderNodeProcessor(node, i => i == 0); - - var ex = Assert.Throws(() => processor.RasterizeToRenderTargets()); - - Assert.That(ex!.Message, Is.EqualTo("render-fault")); - Assert.That(processor.CreatedTargets, Has.Count.EqualTo(3)); - Assert.That(processor.CreatedTargets[0].DisposeWasCalled, Is.True); - Assert.That(processor.CreatedTargets[1].DisposeWasCalled, Is.True); - Assert.That(processor.CreatedTargets[2].DisposeWasCalled, Is.True); - Assert.That(disposed, Is.EqualTo(new[] { "first", "second", "render-fault" })); - } - - // A throwing Dispose() during post-success cleanup must not discard the already-produced bitmap. - [Test] - public void RasterizeAndConcat_ReturnsBitmap_WhenRenderSucceedsButRenderTargetDisposeThrows() - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("ok", disposed)); - var processor = new FakeRenderNodeProcessor(node, _ => true); - - using var result = processor.RasterizeAndConcat(); - - Assert.That(result, Is.Not.Null); - Assert.That(result.Width, Is.EqualTo(4)); - Assert.That(result.Height, Is.EqualTo(4)); - Assert.That(processor.CreatedTargets, Has.Count.EqualTo(1)); - Assert.That(processor.CreatedTargets[0].DisposeWasCalled, Is.True); - Assert.That(disposed, Is.EqualTo(new[] { "ok" })); - } - - // A throwing Dispose() during post-snapshot cleanup must not discard the already-snapshotted bitmaps. - [Test] - public void Rasterize_ReturnsBitmaps_WhenRenderSucceedsButRenderTargetDisposeThrows() - { - var disposed = new List(); - using var node = new StaticRenderNode( - CreateOperation("ok", disposed)); - var processor = new FakeRenderNodeProcessor(node, _ => true); - - var result = processor.Rasterize(); - - Assert.That(result, Has.Count.EqualTo(1)); - using (result[0]) - { - Assert.That(result[0].Width, Is.EqualTo(4)); - Assert.That(result[0].Height, Is.EqualTo(4)); - } - - Assert.That(processor.CreatedTargets, Has.Count.EqualTo(1)); - Assert.That(processor.CreatedTargets[0].DisposeWasCalled, Is.True); - Assert.That(disposed, Is.EqualTo(new[] { "ok" })); - } - - private static StaticRenderNode CreateRenderThrowWithThrowingRemainingOps(ICollection disposed) - { - return new StaticRenderNode( - CreateOperation("first", disposed), - CreateOperation("render-fault", disposed, throwOnRender: true), - CreateOperation("throwing-remaining-1", disposed, throwOnDispose: true), - CreateOperation("throwing-remaining-2", disposed, throwOnDispose: true), - CreateOperation("remaining", disposed)); - } - - private static RenderNodeOperation CreateOperation( - string name, - ICollection disposed, - bool throwOnRender = false, - bool throwOnDispose = false, - string? disposeFaultMessage = null) - { - return RenderNodeOperation.CreateLambda( - new Rect(0, 0, 4, 4), - _ => - { - if (throwOnRender) - { - throw new InvalidOperationException(name); - } - }, - onDispose: () => - { - disposed.Add(name); - if (throwOnDispose) - { - throw new InvalidOperationException(disposeFaultMessage ?? name); - } - }); - } - - private sealed class StaticRenderNode(params RenderNodeOperation[] operations) : RenderNode - { - public override RenderNodeOperation[] Process(RenderNodeContext context) => operations; - } - - // Substitutes RenderTarget allocation so the exception-safety paths can run with a RenderTarget - // whose Dispose() throws, or with a failing/null allocation, none of which a real GPU target offers. - private sealed class FakeRenderNodeProcessor( - RenderNode root, - Func shouldThrowOnDispose, - bool throwOnCreate = false, - bool returnNullOnCreate = false) - : RenderNodeProcessor(root, useRenderCache: false) - { - public List CreatedTargets { get; } = new(); - - protected override RenderTarget? CreateRenderTarget(int width, int height) - { - if (throwOnCreate) - { - throw new InvalidOperationException("rt-create-fault"); - } - - if (returnNullOnCreate) - { - return null; - } - - var target = new FakeRenderTarget(width, height, shouldThrowOnDispose(CreatedTargets.Count)); - CreatedTargets.Add(target); - return target; - } - } - - private sealed class FakeRenderTarget(int width, int height, bool throwOnDispose) - : RenderTarget(CreateReadbackSurface(width, height), width, height) - { - public bool DisposeWasCalled { get; private set; } - - // A CPU raster surface (CreateNull has no backing store and fails ReadPixels) so the - // RasterizeAndConcat success path can read pixels back through Snapshot() without a GPU. - private static SKSurface CreateReadbackSurface(int width, int height) => - SKSurface.Create(new SKImageInfo( - width, height, SKColorType.RgbaF16, SKAlphaType.Premul, SKColorSpace.CreateSrgbLinear())); - - // Throw only on explicit disposal (disposing == true). The finalizer drives - // Dispose(disposing: false), which must stay throw-free so a GC-collected double - // cannot tear down the runtime from the finalizer thread. - protected override void Dispose(bool disposing) - { - bool shouldThrow = disposing && throwOnDispose; - if (disposing) - { - DisposeWasCalled = true; - } - - base.Dispose(disposing); - if (shouldThrow) - { - throw new InvalidOperationException("rt-dispose-fault"); - } - } - } -} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererAllocationFailureTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererAllocationFailureTests.cs new file mode 100644 index 0000000000..bf326581db --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererAllocationFailureTests.cs @@ -0,0 +1,353 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RenderNodeRendererAllocationFailureTests +{ + private static readonly Rect s_domain = new(0, 0, 100, 100); + + [Test] + public void PreviewMaterializationAllocationFailure_DropsContributionAndRecordsDiagnostics() + { + using FilterEffect.Resource resource = CreateStrokeEffectResource(); + using FilterEffectRenderNode node = CreateScene(resource); + var factory = new FailSecondTargetFactory(); + using var renderer = CreateRenderer(node, RenderIntent.Preview, factory); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The preview allocation-drop request produced no bitmap."); + PixelSize expectedDeviceSize = PixelRect.FromRect(s_domain, rasterization.OutputScale).Size; + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(rasterization.Bounds, Is.EqualTo(s_domain)); + Assert.That(rasterization.OutputScale, Is.EqualTo(1)); + Assert.That(new PixelSize(bitmap.Width, bitmap.Height), Is.EqualTo(expectedDeviceSize)); + Assert.That(bitmap.GetPixelSpan().ToArray(), Is.All.Zero, + "a dropped preview contribution must leave the cleared destination transparent"); + Assert.That(factory.FailureConsumed, Is.True); + Assert.That(factory.CreateCalls, Is.EqualTo(2)); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(102, 102))); + }); + } + + [Test] + public void DeliveryMaterializationAllocationFailure_Throws() + { + using FilterEffect.Resource resource = CreateStrokeEffectResource(); + using FilterEffectRenderNode node = CreateScene(resource); + var factory = new FailSecondTargetFactory(); + using var renderer = CreateRenderer(node, RenderIntent.Delivery, factory); + + InvalidOperationException? exception = Assert.Throws(() => + { + using RenderNodeRasterization unexpected = renderer.Rasterize(); + }); + + Assert.Multiple(() => + { + Assert.That( + exception!.Message, + Is.EqualTo("The render-target factory could not allocate 102x102 pixels.")); + Assert.That(factory.FailureConsumed, Is.True); + Assert.That(factory.CreateCalls, Is.EqualTo(2)); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(102, 102))); + }); + } + + [Test] + public void PreviewGeometryCropAllocationFailure_DropsContributionAndRecordsDiagnostics() + { + using FilterEffect.Resource resource = new ShrinkingGeometryEffect() + .ToResource(CompositionContext.Default); + using FilterEffectRenderNode node = CreateScene(resource); + var factory = new FailSpecificSizeTargetFactory(new PixelSize(98, 98)); + using var renderer = CreateRenderer(node, RenderIntent.Preview, factory); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The preview Geometry crop allocation-drop request produced no bitmap."); + + Assert.Multiple(() => + { + Assert.That(bitmap.GetPixelSpan().ToArray(), Is.All.Zero, + "a dropped Geometry crop must leave the cleared destination transparent"); + Assert.That(factory.FailureConsumed, Is.True); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(98, 98))); + }); + } + + [Test] + public void DeliveryGeometryCropAllocationFailure_Throws() + { + using FilterEffect.Resource resource = new ShrinkingGeometryEffect() + .ToResource(CompositionContext.Default); + using FilterEffectRenderNode node = CreateScene(resource); + var factory = new FailSpecificSizeTargetFactory(new PixelSize(98, 98)); + using var renderer = CreateRenderer(node, RenderIntent.Delivery, factory); + + InvalidOperationException? exception = Assert.Throws(() => + { + using RenderNodeRasterization unexpected = renderer.Rasterize(); + }); + + Assert.Multiple(() => + { + Assert.That( + exception!.Message, + Is.EqualTo("The render-target factory could not allocate 98x98 pixels.")); + Assert.That(factory.FailureConsumed, Is.True); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(98, 98))); + }); + } + + [Test] + public void PreviewExpandedTargetAllocationFailureLeavesBorrowedDestinationUnmodified() + { + using var node = new ExpandedTargetReadNode(); + var factory = new AlwaysFailTargetFactory(); + using var renderer = CreateRenderer( + node, + RenderIntent.Preview, + factory, + requestedRegion: new Rect(25, 25, 50, 50)); + using RenderTarget target = CpuTargetFactory.CreateTarget(new PixelSize(100, 100)); + using var canvas = new ImmediateCanvas(target, logicalSize: s_domain.Size); + canvas.Clear(Colors.OrangeRed); + using Bitmap before = target.Snapshot(); + + Assert.That(() => renderer.Render(canvas), Throws.Nothing); + using Bitmap after = target.Snapshot(); + + Assert.Multiple(() => + { + Assert.That(after.GetPixelSpan().ToArray(), Is.EqualTo(before.GetPixelSpan().ToArray())); + Assert.That(node.CallbackCount, Is.Zero); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(100, 100))); + }); + } + + [Test] + public void DeliveryExpandedTargetAllocationFailureThrowsWithoutExecuting() + { + using var node = new ExpandedTargetReadNode(); + var factory = new AlwaysFailTargetFactory(); + using var renderer = CreateRenderer( + node, + RenderIntent.Delivery, + factory, + requestedRegion: new Rect(25, 25, 50, 50)); + using RenderTarget target = CpuTargetFactory.CreateTarget(new PixelSize(100, 100)); + using var canvas = new ImmediateCanvas(target, logicalSize: s_domain.Size, intent: RenderIntent.Delivery); + canvas.Clear(Colors.OrangeRed); + + InvalidOperationException? exception = Assert.Throws( + () => renderer.Render(canvas)); + + Assert.Multiple(() => + { + Assert.That(exception!.Message, Is.EqualTo("The render-target factory could not allocate 100x100 pixels.")); + Assert.That(node.CallbackCount, Is.Zero); + Assert.That(factory.FailedDeviceSize, Is.EqualTo(new PixelSize(100, 100))); + }); + } + + private static FilterEffect.Resource CreateStrokeEffectResource() + { + var pen = new Pen + { + Thickness = { CurrentValue = 9 }, + Brush = { CurrentValue = Brushes.OrangeRed }, + }; + var effect = new StrokeEffect + { + Pen = { CurrentValue = pen }, + }; + return effect.ToResource(CompositionContext.Default); + } + + [SuppressResourceClassGeneration] + private sealed partial class ShrinkingGeometryEffect : FilterEffect + { + private static readonly GeometryDescription s_geometry = GeometryDescription.Create( + state: true, + static (session, _) => + { + session.Canvas.Use(session.Input.Draw); + session.SetOutputBounds(session.OutputBounds.Inflate(new Thickness(-1))); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput); + + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => context.Geometry(s_geometry); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } + } + + private static FilterEffectRenderNode CreateScene(FilterEffect.Resource resource) + { + var node = new FilterEffectRenderNode(resource); + node.AddChild(new EllipseRenderNode(s_domain, Brushes.Resource.White, null)); + return node; + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + RenderIntent intent, + IRenderTargetFactory factory, + Rect? requestedRegion = null) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + Intent = intent, + TargetDomain = s_domain, + RequestedRegion = requestedRegion, + OutputScale = 1, + MaxWorkingScale = intent == RenderIntent.Delivery + ? float.PositiveInfinity + : 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = factory, + }); + + private sealed class FailSecondTargetFactory : CpuTargetFactory + { + public bool FailureConsumed { get; private set; } + + public PixelSize? FailedDeviceSize { get; private set; } + + public override RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + int index = CreateCalls++; + if (index == 1) + { + FailureConsumed = true; + FailedDeviceSize = deviceSize; + return null; + } + + return CreateTarget(deviceSize); + } + } + + private sealed class FailSpecificSizeTargetFactory(PixelSize failureSize) : CpuTargetFactory + { + public bool FailureConsumed { get; private set; } + + public PixelSize? FailedDeviceSize { get; private set; } + + public override RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + CreateCalls++; + if (!FailureConsumed && deviceSize == failureSize) + { + FailureConsumed = true; + FailedDeviceSize = deviceSize; + return null; + } + + return CreateTarget(deviceSize); + } + } + + private sealed class AlwaysFailTargetFactory : IRenderTargetFactory + { + public PixelSize? FailedDeviceSize { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + FailedDeviceSize = allocation.DeviceSize; + return null; + } + } + + private sealed class ExpandedTargetReadNode : RenderNode + { + public int CallbackCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.RequiredRegion); + output.Canvas.Use(static canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(s_domain), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale))); + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => + { + CallbackCount++; + session.UseSnapshot(static _ => { }); + }, + TargetRegion.Region(s_domain), + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + } + } + + private class CpuTargetFactory : IRenderTargetFactory + { + public int CreateCalls { get; protected set; } + + public virtual RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + CreateCalls++; + return CreateTarget(deviceSize); + } + + internal static RenderTarget CreateTarget(PixelSize deviceSize) + { + SKSurface surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create the CPU allocation-failure test surface."); + return new CpuRenderTarget(surface, deviceSize); + } + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererDeviceBoundsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererDeviceBoundsTests.cs new file mode 100644 index 0000000000..933d14dabc --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererDeviceBoundsTests.cs @@ -0,0 +1,150 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RenderNodeRendererDeviceBoundsTests +{ + [Test] + public void Rasterize_FractionalOriginReportsTheBoundsTheBitmapActuallyOccupies() + { + var bounds = new Rect(10.25f, 20.25f, 3.5f, 2.5f); + const float outputScale = 2; + using RenderNode root = ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds); + var factory = new CpuTargetFactory(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = outputScale, + MaxWorkingScale = 2, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The fractional-origin fixture must produce a bitmap."); + PixelRect reportedDeviceBounds = PixelRect.FromRect(rasterization.Bounds, outputScale); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds.X * outputScale, Is.EqualTo((float)reportedDeviceBounds.X), + "The reported origin must land on a device pixel."); + Assert.That(rasterization.Bounds.Y * outputScale, Is.EqualTo((float)reportedDeviceBounds.Y), + "The reported origin must land on a device pixel."); + Assert.That(rasterization.Bounds.Width * outputScale, Is.EqualTo((float)bitmap.Width), + "The reported width must be the width of the returned pixels."); + Assert.That(rasterization.Bounds.Height * outputScale, Is.EqualTo((float)bitmap.Height), + "The reported height must be the height of the returned pixels."); + Assert.That(rasterization.Bounds.Contains(bounds), Is.True, + "The reported bounds must cover the selected logical output."); + }); + } + + [Test] + public void Rasterize_SelectionWhoseScaledEdgesCollapseStillProducesItsDevicePixel() + { + // 2^24 is the float magnitude at which adding 0.5 no longer changes the value, so the selection's + // right edge is indistinguishable from its left edge. + const float origin = 16777216f; + var bounds = new Rect(origin, 0, 0.5f, 4); + Assert.That(bounds.Right, Is.EqualTo(bounds.X), "the fixture requires a float-collapsed right edge"); + + using RenderNode root = ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds); + var factory = new CpuTargetFactory(); + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = factory, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("A selection with positive area must produce a bitmap."); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(bitmap.Width, Is.EqualTo(1)); + Assert.That(bitmap.Height, Is.EqualTo(4)); + Assert.That(rasterization.Bounds, Is.EqualTo(new Rect(origin, 0, 1, 4))); + }); + } + + [Test] + public void RenderInDeviceSpace_ResolvesFullTargetAgainstPhysicalViewport() + { + var deviceSize = new PixelSize(384, 216); + var logicalSize = new Size(192, 108); + Rect? observedDomain = null; + using var root = new DomainProbeNode(context => observedDomain = context.TargetDomain); + using var target = new DeviceBoundsRenderTarget(deviceSize); + using var canvas = new ImmediateCanvas(target, density: 2, logicalSize: logicalSize); + using var renderer = new RenderNodeRenderer(root); + + using (canvas.PushDeviceSpace()) + renderer.Render(canvas); + + Assert.That(observedDomain, Is.EqualTo(new Rect(0, 0, 384, 216))); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + SKSurface surface = SKSurface.Create(new SKImageInfo( + deviceSize.Width, + deviceSize.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException( + "Could not create the CPU device-bounds test surface."); + return new CpuRenderTarget(surface, deviceSize); + } + + private sealed class CpuRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height); + } + + private sealed class DeviceBoundsRenderTarget(PixelSize size) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + size.Width, + size.Height); + + private sealed class DomainProbeNode(Action observe) : RenderNode + { + public override void Process(RenderNodeContext context) + { + observe(context); + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + static _ => { }, + TargetRegion.Full, + Rect.Empty, + RenderHitTestContract.None))); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererExceptionSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererExceptionSafetyTests.cs new file mode 100644 index 0000000000..885ae9d344 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererExceptionSafetyTests.cs @@ -0,0 +1,589 @@ +using System.Runtime.ExceptionServices; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.UnitTests.Engine.Graphics.Rendering.Failure; + +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public class RenderNodeRendererExceptionSafetyTests +{ + public enum EntryPoint + { + Rasterize, + Render, + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void DischargesFaultingAndUnexecutedResources_WhenExecutionThrows(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("fault", ThrowOnExecute: true), + new RecordedOperationSpec("remaining")); + using var renderer = CreateRenderer(node); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That(ex!.Message, Is.EqualTo("fault")); + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "fault", "first" })); + } + + [Test] + public void CleanupOnlyFailure_DischargesEveryResourceExactlyOnceAndSurfacesFailure() + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("fault", ThrowOnDispose: true), + new RecordedOperationSpec("remaining")); + using var target = CreateCpuTarget(4, 4); + using var canvas = new ImmediateCanvas(target); + + var ex = Assert.Throws(() => ExecuteRequestAndSurfaceOwnerFailure(node, canvas)); + + Assert.That(ex!.InnerExceptions.Single().Message, Is.EqualTo("fault")); + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "fault", "first" })); + } + + [Test] + public void ExpandedTargetCleanup_PreservesPrimaryAndAttemptsEveryResource() + { + var primaryFailure = new InvalidOperationException("expanded-primary"); + var firstFailure = new InvalidOperationException("expanded-first-cleanup"); + var secondFailure = new InvalidOperationException("expanded-second-cleanup"); + var thirdFailure = new InvalidOperationException("expanded-third-cleanup"); + var first = new FailureTestDisposable(firstFailure); + var second = new FailureTestDisposable(secondFailure); + var third = new FailureTestDisposable(thirdFailure); + using var owner = new RenderRequestOwner(); + ExceptionDispatchInfo? primary = ExceptionDispatchInfo.Capture(primaryFailure); + + RenderNodeRenderer.DisposeExecutionResourcesAndCapture( + owner, + ref primary, + first, + second, + third); + + Assert.Multiple(() => + { + Assert.That(primary?.SourceException, Is.SameAs(primaryFailure)); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(primaryFailure)); + Assert.That(owner.CleanupFailures, Is.EqualTo(new[] { firstFailure, secondFailure, thirdFailure })); + Assert.That(first.DisposeCalls, Is.EqualTo(1)); + Assert.That(second.DisposeCalls, Is.EqualTo(1)); + Assert.That(third.DisposeCalls, Is.EqualTo(1)); + }); + } + + [Test] + public void Measure_CleanupOnlyFailure_DischargesEveryResourceExactlyOnceAndSurfacesFailure() + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first", TrackMetadataDischarge: true), + new RecordedOperationSpec("fault", ThrowOnDispose: true, TrackMetadataDischarge: true), + new RecordedOperationSpec("remaining", TrackMetadataDischarge: true)); + using var renderer = CreateRenderer(node); + + var ex = Assert.Throws(() => renderer.Measure()); + + Assert.That(ex!.Flatten().InnerExceptions.Single().Message, Is.EqualTo("fault")); + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "fault", "first" })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void PooledTargetDisposeFailure_SurfacesAtRendererDisposalAfterSuccessfulRequest(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("second")); + int throwingTarget = entryPoint == EntryPoint.Rasterize ? 1 : 0; + var factory = new TrackingTargetFactory(index => index == throwingTarget); + var renderer = CreateRenderer(node, factory); + + Assert.DoesNotThrow(() => Execute(entryPoint, renderer)); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).False); + var ex = Assert.Throws(renderer.Dispose); + + Assert.That(ex!.Message, Is.EqualTo("rt-dispose-fault")); + Assert.That(factory.CreatedTargets, Has.Count.EqualTo(entryPoint == EntryPoint.Rasterize ? 2 : 1)); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).True); + Assert.That(discharged, Is.EqualTo(new[] { "second", "first" })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void ContinuesCleanupAndPreservesOriginalException_WhenRemainingResourcesThrow(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("render-fault", ThrowOnExecute: true), + new RecordedOperationSpec("throwing-remaining-1", ThrowOnDispose: true), + new RecordedOperationSpec("throwing-remaining-2", ThrowOnDispose: true), + new RecordedOperationSpec("remaining")); + using var renderer = CreateRenderer(node); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That(ex!.Message, Is.EqualTo("render-fault")); + Assert.That(discharged, Is.EqualTo(new[] + { + "remaining", + "throwing-remaining-2", + "throwing-remaining-1", + "render-fault", + "first", + })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void PreservesExecutionException_WhenFaultingResourceAlsoThrowsOnDispose(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec( + "render-fault", + ThrowOnExecute: true, + ThrowOnDispose: true, + DisposeFaultMessage: "dispose-fault"), + new RecordedOperationSpec("remaining")); + using var renderer = CreateRenderer(node); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That(ex!.Message, Is.EqualTo("render-fault")); + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "render-fault", "first" })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void PreservesExecutionException_WhilePooledTargetFailureWaitsForRendererDisposal(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("render-fault", ThrowOnExecute: true, AllocateBeforeThrow: true)); + int throwingTarget = entryPoint == EntryPoint.Rasterize ? 1 : 0; + var factory = new TrackingTargetFactory(index => index == throwingTarget); + var renderer = CreateRenderer(node, factory); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That(ex!.Message, Is.EqualTo("render-fault")); + Assert.That(factory.CreatedTargets, Has.Count.EqualTo(entryPoint == EntryPoint.Rasterize ? 2 : 1)); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).False); + Assert.That(discharged, Is.EqualTo(new[] { "render-fault" })); + var cleanup = Assert.Throws(renderer.Dispose); + Assert.That(cleanup!.Message, Is.EqualTo("rt-dispose-fault")); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).True); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void DischargesRecordedResources_WhenTargetFactoryThrows(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("second")); + var factory = new TrackingTargetFactory(_ => false, throwOnCreate: true); + using var renderer = CreateRenderer(node, factory); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That(ex!.Message, Is.EqualTo("rt-create-fault")); + Assert.That(discharged, Is.EqualTo(new[] { "second", "first" })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void DischargesRecordedResources_WhenTargetFactoryReturnsNull(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("second")); + var factory = new TrackingTargetFactory(_ => false, returnNullOnCreate: true); + using var renderer = CreateRenderer(node, factory); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That( + ex!.Message, + Does.StartWith("The render-target factory could not allocate 4x4 pixels")); + Assert.That(discharged, Is.EqualTo(new[] { "second", "first" })); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void PreExecutionAllocationFailureRecordsTheFamilyOwnerWithoutDiagnostics( + EntryPoint entryPoint) + { + using var node = new ExpandedCaptureNode( + publishRasterOutput: entryPoint == EntryPoint.Rasterize); + var factory = new TrackingTargetFactory(_ => false, throwOnCreate: true); + using var renderer = CreateRenderer(node, factory); + + InvalidOperationException? failure = Assert.Throws( + () => Execute(entryPoint, renderer)); + RenderRequestOwner owner = node.NestedRequest!.Request.Options.Owner; + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("rt-create-fault")); + Assert.That(owner.PrimaryFailure?.SourceException, Is.SameAs(failure)); + Assert.That(owner.SecondaryFailures, Is.Empty); + Assert.That(node.NestedRequest.Request.State, Is.EqualTo(RenderRequestState.Disposed)); + }); + } + + [TestCase(EntryPoint.Rasterize)] + [TestCase(EntryPoint.Render)] + public void PreservesAllocationFailure_WhenResourceCleanupAlsoThrows(EntryPoint entryPoint) + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first", ThrowOnDispose: true), + new RecordedOperationSpec("second")); + var factory = new TrackingTargetFactory(_ => false, returnNullOnCreate: true); + using var renderer = CreateRenderer(node, factory); + + var ex = Assert.Throws(() => Execute(entryPoint, renderer)); + + Assert.That( + ex!.Message, + Does.StartWith("The render-target factory could not allocate 4x4 pixels")); + Assert.That(discharged, Is.EqualTo(new[] { "second", "first" })); + } + + [Test] + public void RequestOwner_CleanupContinuesAfterFaultAndPreservesStrictLifo() + { + var discharged = new List(); + using var owner = new RenderRequestOwner(); + Register(owner, new RecordedOperation(new RecordedOperationSpec("first"), discharged, true)); + Register(owner, new RecordedOperation( + new RecordedOperationSpec("throws", ThrowOnDispose: true), + discharged, + true)); + Register(owner, new RecordedOperation(new RecordedOperationSpec("remaining"), discharged, true)); + + owner.Cleanup(); + + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "throws", "first" })); + Assert.That(owner.CleanupFailures.Length, Is.EqualTo(1)); + Assert.That(owner.PrimaryFailure!.SourceException, Is.TypeOf()); + } + + [Test] + public void Diagnostics_ClassifyResourceDisposeFaultAsCleanup() + { + var discharged = new List(); + using var owner = new RenderRequestOwner(); + Register(owner, new RecordedOperation(new RecordedOperationSpec("remaining"), discharged, true)); + Register(owner, new RecordedOperation( + new RecordedOperationSpec("fault", ThrowOnDispose: true), + discharged, + true)); + + owner.Cleanup(); + + Assert.Multiple(() => + { + Assert.That(owner.CleanupFailures.Length, Is.EqualTo(1), + "A resource that throws while being released is a cleanup fault, not an execution failure."); + Assert.That(owner.SecondaryFailures, Is.Empty, + "With no earlier outcome to preserve, the cleanup fault becomes the primary rather than a secondary."); + Assert.That(owner.PrimaryFailure!.SourceException, Is.TypeOf()); + Assert.That( + ((AggregateException)owner.PrimaryFailure.SourceException).InnerExceptions + .Select(static inner => inner.Message), + Is.EqualTo(new[] { "fault" })); + Assert.That(discharged, Is.EqualTo(new[] { "fault", "remaining" })); + }); + } + + [Test] + public void Rasterize_ReturnsBuiltTargetsToPoolAndPreservesExecutionFailure() + { + var discharged = new List(); + using var node = CreateNode( + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("second"), + new RecordedOperationSpec("render-fault", ThrowOnExecute: true, AllocateBeforeThrow: true)); + var factory = new TrackingTargetFactory(index => index == 1); + var renderer = CreateRenderer(node, factory); + + var ex = Assert.Throws(() => renderer.Rasterize()); + + Assert.That(ex!.Message, Is.EqualTo("render-fault")); + Assert.That(factory.CreatedTargets, Has.Count.EqualTo(2)); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).False); + Assert.That(discharged, Is.EqualTo(new[] { "render-fault", "second", "first" })); + var cleanup = Assert.Throws(renderer.Dispose); + Assert.That(cleanup!.Message, Is.EqualTo("rt-dispose-fault")); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).True); + } + + [Test] + public void Rasterize_SurfacesPooledRootTargetDisposeFailureAtRendererDisposal() + { + var discharged = new List(); + using var node = CreateNode(discharged, new RecordedOperationSpec("ok")); + var factory = new TrackingTargetFactory(index => index == 0); + var renderer = CreateRenderer(node, factory); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Assert.That(factory.CreatedTargets, Has.All.Property(nameof(FakeRenderTarget.DisposeWasCalled)).False); + var ex = Assert.Throws(renderer.Dispose); + + Assert.That(ex!.Message, Is.EqualTo("rt-dispose-fault")); + Assert.That(factory.CreatedTargets, Has.Count.EqualTo(2)); + Assert.That(factory.CreatedTargets[0].DisposeWasCalled, Is.True); + Assert.That(factory.CreatedTargets[1].DisposeWasCalled, Is.True); + Assert.That(discharged, Is.EqualTo(new[] { "ok" })); + } + + [Test] + public void Diagnostics_ReconcileCleanupFaultAndIntermediateDischarge() + { + var discharged = new List(); + using var owner = new RenderRequestOwner(); + RenderResource kept = Register( + owner, + new RecordedOperation(new RecordedOperationSpec("kept"), discharged, true)); + RenderResource faulting = Register( + owner, + new RecordedOperation(new RecordedOperationSpec("fault", ThrowOnDispose: true), discharged, true)); + + owner.Cleanup(); + owner.Cleanup(); + + Assert.Multiple(() => + { + Assert.That(discharged, Is.EqualTo(new[] { "fault", "kept" }), + "A fault must not strand the intermediates registered before it."); + Assert.That( + faulting.RegistrationState, + Is.EqualTo(RenderResourceRegistrationState.Released), + "An intermediate that threw on release is still released; retrying it would double-dispose."); + Assert.That(kept.RegistrationState, Is.EqualTo(RenderResourceRegistrationState.Released)); + Assert.That(owner.CleanupFailures.Length, Is.EqualTo(1)); + Assert.That(discharged, Has.Count.EqualTo(2), "A second cleanup pass must discharge nothing again."); + }); + } + + [Test] + public void Diagnostics_AttributeRequestLevelOutputFailureWithoutReplacingExecutedOutcome() + { + using var owner = new RenderRequestOwner(); + var executed = new InvalidOperationException("execution-outcome"); + var requestLevel = new InvalidOperationException("request-level-output"); + + owner.RecordPrimaryFailure(executed); + owner.RecordPrimaryFailure(executed); + owner.RecordPrimaryFailure(requestLevel); + + Assert.Multiple(() => + { + Assert.That(owner.PrimaryFailure!.SourceException, Is.SameAs(executed), + "The executed outcome is what the caller sees; a later request-level failure must not replace it."); + Assert.That(owner.SecondaryFailures, Is.EqualTo(new[] { requestLevel }), + "Re-observing the same exception across a request boundary is not an independent failure."); + Assert.That(owner.CleanupFailures, Is.Empty, + "A failure attributed at the request level is not a cleanup fault."); + }); + } + + private static FixedOpsNode CreateNode( + ICollection discharged, + params RecordedOperationSpec[] operations) + => new(operations, discharged); + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + IRenderTargetFactory? targetFactory = null) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = float.PositiveInfinity, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = targetFactory, + }); + + private static void Execute(EntryPoint entryPoint, RenderNodeRenderer renderer) + { + if (entryPoint == EntryPoint.Rasterize) + { + using RenderNodeRasterization rasterization = renderer.Rasterize(); + return; + } + + using RenderTarget target = CreateCpuTarget(4, 4); + using var canvas = new ImmediateCanvas(target); + renderer.Render(canvas); + } + + private static void ExecuteRequestAndSurfaceOwnerFailure(RenderNode node, ImmediateCanvas destination) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: destination.Density, + maxWorkingScale: destination.MaxWorkingScale, + cachePolicy: Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled)); + RenderRequestOwner owner = request.Options.Owner; + CompiledRenderRequest? compiled = null; + Exception? executionFailure = null; + using var targetRegistry = new RenderTargetLeaseRegistry(factory: null); + using RenderTargetLeaseSession targets = targetRegistry.BeginSession( + RenderIntent.Preview, + destination._renderTarget); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + compiled = new RenderRequestCompiler().Compile(request, graph); + new RenderRequestExecutor(targets).Execute(compiled, destination); + } + catch (Exception ex) + { + executionFailure = ex; + } + finally + { + if (compiled is not null) + compiled.Dispose(); + else + request.Dispose(); + targets.Dispose(); + } + + owner.ThrowIfFailed(); + targets.ThrowIfCleanupFailed(); + if (executionFailure is not null) + throw executionFailure; + } + + private static RenderResource Register( + RenderRequestOwner owner, + RecordedOperation operation) + { + RenderResource resource = owner.ResourceRegistry.RegisterOwned(operation); + owner.ResourceRegistry.Commit(resource); + return resource; + } + + private static RenderTarget CreateCpuTarget(int width, int height) + => new FakeRenderTarget(width, height, throwOnDispose: false); + + private sealed class TrackingTargetFactory( + Func shouldThrowOnDispose, + bool throwOnCreate = false, + bool returnNullOnCreate = false) : IRenderTargetFactory + { + public List CreatedTargets { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + if (throwOnCreate) + throw new InvalidOperationException("rt-create-fault"); + if (returnNullOnCreate) + return null; + + var target = new FakeRenderTarget( + deviceSize.Width, + deviceSize.Height, + shouldThrowOnDispose(CreatedTargets.Count)); + CreatedTargets.Add(target); + return target; + } + } + + private sealed class ExpandedCaptureNode(bool publishRasterOutput = false) : RenderNode + { + private static readonly Rect s_domain = new(0, 0, 4, 4); + private readonly EmptyNode _nested = new(); + + public RecordedNestedRenderTarget? NestedRequest { get; private set; } + + public override void Process(RenderNodeContext context) + { + NestedRequest = context.RecordNestedTarget(_nested, s_domain); + context.Publish(context.TargetCapture(TargetCaptureDescription.Create( + TargetRegion.Region(s_domain), + s_domain, + RenderHitTestContract.None, + TargetCaptureScaleContract.MaterializeAtWorkingScale))); + if (publishRasterOutput) + { + context.Publish(context.OpaqueSource( + FailureTestSupport.SourceDescription())); + } + } + + protected override void OnDispose(bool disposing) + { + if (disposing) + _nested.Dispose(); + } + } + + private sealed class EmptyNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + } + } + + private sealed class FakeRenderTarget(int width, int height, bool throwOnDispose) + : RenderTarget(CreateReadbackSurface(width, height), width, height) + { + public bool DisposeWasCalled { get; private set; } + + private static SKSurface CreateReadbackSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())); + + protected override void Dispose(bool disposing) + { + bool shouldThrow = disposing && throwOnDispose; + if (disposing) + DisposeWasCalled = true; + + base.Dispose(disposing); + if (shouldThrow) + throw new InvalidOperationException("rt-dispose-fault"); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererSnapshotFastPathTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererSnapshotFastPathTests.cs new file mode 100644 index 0000000000..d8e5427099 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderNodeRendererSnapshotFastPathTests.cs @@ -0,0 +1,308 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RenderNodeRendererSnapshotFastPathTests +{ + [Test] + public void TakeRasterizationBitmap_FullExtentTransfersOriginalSnapshot() + { + Bitmap complete = CreateTokenBitmap(4, 2); + + Bitmap selected = RenderNodeRenderer.TakeRasterizationBitmap( + complete, + new PixelRect(0, 0, 4, 2)); + + Assert.Multiple(() => + { + Assert.That(selected, Is.SameAs(complete)); + Assert.That(complete.IsDisposed, Is.False); + AssertToken(selected, 3, 1, 7); + }); + + selected.Dispose(); + selected.Dispose(); + Assert.That(complete.IsDisposed, Is.True); + } + + [Test] + public void TakeRasterizationBitmap_PartialExtentCopiesPixelsAndDisposesOriginal() + { + Bitmap complete = CreateTokenBitmap(4, 3); + + using Bitmap selected = RenderNodeRenderer.TakeRasterizationBitmap( + complete, + new PixelRect(1, 1, 2, 2)); + + Assert.Multiple(() => + { + Assert.That(selected, Is.Not.SameAs(complete)); + Assert.That(complete.IsDisposed, Is.True); + Assert.That(selected.Width, Is.EqualTo(2)); + Assert.That(selected.Height, Is.EqualTo(2)); + AssertToken(selected, 0, 0, 5); + AssertToken(selected, 1, 0, 6); + AssertToken(selected, 0, 1, 9); + AssertToken(selected, 1, 1, 10); + }); + } + + [Test] + public void TakeRasterizationBitmap_CropFailureDisposesOriginal() + { + Bitmap complete = CreateTokenBitmap(4, 2); + + Assert.Throws(() => + RenderNodeRenderer.TakeRasterizationBitmap( + complete, + new PixelRect(3, 0, 2, 2))); + + Assert.That(complete.IsDisposed, Is.True); + } + + [Test] + public void Rasterize_FullOutputPreservesPixelsAndOwnsBitmapIndependently() + { + var bounds = new Rect(0, 0, 4, 2); + using var source = new CpuRenderTarget(4, 2); + DrawColumnPattern(source); + using var node = new MaterializedSourceNode(source, bounds, requireFullReadback: false); + var factory = new TrackingTargetFactory(); + var renderer = CreateRenderer(node, bounds, requestedRegion: null, factory); + RenderNodeRasterization? rasterization = null; + + try + { + rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The full-output fixture must produce a bitmap."); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(bounds)); + Assert.That(bitmap.Width, Is.EqualTo(4)); + Assert.That(bitmap.Height, Is.EqualTo(2)); + AssertDominant(bitmap, 0, 0, red: true, green: false, blue: false); + AssertDominant(bitmap, 3, 1, red: true, green: true, blue: true); + }); + + renderer.Dispose(); + + Assert.Multiple(() => + { + Assert.That(bitmap.IsDisposed, Is.False); + AssertDominant(bitmap, 2, 0, red: false, green: false, blue: true); + Assert.That(factory.Targets, Is.Not.Empty); + Assert.That(factory.Targets, Has.All.Matches( + target => target.IsDisposed && target.DisposeCalls == 1)); + Assert.That(source.IsDisposed, Is.False); + }); + + rasterization.Dispose(); + rasterization.Dispose(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsDisposed, Is.True); + Assert.That(bitmap.IsDisposed, Is.True); + Assert.Throws(() => _ = rasterization.Bitmap); + }); + } + finally + { + rasterization?.Dispose(); + renderer.Dispose(); + } + } + + [Test] + public void Rasterize_PartialOutputCropsExpandedSnapshot() + { + var bounds = new Rect(0, 0, 4, 2); + var requestedRegion = new Rect(1, 0, 2, 2); + using var source = new CpuRenderTarget(4, 2); + DrawColumnPattern(source); + using var node = new MaterializedSourceNode(source, bounds, requireFullReadback: true); + var factory = new TrackingTargetFactory(); + using var renderer = CreateRenderer(node, bounds, requestedRegion, factory); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The partial-output fixture must produce a bitmap."); + + Assert.Multiple(() => + { + Assert.That(node.ReadbackSize, Is.EqualTo(new PixelSize(4, 2)), + "The target readback must keep the execution snapshot larger than the selected subset."); + Assert.That(rasterization.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(bitmap.Width, Is.EqualTo(2)); + Assert.That(bitmap.Height, Is.EqualTo(2)); + AssertDominant(bitmap, 0, 0, red: false, green: true, blue: false); + AssertDominant(bitmap, 1, 1, red: false, green: false, blue: true); + }); + } + + private static RenderNodeRenderer CreateRenderer( + RenderNode node, + Rect targetDomain, + Rect? requestedRegion, + IRenderTargetFactory targetFactory) + => new(node, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = targetDomain, + RequestedRegion = requestedRegion, + OutputScale = 1, + MaxWorkingScale = 1, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = targetFactory, + }); + + private static Bitmap CreateTokenBitmap(int width, int height) + { + var bitmap = new Bitmap( + width, + height, + BitmapColorType.Rgba8888, + BitmapAlphaType.Unpremul); + for (int y = 0; y < height; y++) + { + Span row = bitmap.GetRow(y); + for (int x = 0; x < width; x++) + { + byte token = checked((byte)((y * width) + x)); + int offset = x * bitmap.BytesPerPixel; + row[offset] = token; + row[offset + 1] = (byte)(token + 1); + row[offset + 2] = (byte)(token + 2); + row[offset + 3] = byte.MaxValue; + } + } + + return bitmap; + } + + private static void AssertToken(Bitmap bitmap, int x, int y, byte expected) + { + Span row = bitmap.GetRow(y); + int offset = x * bitmap.BytesPerPixel; + Assert.That(row[offset], Is.EqualTo(expected)); + } + + private static void DrawColumnPattern(RenderTarget target) + { + SKCanvas canvas = target.Value.Canvas; + canvas.Clear(SKColors.Transparent); + SKColor[] colors = [SKColors.Red, SKColors.Lime, SKColors.Blue, SKColors.White]; + using var paint = new SKPaint(); + for (int x = 0; x < colors.Length; x++) + { + paint.Color = colors[x]; + canvas.DrawRect(x, 0, 1, target.Height, paint); + } + + canvas.Flush(); + } + + private static void AssertDominant( + Bitmap bitmap, + int x, + int y, + bool red, + bool green, + bool blue) + { + Span row = bitmap.GetRow(y); + int offset = x * 4; + float actualRed = (float)BitConverter.UInt16BitsToHalf(row[offset]); + float actualGreen = (float)BitConverter.UInt16BitsToHalf(row[offset + 1]); + float actualBlue = (float)BitConverter.UInt16BitsToHalf(row[offset + 2]); + const float threshold = 0.75f; + + Assert.Multiple(() => + { + Assert.That(actualRed, red ? Is.GreaterThan(threshold) : Is.LessThan(0.25f)); + Assert.That(actualGreen, green ? Is.GreaterThan(threshold) : Is.LessThan(0.25f)); + Assert.That(actualBlue, blue ? Is.GreaterThan(threshold) : Is.LessThan(0.25f)); + }); + } + + private sealed class MaterializedSourceNode( + RenderTarget source, + Rect bounds, + bool requireFullReadback) : RenderNode + { + public PixelSize? ReadbackSize { get; private set; } + + public override void Process(RenderNodeContext context) + { + RenderResource target = context.Borrow(source); + context.Publish(context.MaterializedInput( + MaterializedInputDescription.FromRenderTarget( + target, + bounds, + EffectiveScale.At(1), + PixelRect.FromRect(bounds, 1), + default, + RenderHitTestContract.OutputBounds))); + + if (!requireFullReadback) + return; + + context.Publish(context.TargetCommand( + [], + TargetCommandDescription.CreateRequestLocal( + session => session.UseSnapshot( + bitmap => ReadbackSize = new PixelSize(bitmap.Width, bitmap.Height)), + TargetRegion.Region(bounds), + Rect.Empty, + RenderHitTestContract.None, + TargetAccess.Readback))); + } + } + + private sealed class TrackingTargetFactory : IRenderTargetFactory + { + public List Targets { get; } = []; + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + PixelSize deviceSize = allocation.DeviceSize; + var target = new TrackingRenderTarget(deviceSize.Width, deviceSize.Height); + Targets.Add(target); + return target; + } + } + + private sealed class TrackingRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height) + { + public int DisposeCalls { get; private set; } + + protected override void Dispose(bool disposing) + { + if (disposing && !IsDisposed) + DisposeCalls++; + + base.Dispose(disposing); + } + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height); + + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create a CPU render target."); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderResourceSlotTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderResourceSlotTests.cs new file mode 100644 index 0000000000..ca9a69a807 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderResourceSlotTests.cs @@ -0,0 +1,113 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RenderResourceSlotTests +{ + [Test] + public void BoundSlotsLeaseTheMatchingTypedResourceRegardlessOfBindingOrder() + { + var left = new Payload("left"); + var right = new Payload("right"); + using var registry = new RenderRequestResourceRegistry(); + RenderResource leftToken = registry.RegisterBorrowed(left); + RenderResource rightToken = registry.RegisterBorrowed(right); + registry.Commit(leftToken); + registry.Commit(rightToken); + + var leftSlot = new RenderResourceSlot(); + var rightSlot = new RenderResourceSlot(); + RenderResourceBinding[] bindings = [rightSlot.Bind(rightToken), leftSlot.Bind(leftToken)]; + var reached = new List(); + var session = new RenderExecutionSessionToken(); + + session.RunAndComplete(() => + session.UseResource( + leftSlot, + bindings, + value => + { + reached.Add(value.Name); + session.UseResource(rightSlot, bindings, other => reached.Add(other.Name)); + })); + + Assert.That(reached, Is.EqualTo(new[] { "left", "right" })); + } + + [Test] + public void MissingSlotFailsWithoutFallingBackToAnotherSameTypedBinding() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterBorrowed(new Payload("bound")); + registry.Commit(token); + + var bound = new RenderResourceSlot(); + var missing = new RenderResourceSlot(); + RenderResourceBinding[] bindings = [bound.Bind(token)]; + var session = new RenderExecutionSessionToken(); + + KeyNotFoundException? exception = Assert.Throws(() => + session.RunAndComplete(() => session.UseResource(missing, bindings, static _ => { }))); + + Assert.That(exception!.Message, Does.Contain("slot")); + } + + [Test] + public void BindingRejectsATokenWhoseLifecycleHasEnded() + { + var payload = new DisposablePayload(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterOwned(payload); + var slot = new RenderResourceSlot(); + + registry.Rollback(token); + + Assert.Multiple(() => + { + Assert.That(payload.DisposeCalls, Is.EqualTo(1)); + Assert.That( + () => slot.Bind(token), + Throws.InvalidOperationException.With.Message.Contains("cannot be bound")); + }); + } + + [Test] + public void BorrowedResourcesAreReleasedWithoutTakingDisposalOwnership() + { + var payload = new DisposablePayload(); + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterBorrowed(payload); + + registry.Rollback(token); + + Assert.That(payload.DisposeCalls, Is.Zero); + } + + [Test] + public void BindingRejectsATokenWithADifferentDeclaredType() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterBorrowed(new Payload("payload")); + var slot = new RenderResourceSlot(); + + ArgumentException? exception = Assert.Throws( + () => new RenderResourceBinding(slot, token)); + + Assert.That(exception!.ParamName, Is.EqualTo("resource")); + } + + private sealed class Payload(string name) + { + public string Name { get; } = name; + } + + private sealed class OtherPayload; + + private sealed class DisposablePayload : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() => DisposeCalls++; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderScaleFootprintBudgetTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderScaleFootprintBudgetTests.cs new file mode 100644 index 0000000000..f60f49324f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderScaleFootprintBudgetTests.cs @@ -0,0 +1,141 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +// Every clamp must bound the footprint PixelRect.FromRect actually allocates, not a logical-extent estimate. +[TestFixture] +public class RenderScaleFootprintBudgetTests +{ + private static readonly float[] s_origins = + [-40000.5f, -16384.5f, -1.5f, -0.75f, -0.5f, 0f, 0.1f, 0.5f, 0.75f, 1.5f, 1000.3f]; + + private static readonly float[] s_extents = + [0f, 0.25f, 1f, 16383.5f, 16384f, 16384.5f, 20000.7f, 40000f, 100000f]; + + private static readonly float[] s_scales = [0.5f, 1f, 1.7f, 4f, 8f]; + + [Test] + public void ExactBufferBudget_DegenerateAxisWithFractionalOrigin_KeepsFootprintWithinBudget() + { + var bounds = new Rect(0.5f, 0.5f, 0f, RenderScaleUtilities.MaxBufferDimension); + + float clamped = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, 1f); + + Assert.That( + PixelRect.FromRect(bounds, 1f).Height, + Is.GreaterThan(RenderScaleUtilities.MaxBufferDimension), + "the fixture must actually overflow at the requested scale"); + Assert.That( + PixelRect.FromRect(bounds, clamped).Height, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(clamped, Is.GreaterThan(0f).And.LessThanOrEqualTo(1f)); + } + + [Test] + public void RasterApronBudget_DegenerateAxisWithFractionalOrigin_KeepsAproneFootprintWithinBudget() + { + var bounds = new Rect(0.5f, 0.5f, 0f, RenderScaleUtilities.MaxBufferDimension - 2f); + + float clamped = RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, 1f); + + Assert.That( + RenderScaleUtilities.AddRasterApron(PixelRect.FromRect(bounds, 1f)).Height, + Is.GreaterThan(RenderScaleUtilities.MaxBufferDimension), + "the fixture must actually overflow at the requested scale"); + Assert.That( + RenderScaleUtilities.AddRasterApron(PixelRect.FromRect(bounds, clamped)).Height, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(clamped, Is.GreaterThan(0f).And.LessThanOrEqualTo(1f)); + } + + [Test] + public void BufferBudget_FractionalOrigin_KeepsFootprintWithinBudget() + { + var bounds = new Rect(0.5f, 0f, RenderScaleUtilities.MaxBufferDimension - 0.4f, 1f); + + float clamped = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 1f); + + Assert.That( + PixelRect.FromRect(bounds, 1f).Width, + Is.GreaterThan(RenderScaleUtilities.MaxBufferDimension), + "the fixture must actually overflow at the requested scale"); + Assert.That( + PixelRect.FromRect(bounds, clamped).Width, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(clamped, Is.GreaterThan(0f).And.LessThanOrEqualTo(1f)); + } + + [Test] + public void BufferBudget_NonPositiveMaxDimension_IsRejected() + { + Assert.That( + () => RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), 2, 0), + Throws.TypeOf()); + Assert.That( + () => RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), 2, -1), + Throws.TypeOf()); + } + + [Test] + public void AllClamps_SweepOfOriginsExtentsAndScales_NeverExceedTheBudget() + { + foreach (Rect bounds in EnumerateBounds()) + { + foreach (float requested in s_scales) + { + AssertWithinBudget( + "coarse", + bounds, + requested, + RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, requested), + apronPixels: 0); + AssertWithinBudget( + "exact", + bounds, + requested, + RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, requested), + apronPixels: 0); + AssertWithinBudget( + "apron", + bounds, + requested, + RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, requested), + apronPixels: 2); + } + } + + static void AssertWithinBudget( + string clamp, Rect bounds, float requested, float clamped, int apronPixels) + { + string context = $"{clamp}: bounds={bounds}, requested={requested}, clamped={clamped}"; + Assert.That(clamped, Is.GreaterThan(0f), context); + Assert.That(clamped, Is.LessThanOrEqualTo(requested), context); + + PixelRect footprint = PixelRect.FromRect(bounds, clamped); + Assert.That( + footprint.Width + apronPixels, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension), + context); + Assert.That( + footprint.Height + apronPixels, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension), + context); + } + } + + private static IEnumerable EnumerateBounds() + { + foreach (float x in s_origins) + { + foreach (float width in s_extents) + { + foreach (float height in s_extents) + { + yield return new Rect(x, -x, width, height); + } + } + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetFactoryReachTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetFactoryReachTests.cs new file mode 100644 index 0000000000..090281f221 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetFactoryReachTests.cs @@ -0,0 +1,267 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Pins that every legacy filter-effect path allocates its own surfaces through the caller's +/// instead of the global allocator. +/// +/// +/// A factory's targets may come from a graphics context the global allocator knows nothing about. A path that +/// goes around it both ignores the caller's allocation policy and can sample a factory-backed input into a +/// foreign surface, which shows up as missing output rather than an error. The factory is reachable only +/// through the render pass's lease session, so each seam below is checked with a session in hand. +/// +[TestFixture] +[NonParallelizable] +public sealed class RenderTargetFactoryReachTests +{ + private const string BlueShader = + "half4 apply(half4 color) { return half4(0.0, 0.0, color.a, color.a); }"; + + private static readonly Rect s_bounds = new(0, 0, 8, 6); + + [Test] + public void LegacyShaderStage_AllocatesThroughTheFactory() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + using ProgramCache cache = SkRuntimeEffectProgramCache.Create(); + var factory = new CountingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + + FilterEffectStageFallbackExecutor.ApplyShader( + targets, + ShaderDescription.CurrentPixel(BlueShader), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + (target, source) => SkRuntimeEffectProgramCache.AcquireForDestination( + cache, + target.RenderTarget!, + source), + session); + + Assert.That(factory.Requests, Is.Not.Empty, + "A typed shader stage must ask the caller's factory for its output surface."); + } + + [Test] + public void LegacyGeometryStage_AllocatesThroughTheFactory() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + var factory = new CountingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + + FilterEffectStageFallbackExecutor.ApplyGeometry( + targets, + GeometryDescription.CreateRequestLocal( + static session => session.Canvas.Use(static canvas => canvas.Clear()), + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput), + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + session); + + Assert.That(factory.Requests, Is.Not.Empty, + "A typed geometry stage must ask the caller's factory for its output surface."); + } + + [Test] + public void CustomEffectTargets_AllocateThroughTheFactory() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + var factory = new CountingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + renderTargetLeaseSession: session); + + using EffectTarget fresh = context.CreateTarget(s_bounds); + int afterCreateTarget = factory.Requests.Count; + using EffectTarget replacement = context.CreateTargetLike(targets[0]); + + Assert.Multiple(() => + { + Assert.That(afterCreateTarget, Is.GreaterThan(0), + "CreateTarget must ask the caller's factory."); + Assert.That(fresh.RenderTarget, Is.Not.Null); + Assert.That(replacement.RenderTarget, Is.Not.Null); + }); + } + + /// + /// A declined native replacement leaves the caller holding the unfiltered source, and a preview keeps + /// going with it. The request has to be told, or the executor can publish that unfiltered frame into a + /// persistent node cache or a backdrop snapshot and keep bypassing the effect long after the factory + /// recovers. + /// + [Test] + public void ADeclinedNativeReplacement_MarksTheRequestAsHavingDroppedContent() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + var factory = new DecliningTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + renderTargetLeaseSession: session); + + using EffectTarget replacement = context.CreateNativeTargetLike(targets[0]); + + Assert.Multiple(() => + { + Assert.That(factory.Declined, Is.GreaterThan(0), "the fixture must actually decline"); + Assert.That(replacement.RenderTarget, Is.Null, "a declined replacement is an empty target"); + Assert.That( + session.ContentDropObserved, + Is.True, + "the preview kept the unfiltered source, so the request dropped content"); + }); + } + + /// + /// A delivery render ships what it produces, so it fails rather than writing an unprocessed frame. The + /// lease session is what says so, by throwing on the declined acquire rather than reporting it, so the + /// preview-only handling above is never the delivery answer. + /// + [Test] + public void ADeclinedNativeReplacement_FailsADeliveryRenderInsteadOfDroppingTheEffect() + { + using EffectTargets targets = CreateSolidTargets(s_bounds); + var factory = new DecliningTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Delivery); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + outputScale: 1, + workingScale: 1, + maxWorkingScale: 1, + renderTargetLeaseSession: session); + + Assert.Multiple(() => + { + Assert.That( + () => context.CreateNativeTargetLike(targets[0]), + Throws.TypeOf() + .With.Message.Contains("could not allocate")); + Assert.That( + session.ContentDropObserved, + Is.False, + "a delivery render fails rather than recording a drop and carrying on"); + }); + } + + [Test] + public void TileBrushIntermediate_AllocatesThroughTheFactory() + { + var factory = new CountingTargetFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + var content = new RectShape(); + content.Width.CurrentValue = 4; + content.Height.CurrentValue = 4; + content.Fill.CurrentValue = Brushes.White; + var brush = new DrawableBrush(content); + brush.Stretch.CurrentValue = Stretch.Fill; + using var brushResource = (Brush.Resource)brush.ToResource(CompositionContext.Default); + + var constructor = new BrushConstructor( + s_bounds, + brushResource, + BlendMode.SrcOver, + scale: 1f, + maxWorkingScale: 1f, + RenderIntent.Preview, + static (_, bounds, _) => new MaterializedDrawableBrush(CreateOpaqueImage(4, 4), bounds), + session); + + using SKShader? shader = constructor.CreateShader(); + + Assert.Multiple(() => + { + Assert.That(shader, Is.Not.Null, "The fixture must reach the tile-intermediate path."); + Assert.That(factory.Requests, Is.Not.Empty, + "A tile-brush intermediate must ask the caller's factory."); + }); + } + + private static SKImage CreateOpaqueImage(int width, int height) + { + using SKSurface surface = SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("The materializer fixture needs a CPU surface."); + surface.Canvas.Clear(SKColors.White); + return surface.Snapshot(); + } + + private static EffectTargets CreateSolidTargets(Rect bounds) + { + using RenderTarget renderTarget = RenderTarget.Create((int)bounds.Width, (int)bounds.Height) + ?? throw new InvalidOperationException("A CPU render target is required for this test."); + using (var canvas = new ImmediateCanvas( + renderTarget, + density: 1, + maxWorkingScale: 1, + logicalSize: bounds.Size)) + { + canvas.Clear(Colors.Red); + } + + return new EffectTargets + { + new EffectTarget(renderTarget, bounds, EffectiveScale.At(1)), + }; + } + + private sealed class DecliningTargetFactory : IRenderTargetFactory + { + public int Declined { get; private set; } + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + Declined++; + return null; + } + } + + private sealed class CountingTargetFactory : IRenderTargetFactory + { + public List Requests { get; } = []; + + public RenderTarget? Create(RenderTargetAllocationDescriptor allocation) + { + Requests.Add(allocation.DeviceSize); + return RenderTarget.Create(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetPoolRejectionTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetPoolRejectionTests.cs new file mode 100644 index 0000000000..657a1dda4c --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetPoolRejectionTests.cs @@ -0,0 +1,81 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Pins that rejecting a factory's target never takes down a surface something else is still using. +/// +/// +/// A factory can hand back a fresh target instance wrapping a surface the pool already owns. Refusing it is +/// right, but disposing it would free that surface underneath the live slot that holds it, and the next draw +/// into that slot writes to freed memory rather than failing. +/// +[TestFixture] +public sealed class RenderTargetPoolRejectionTests +{ + [Test] + public void ATargetSharingAnOwnedSurface_IsRefusedWithoutDestroyingIt() + { + var factory = new SurfaceSharingFactory(); + using var registry = new RenderTargetLeaseRegistry(factory); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + + RenderTargetLease first = session.Acquire(new PixelSize(8, 8)); + RenderTarget owned = first.Target; + + // The first lease is still held, so a second request of the same size cannot reuse that slot and has + // to create; the factory answers it with another instance over the surface the pool already holds. + Assert.That( + () => session.Acquire(new PixelSize(8, 8)), + Throws.InstanceOf() + .With.Message.Contains("already in use")); + + Assert.Multiple(() => + { + Assert.That(owned.IsDisposed, Is.False); + Assert.That( + () => owned.Value.Canvas.Clear(SKColors.Transparent), + Throws.Nothing, + "The refused instance must not have freed the surface the pool still owns."); + }); + + first.Dispose(); + } + + private sealed class SurfaceSharingFactory : IRenderTargetFactory + { + private SharedSurfaceRenderTarget? _first; + + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + { + if (_first is null) + { + _first = new SharedSurfaceRenderTarget( + CreateSurface(allocation.DeviceSize), + allocation.DeviceSize); + return _first; + } + + // Deliberately wrong: a new instance over a surface the pool already owns. + return new SharedSurfaceRenderTarget(_first.BackingSurface, allocation.DeviceSize); + } + + private static SKSurface CreateSurface(PixelSize size) + => SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create a CPU render target."); + } + + private sealed class SharedSurfaceRenderTarget(SKSurface surface, PixelSize size) + : RenderTarget(surface, size.Width, size.Height) + { + public SKSurface BackingSurface { get; } = surface; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSharedSurfaceTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSharedSurfaceTests.cs new file mode 100644 index 0000000000..59819bda27 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSharedSurfaceTests.cs @@ -0,0 +1,167 @@ +using Beutl.Graphics.Backend; +using Beutl.Graphics.Rendering; +using Moq; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +/// +/// Pins that a failed shared-surface initialization releases the backend texture it already created. +/// +/// +/// The backend texture has no finalizer and nothing registers it anywhere, so a texture that escapes +/// before it reaches a strands its image, view and device memory for the +/// life of the process — and callers treat the resulting null as a per-frame degrade, so the leak +/// compounds instead of happening once. +/// +public sealed class RenderTargetSharedSurfaceTests +{ + [Test] + public void CreateSharedSurface_ReleasesTextureAndSurface_WhenTheTransparentClearThrows() + { + var texture = new FailingClearTexture(4, 4, failOnSurfaceCreation: false); + var context = new Mock(); + context.Setup(x => x.CreateTexture2D(4, 4, TextureFormat.RGBA16Float)).Returns(texture); + + Assert.Throws( + () => RenderTarget.CreateSharedSurface(context.Object, 4, 4, out _)); + + Assert.That(texture.DisposeCount, Is.EqualTo(1)); + Assert.That(texture.CreatedSurface!.Handle, Is.EqualTo(IntPtr.Zero)); + } + + [Test] + public void CreateSharedSurface_ReleasesTheTexture_WhenSurfaceCreationThrows() + { + var texture = new FailingClearTexture(4, 4, failOnSurfaceCreation: true); + var context = new Mock(); + context.Setup(x => x.CreateTexture2D(4, 4, TextureFormat.RGBA16Float)).Returns(texture); + + Assert.Throws( + () => RenderTarget.CreateSharedSurface(context.Object, 4, 4, out _)); + + Assert.That(texture.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public void CreateSharedSurface_ClearsAndKeepsTheTexture_WhenInitializationSucceeds() + { + var texture = new ClearableRasterTexture(4, 4); + var context = new Mock(); + context.Setup(x => x.CreateTexture2D(4, 4, TextureFormat.RGBA16Float)).Returns(texture); + + using SKSurface? surface = RenderTarget.CreateSharedSurface(context.Object, 4, 4, out ITexture2D? created); + + Assert.That(created, Is.SameAs(texture)); + Assert.That(texture.ClearCount, Is.EqualTo(1)); + Assert.That(texture.DisposeCount, Is.Zero); + Assert.That(surface!.Handle, Is.Not.EqualTo(IntPtr.Zero)); + } + + /// + /// A backend that declines to wrap the texture reports it by returning null rather than by throwing, and + /// the throwing path was the only one that released. The texture has no finalizer, so a caller treating + /// the resulting null as a per-frame degrade would strand one image, view and allocation per frame. + /// + [Test] + public void CreateSharedSurface_ReleasesTheTexture_WhenTheBackendDeclinesToWrapIt() + { + var texture = new DecliningTexture(4, 4); + var context = new Mock(); + context.Setup(x => x.CreateTexture2D(4, 4, TextureFormat.RGBA16Float)).Returns(texture); + + SKSurface? surface = RenderTarget.CreateSharedSurface(context.Object, 4, 4, out ITexture2D? created); + + Assert.Multiple(() => + { + Assert.That(surface, Is.Null); + Assert.That(created, Is.Null, "A texture that was released must not be handed back."); + Assert.That(texture.DisposeCount, Is.EqualTo(1)); + }); + } + + private sealed class DecliningTexture(int width, int height) : RasterBackedTexture(width, height) + { + public override bool HasTransparentContents => false; + + public override SKSurface CreateSkiaSurface() => null!; + + public override void ClearToTransparent() + => throw new InvalidOperationException("A declined wrap must not reach the clear."); + } + + private abstract class RasterBackedTexture(int width, int height) + : ITexture2D, ITransparentClearableTexture + { + public int Width { get; } = width; + + public int Height { get; } = height; + + public TextureFormat Format => TextureFormat.RGBA16Float; + + public IntPtr NativeHandle => IntPtr.Zero; + + public IntPtr NativeViewHandle => IntPtr.Zero; + + public bool RequiresSkiaFlushForBackendInterop => false; + + public abstract bool HasTransparentContents { get; } + + public SKSurface? CreatedSurface { get; private set; } + + public int DisposeCount { get; private set; } + + protected SKSurface CreateRasterSurface() + { + CreatedSurface = SKSurface.Create(new SKImageInfo(Width, Height)); + return CreatedSurface; + } + + public void Upload(ReadOnlySpan data) => throw new NotSupportedException(); + + public byte[] DownloadPixels() => throw new NotSupportedException(); + + public abstract SKSurface CreateSkiaSurface(); + + public void PrepareForRender() => throw new NotSupportedException(); + + public void PrepareForSampling() => throw new NotSupportedException(); + + public void PrepareForSkiaRendering() => throw new NotSupportedException(); + + public void PrepareForSkiaSampling(bool requireCompletion) => throw new NotSupportedException(); + + public abstract void ClearToTransparent(); + + public void MarkContentsTransparent() => MarkedTransparentCount++; + + public int MarkedTransparentCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + private sealed class FailingClearTexture(int width, int height, bool failOnSurfaceCreation) + : RasterBackedTexture(width, height) + { + public override bool HasTransparentContents => false; + + public override SKSurface CreateSkiaSurface() + => failOnSurfaceCreation + ? throw new InvalidOperationException("surface creation failed") + : CreateRasterSurface(); + + public override void ClearToTransparent() + => throw new InvalidOperationException("clear failed"); + } + + private sealed class ClearableRasterTexture(int width, int height) : RasterBackedTexture(width, height) + { + public override bool HasTransparentContents => ClearCount > 0; + + public int ClearCount { get; private set; } + + public override SKSurface CreateSkiaSurface() => CreateRasterSurface(); + + public override void ClearToTransparent() => ClearCount++; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSnapshotTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSnapshotTests.cs index 35c57e9df1..5a6ba79658 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSnapshotTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetSnapshotTests.cs @@ -176,7 +176,7 @@ public void RendererSnapshotIntoDestination_MatchesAllocatingSnapshot() VulkanTestEnvironment.EnsureAvailable(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - using var renderer = new Renderer(64, 48); + using var renderer = new Renderer(64, 48, RenderIntent.Preview); using Bitmap allocated = renderer.Snapshot(); using Bitmap reused = NewScratch(allocated.Width, allocated.Height); diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetThreadAffinityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetThreadAffinityTests.cs index d27bc72fbb..260a3f7b9c 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetThreadAffinityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RenderTargetThreadAffinityTests.cs @@ -1,5 +1,8 @@ -using Beutl.Graphics; +using Beutl.Composition; +using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Threading; using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -102,6 +105,52 @@ public void Dispose_gives_up_waiting_rather_than_releasing_off_a_busy_owning_thr "the queued release should still run once the render thread drains it"); } + [Test] + public void A_timed_out_release_completes_when_the_busy_dispatcher_shuts_down() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + using var occupied = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + int executions = 0; + Task? caller = null; + bool dispatcherJoined; + + try + { + dispatcher.Dispatch(() => + { + occupied.Set(); + release.Wait(TimeSpan.FromSeconds(30)); + }); + Assert.That(occupied.Wait(TimeSpan.FromSeconds(5)), Is.True); + + caller = Task.Run(() => GpuResourceRelease.Run( + dispatcher, + () => Interlocked.Increment(ref executions))); + Assert.That( + SpinWait.SpinUntil(() => caller.IsCompleted, TimeSpan.FromSeconds(10)), + Is.True, + "Run did not return after its bounded wait"); + Assert.That(executions, Is.Zero); + + dispatcher.Shutdown(); + } + finally + { + release.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcherJoined = dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + } + + Assert.Multiple(() => + { + Assert.That(caller!.IsCompletedSuccessfully, Is.True); + Assert.That(dispatcherJoined, Is.True); + Assert.That(executions, Is.EqualTo(1)); + }); + } + // Giving up leaves the cleanup queued with IsDisposed still false, so the second Dispose has to // be turned away by something claimed before the queue, or the shared paints get disposed twice. [Test] @@ -173,6 +222,37 @@ public void A_release_slower_than_the_deadline_is_waited_out_once_it_has_started Assert.That(completed, Is.True); } + [Test] + public void Required_operation_keeps_waiting_while_the_dispatcher_is_live() + { + using var occupied = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + int result = 0; + + try + { + RenderThread.Dispatcher.Dispatch(() => + { + occupied.Set(); + release.Wait(TimeSpan.FromSeconds(60)); + }); + Assert.That(occupied.Wait(TimeSpan.FromSeconds(30)), Is.True); + + Task caller = Task.Run(() => + result = GpuResourceRelease.RunRequired(RenderThread.Dispatcher, static () => 42)); + + Assert.That(caller.Wait(TimeSpan.FromSeconds(6)), Is.False, + "a live but busy dispatcher must not turn a valid queued operation into a timeout"); + release.Set(); + Assert.That(caller.Wait(TimeSpan.FromSeconds(30)), Is.True); + Assert.That(result, Is.EqualTo(42)); + } + finally + { + release.Set(); + } + } + [Test] public void A_release_that_throws_after_the_wait_was_given_up_leaves_the_dispatcher_usable() { @@ -201,6 +281,108 @@ public void A_release_that_throws_after_the_wait_was_given_up_leaves_the_dispatc "the render thread must keep draining after a queued release faulted"); } + [Test] + public void A_required_operation_queued_before_shutdown_is_rejected_once() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + using var occupied = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + using var callerEntered = new ManualResetEventSlim(false); + Exception? failure = null; + int executions = 0; + var caller = new Thread(() => + { + callerEntered.Set(); + try + { + GpuResourceRelease.RunRequired(dispatcher, () => Interlocked.Increment(ref executions)); + } + catch (Exception ex) + { + failure = ex; + } + }) + { IsBackground = true }; + + try + { + dispatcher.Dispatch(() => + { + occupied.Set(); + release.Wait(TimeSpan.FromSeconds(30)); + }); + Assert.That(occupied.Wait(TimeSpan.FromSeconds(5)), Is.True); + caller.Start(); + Assert.That(callerEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); + Assert.That(WaitUntilBlocked(caller), Is.True); + + dispatcher.Shutdown(); + Assert.That(caller.Join(TimeSpan.FromSeconds(5)), Is.True); + } + finally + { + release.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + } + + Assert.Multiple(() => + { + Assert.That(failure, Is.TypeOf()); + Assert.That(executions, Is.Zero); + }); + } + + [Test] + public async Task A_required_operation_that_started_before_shutdown_completes_once() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + using var started = new ManualResetEventSlim(false); + using var finish = new ManualResetEventSlim(false); + int executions = 0; + Task caller = Task.Run(() => GpuResourceRelease.RunRequired(dispatcher, () => + { + started.Set(); + finish.Wait(TimeSpan.FromSeconds(30)); + return Interlocked.Increment(ref executions); + })); + int result; + bool dispatcherJoined; + + try + { + Assert.That(started.Wait(TimeSpan.FromSeconds(5)), Is.True); + dispatcher.Shutdown(); + Assert.That(caller.IsCompleted, Is.False); + finish.Set(); + result = await caller.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + finish.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + dispatcherJoined = dispatcher.Thread.Join(TimeSpan.FromSeconds(5)); + } + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(1)); + Assert.That(dispatcherJoined, Is.True); + Assert.That(executions, Is.EqualTo(1)); + }); + } + + [Test] + public void A_required_operation_preserves_its_exception_type() + { + Assert.Throws(() => + GpuResourceRelease.RunRequired( + RenderThread.Dispatcher, + static () => throw new InvalidOperationException("required operation failed"))); + } + [Test] public void Dispose_on_the_owning_thread_releases_inline() { diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs index 53108605f0..6ba2b2ef9f 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs @@ -6,61 +6,152 @@ using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; -// Drives the cleanup-sweep contract through a real Renderer (RenderDrawable and RecalculateBoundaries): -// a mid-loop throw must still dispose every pulled op, or the GPU handles they back leak. Renderer needs -// Vulkan and the render thread, hence VulkanTestEnvironment. +// Drives request-owner cleanup through the production frame renderer using recorded resources. [NonParallelizable] [TestFixture] public class RendererExceptionSafetyTests { [Test] - public void RenderDrawable_DisposesFaultingAndRemainingOperations_WhenRenderThrows() + public void RenderDrawable_DischargesFaultingAndUnexecutedResources_WhenExecutionThrows() { VulkanTestEnvironment.EnsureAvailable(); - var disposed = new List(); + var discharged = new List(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - using var renderer = new Renderer(16, 16); + using var renderer = new Renderer(16, 16, RenderIntent.Preview); CompositionFrame frame = CreateFrame( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnRender: true), - CreateOperation("remaining", disposed)); + discharged, + new RecordedOperationSpec("first"), + new RecordedOperationSpec("fault", ThrowOnExecute: true), + new RecordedOperationSpec("remaining")); var ex = Assert.Throws(() => renderer.Render(frame)); Assert.That(ex!.Message, Is.EqualTo("fault")); - Assert.That(disposed, Is.EquivalentTo(new[] { "first", "fault", "remaining" })); + Assert.That(discharged, Is.EquivalentTo(new[] { "first", "fault", "remaining" })); }); } [Test] - public void RecalculateBoundaries_DisposesFaultingAndRemainingOperations_WhenDisposeThrows() + public void RecalculateBoundaries_DischargesEveryMetadataResource_WhenOneDischargeThrows() { VulkanTestEnvironment.EnsureAvailable(); - var disposed = new List(); + var discharged = new List(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - using var renderer = new Renderer(16, 16); + using var renderer = new Renderer(16, 16, RenderIntent.Preview); CompositionFrame frame = CreateFrame( - CreateOperation("first", disposed), - CreateOperation("fault", disposed, throwOnDispose: true), - CreateOperation("remaining", disposed)); + discharged, + new RecordedOperationSpec("first", TrackMetadataDischarge: true), + new RecordedOperationSpec("fault", ThrowOnDispose: true, TrackMetadataDischarge: true), + new RecordedOperationSpec("remaining", TrackMetadataDischarge: true)); renderer.UpdateFrame(frame); - var ex = Assert.Throws(() => renderer.RecalculateBoundaries(0)); + var ex = Assert.Throws(() => renderer.RecalculateBoundaries(0)); - Assert.That(ex!.Message, Is.EqualTo("fault")); - // The faulting op must not be re-disposed; the trailing op must still be cleaned up by the sweep. - Assert.That(disposed, Is.EquivalentTo(new[] { "first", "fault", "remaining" })); + // Metadata-only requests own the same recording resources. Cleanup is strict LIFO and a + // failing resource cannot stop later cleanup or cause the same resource to run twice. + Assert.That(ex!.Flatten().InnerExceptions.Single().Message, Is.EqualTo("fault")); + Assert.That(discharged, Is.EqualTo(new[] { "remaining", "fault", "first" })); }); } - private static CompositionFrame CreateFrame(params RenderNodeOperation[] operations) + [Test] + public void RenderDrawable_ClearsCurrentFrameMetadata_WhenExecutionThrows() { - var drawable = new FaultingDrawable(operations); + RenderThread.Dispatcher.Invoke(() => + { + using var renderer = new Renderer( + width: 16, + height: 16, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(16, 16)); + var previous = new FaultingDrawable([new RecordedOperationSpec("previous")]); + var previousResource = (Drawable.Resource)previous.ToResource(CompositionContext.Default); + var previousFrame = new CompositionFrame( + ImmutableArray.Create(previousResource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(16, 16), + null); + renderer.Render(previousFrame); + Assert.That(renderer.GetBoundary(previous), Is.Not.Null); + + var faulting = new FaultingDrawable([new RecordedOperationSpec("fault", ThrowOnExecute: true)]); + var faultingResource = (Drawable.Resource)faulting.ToResource(CompositionContext.Default); + var faultingFrame = new CompositionFrame( + ImmutableArray.Create(faultingResource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(16, 16), + null); + + Assert.That( + () => renderer.Render(faultingFrame), + Throws.TypeOf().With.Message.EqualTo("fault")); + Assert.Multiple(() => + { + Assert.That(renderer.GetBoundary(previous), Is.Null); + Assert.That(renderer.GetBoundary(faulting), Is.Null); + Assert.That(renderer.GetBoundaries(zIndex: 0), Is.Empty); + }); + }); + } + + [Test] + public void UpdateFrame_PublishesMetadataOnlyAfterEveryDrawableRecordsAndRetriesFaultedEntry() + { + RenderThread.Dispatcher.Invoke(() => + { + using var renderer = new Renderer( + width: 16, + height: 16, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(16, 16)); + var first = new RecordingFailureDrawable(failures: 0); + var faulting = new RecordingFailureDrawable(failures: 1); + var firstResource = (Drawable.Resource)first.ToResource(CompositionContext.Default); + var faultingResource = (Drawable.Resource)faulting.ToResource(CompositionContext.Default); + var frame = new CompositionFrame( + ImmutableArray.Create(firstResource, faultingResource), + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(16, 16), + null); + + Assert.That( + () => renderer.UpdateFrame(frame), + Throws.TypeOf().With.Message.EqualTo("recording failed")); + Assert.Multiple(() => + { + Assert.That(renderer.GetBoundary(first), Is.Null); + Assert.That(renderer.GetBoundary(faulting), Is.Null); + Assert.That(renderer.GetBoundaries(zIndex: 0), Is.Empty); + }); + + Assert.That(() => renderer.UpdateFrame(frame), Throws.Nothing); + Assert.Multiple(() => + { + // A faulted frame revalidates nothing, so the entry that already recorded keeps its mark + // and re-records. Consuming it early would strand a mark on a node a skipped entry shares. + Assert.That(first.RenderCalls, Is.EqualTo(2)); + Assert.That(faulting.RenderCalls, Is.EqualTo(2)); + Assert.That(renderer.GetBoundary(first), Is.Not.Null); + Assert.That(renderer.GetBoundary(faulting), Is.Not.Null); + }); + }); + } + + private static CompositionFrame CreateFrame( + ICollection discharged, + params RecordedOperationSpec[] operations) + { + var drawable = new FaultingDrawable(operations) { Discharged = discharged }; var resource = (Drawable.Resource)drawable.ToResource(CompositionContext.Default); return new CompositionFrame( ImmutableArray.Create(resource), @@ -69,39 +160,60 @@ private static CompositionFrame CreateFrame(params RenderNodeOperation[] operati new CompositionEligibility([drawable])); } - private static RenderNodeOperation CreateOperation( - string name, - ICollection disposed, - bool throwOnRender = false, - bool throwOnDispose = false) + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} + +// Top-level partial because EngineObjectResourceGenerator does not support nested types. +internal sealed partial class FaultingDrawable : Drawable +{ + private readonly RecordedOperationSpec[] _operations; + + public FaultingDrawable(RecordedOperationSpec[] operations) + { + _operations = operations; + } + + public ICollection Discharged { get; set; } = new List(); + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + => context.DrawNode(new FixedOpsNode(_operations, Discharged)); + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(4, 4); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) { - return RenderNodeOperation.CreateLambda( - new Rect(0, 0, 4, 4), - _ => - { - if (throwOnRender) - { - throw new InvalidOperationException(name); - } - }, - onDispose: () => - { - disposed.Add(name); - if (throwOnDispose) - { - throw new InvalidOperationException(name); - } - }); } } -// Emits a fixed set of ops into the render graph, with Render overridden to bypass the blend/opacity/filter -// pushes so they reach the Renderer's pull loop unwrapped. Top-level partial because -// EngineObjectResourceGenerator does not support nested types. -internal sealed partial class FaultingDrawable(RenderNodeOperation[] operations) : Drawable +internal sealed partial class RecordingFailureDrawable : Drawable { + private int _remainingFailures; + + public RecordingFailureDrawable(int failures) + { + _remainingFailures = failures; + } + + public int RenderCalls { get; private set; } + public override void Render(GraphicsContext2D context, Drawable.Resource resource) - => context.DrawNode(new FixedOpsNode(operations)); + { + RenderCalls++; + context.DrawRectangle(new Rect(0, 0, 4, 4), Brushes.Resource.White, null); + if (_remainingFailures-- > 0) + { + throw new InvalidOperationException("recording failed"); + } + } protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(4, 4); @@ -110,7 +222,104 @@ protected override void OnDraw(GraphicsContext2D context, Drawable.Resource reso } } -internal sealed class FixedOpsNode(RenderNodeOperation[] operations) : RenderNode +internal sealed class FixedOpsNode : RenderNode +{ + private static readonly RenderResourceSlot s_fillSlot = new(); + private readonly Func> _operationFactory; + private readonly ICollection _discharged; + private readonly Action? _onProcess; + + public FixedOpsNode( + IReadOnlyList operations, + ICollection? discharged = null) + : this(() => operations, discharged) + { + } + + public FixedOpsNode( + Func> operationFactory, + ICollection? discharged = null, + Action? onProcess = null) + { + _operationFactory = operationFactory; + _discharged = discharged ?? new List(); + _onProcess = onProcess; + } + + public int ProcessCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + ProcessCalls++; + _onProcess?.Invoke(); + IReadOnlyList operations = _operationFactory(); + RenderResource fillResource = context.Borrow(Brushes.Resource.White); + for (int index = 0; index < operations.Count; index++) + { + RecordedOperationSpec spec = operations[index]; + bool trackDischarge = context.Purpose != RenderRequestPurpose.Bounds || spec.TrackMetadataDischarge; + var operation = new RecordedOperation(spec, _discharged, trackDischarge); + _ = context.Own(operation); + var definition = OpaqueRenderDefinition.Create( + static (session, state) => session.UseResource( + s_fillSlot, + fill => RecordedOperation.Execute(state, session, fill)), + OpaqueRenderBoundsContract.Source(spec.EffectiveBounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.Vector, + resources: [s_fillSlot]); + context.Publish(context.OpaqueSource(definition.Call(spec, [s_fillSlot.Bind(fillResource)]))); + } + } +} + +internal readonly record struct RecordedOperationSpec( + string Name, + bool ThrowOnExecute = false, + bool AllocateBeforeThrow = false, + bool ThrowOnDispose = false, + string? DisposeFaultMessage = null, + bool TrackMetadataDischarge = false, + Rect? Bounds = null) +{ + public Rect EffectiveBounds => Bounds ?? new Rect(0, 0, 4, 4); +} + +internal sealed class RecordedOperation( + RecordedOperationSpec spec, + ICollection discharged, + bool trackDischarge) : IDisposable { - public override RenderNodeOperation[] Process(RenderNodeContext context) => operations; + private bool _disposed; + + public static void Execute( + RecordedOperationSpec spec, + OpaqueRenderSession session, + Brush.Resource fill) + { + if (spec.ThrowOnExecute && !spec.AllocateBeforeThrow) + throw new InvalidOperationException(spec.Name); + + Rect bounds = spec.EffectiveBounds; + using OpaqueRenderOutput output = session.CreateOutput(bounds); + if (spec.ThrowOnExecute) + throw new InvalidOperationException(spec.Name); + + output.Canvas.Use(canvas => + canvas.DrawRectangle(bounds, fill, pen: null)); + session.Publish(output); + } + + public void Dispose() + { + if (_disposed) + throw new InvalidOperationException($"{spec.Name}-double-dispose"); + + _disposed = true; + if (trackDischarge) + discharged.Add(spec.Name); + if (spec.ThrowOnDispose) + throw new InvalidOperationException(spec.DisposeFaultMessage ?? spec.Name); + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererIntentTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererIntentTests.cs new file mode 100644 index 0000000000..cb55483c23 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererIntentTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; +using Beutl.Threading; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public sealed class RendererIntentTests +{ + [TestCase(RenderIntent.Preview)] + [TestCase(RenderIntent.Delivery)] + public void TheExecutedFrameRequestCarriesTheRendererIntent(RenderIntent intent) + { + RenderThread.Dispatcher.Invoke(() => + { + using Renderer renderer = CreateRenderer(intent); + + renderer.Render(CreateEmptyFrame()); + + Assert.That(renderer.FrameRequestIntent, Is.EqualTo(intent), + "The frame renderer issues every request, so it must carry the renderer's intent."); + }); + } + + [Test] + public void TheExecutedFrameRequestKeepsTheIntentWhenCacheOptionsRebuildTheFrameRenderer() + { + RenderThread.Dispatcher.Invoke(() => + { + using Renderer renderer = CreateRenderer(RenderIntent.Delivery); + + renderer.CacheOptions = RenderCacheOptions.Disabled; + renderer.ClearAllCaches(); + renderer.Render(CreateEmptyFrame()); + + Assert.That(renderer.FrameRequestIntent, Is.EqualTo(RenderIntent.Delivery), + "Rebuilding the frame renderer for new cache options must not reset its intent to the default."); + }); + } + + // The canvas is the switch every brush-owned intermediate reads to choose degrade vs fail; + // BrushIntermediateAllocationIntentTests covers the outcome itself. + [TestCase(RenderIntent.Preview)] + [TestCase(RenderIntent.Delivery)] + public void ThePaintingCanvasCarriesTheRendererIntent(RenderIntent intent) + { + RenderThread.Dispatcher.Invoke(() => + { + using Renderer renderer = CreateRenderer(intent); + + Assert.Multiple(() => + { + Assert.That(renderer.Intent, Is.EqualTo(intent)); + Assert.That(Renderer.GetInternalCanvas(renderer).Intent, Is.EqualTo(intent)); + }); + }); + } + + [Test] + public void PreviewIsTheDefaultEvenWithoutAWorkingScaleCeiling() + { + RenderThread.Dispatcher.Invoke(() => + { + using var renderer = new Renderer( + width: 8, + height: 8, + RenderIntent.Preview, + renderScale: 1, + maxWorkingScale: float.PositiveInfinity, + surface: new CpuRenderTarget(8, 8)); + + Assert.Multiple(() => + { + Assert.That(renderer.Intent, Is.EqualTo(RenderIntent.Preview)); + Assert.That(Renderer.GetInternalCanvas(renderer).Intent, Is.EqualTo(RenderIntent.Preview), + "An unbounded working scale must not promote a preview renderer to delivery fail-fast."); + }); + }); + } + + [Test] + public void UndefinedIntent_IsRejectedBeforeAnySurfaceIsCreated() + { + ArgumentOutOfRangeException? failure = Assert.Throws( + () => new Renderer(8, 8, (RenderIntent)12345)); + + Assert.That(failure!.ParamName, Is.EqualTo("intent")); + } + + [Test] + public void UndefinedIntent_DisposesTheCallerSuppliedSurface() + { + var surface = new CpuRenderTarget(8, 8); + + Assert.Throws(() => new Renderer( + width: 8, + height: 8, + (RenderIntent)12345, + renderScale: 1, + maxWorkingScale: 1, + surface: surface)); + + Assert.That(surface.IsDisposed, Is.True); + } + + private static Renderer CreateRenderer(RenderIntent intent) + => new( + width: 8, + height: 8, + renderScale: 1, + maxWorkingScale: 1, + surface: new CpuRenderTarget(8, 8), + intent: intent); + + private static CompositionFrame CreateEmptyFrame() => new( + ImmutableArray.Empty, + new TimeRange(TimeSpan.Zero, TimeSpan.FromSeconds(1)), + new PixelSize(8, 8), + null); + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs index f574a6e60e..4f85b7cf3b 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ResolutionScaleTests.cs @@ -1,6 +1,7 @@ using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; +using Beutl.Media; using Beutl.Models; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -45,7 +46,7 @@ public void EffectiveScale_At_RejectsNonPositiveOrNonFinite(float scale) public void Resolve_AllVectorInputs_RastersAtOutputScale() { // All-vector: rasterize at the output density. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.Unbounded, EffectiveScale.Unbounded], outputScale: 0.5f); Assert.That(w, Is.EqualTo(0.5f)); @@ -54,7 +55,7 @@ public void Resolve_AllVectorInputs_RastersAtOutputScale() [Test] public void Resolve_NoInputs_RastersAtOutputScale() { - float w = RenderNodeContext.ResolveWorkingScale([], outputScale: 1.5f); + float w = RenderScaleUtilities.ResolveWorkingScale([], outputScale: 1.5f); Assert.That(w, Is.EqualTo(1.5f)); } @@ -62,7 +63,7 @@ public void Resolve_NoInputs_RastersAtOutputScale() public void Resolve_SubOutputSupply_IsFlooredAtOutputScale() { // Sub-output supply floored to s_out: w = max(1.0, 0.5) = 1.0. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(0.5f)], outputScale: 1.0f); Assert.That(w, Is.EqualTo(1.0f)); @@ -72,7 +73,7 @@ public void Resolve_SubOutputSupply_IsFlooredAtOutputScale() public void Resolve_ReducedScaleProxy_StaysCheapInPreview() { // A 0.5 proxy at 0.5 preview gives max(0.5, 0.5) = 0.5, no forced upsample. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(0.5f)], outputScale: 0.5f); Assert.That(w, Is.EqualTo(0.5f)); @@ -82,7 +83,7 @@ public void Resolve_ReducedScaleProxy_StaysCheapInPreview() public void Resolve_HighResSource_IsNotClampedByOutput() { // A 2.0 source at 1.0 output keeps its density. Output is not a ceiling. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(2.0f)], outputScale: 1.0f); Assert.That(w, Is.EqualTo(2.0f)); @@ -91,7 +92,7 @@ public void Resolve_HighResSource_IsNotClampedByOutput() [Test] public void Resolve_MixedConcrete_TakesDensestSupply() { - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(0.5f), EffectiveScale.At(2.0f), EffectiveScale.Unbounded], outputScale: 1.0f); Assert.That(w, Is.EqualTo(2.0f)); @@ -101,7 +102,7 @@ public void Resolve_MixedConcrete_TakesDensestSupply() public void Resolve_MaxWorkingScale_CapsResult() { // Preview ceiling: a 4.0 source is capped to 2x the output. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(4.0f)], outputScale: 1.0f, maxWorkingScale: 2.0f); @@ -114,7 +115,7 @@ public void Resolve_MaxWorkingScale_CapsResult() public void Resolve_LowResBitmapWithVector_FloorsAtOutput() { // A 0.5 bitmap beside vector must not pull w to 0.5; vector can draw at s_out. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(0.5f), EffectiveScale.Unbounded], outputScale: 1.0f); Assert.That(w, Is.EqualTo(1.0f)); @@ -124,7 +125,7 @@ public void Resolve_LowResBitmapWithVector_FloorsAtOutput() public void Resolve_HighResBitmapWithVector_KeepsBitmapDensity() { // A 2.0 bitmap alongside vector keeps the densest supply (2.0), not pulled to 1.0. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(2.0f), EffectiveScale.Unbounded], outputScale: 1.0f); Assert.That(w, Is.EqualTo(2.0f)); @@ -134,7 +135,7 @@ public void Resolve_HighResBitmapWithVector_KeepsBitmapDensity() public void Resolve_UnitBitmapWithVector_IsByteIdentityNeutral() { // At(1) + vector at output 1.0 => w == 1. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(1.0f), EffectiveScale.Unbounded], outputScale: 1.0f); Assert.That(w, Is.EqualTo(1.0f)); @@ -144,7 +145,7 @@ public void Resolve_UnitBitmapWithVector_IsByteIdentityNeutral() public void Resolve_UnitInputsUnitOutput_IsOne() { // Unit supply at output 1.0 => w == 1. - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.Unbounded], outputScale: 1.0f); Assert.That(w, Is.EqualTo(1.0f)); @@ -156,14 +157,14 @@ public void Resolve_UnitInputsUnitOutput_IsOne() public void Resolve_SmallHighDensitySiblingBesideLowDensity_RaisesWholeBoundary() { // CHARACTERIZATION: the densest input raises w for the entire boundary (known footgun, pinned here). - float wWithVector = RenderNodeContext.ResolveWorkingScale( + float wWithVector = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(8f), EffectiveScale.At(1f), EffectiveScale.Unbounded], outputScale: 1f); Assert.That(wWithVector, Is.EqualTo(8f), "one small At(8) sibling lifts the whole boundary to w == 8"); // Even a single dense sibling next to pure vector content raises w. - float wDenseBesideVector = RenderNodeContext.ResolveWorkingScale( + float wDenseBesideVector = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(8f), EffectiveScale.Unbounded], outputScale: 1f); Assert.That(wDenseBesideVector, Is.EqualTo(8f), @@ -178,7 +179,7 @@ public void Resolve_HighDensitySourceInHalfPreview_RunsAtFullDensity_NoPreviewSp // A high-density source under an effect in Half preview does not get the reduced-scale speedup. // w = min(max(0.5, 4), 1.0) = 1.0, capped by the preview ceiling (2 * s_out). const float halfPreviewCeiling = 1.0f; // = WorkingScaleCeiling.Preview(0.5f) = 2 × 0.5 - float w = RenderNodeContext.ResolveWorkingScale( + float w = RenderScaleUtilities.ResolveWorkingScale( [EffectiveScale.At(4f)], outputScale: 0.5f, maxWorkingScale: WorkingScaleCeiling.Preview(0.5f)); @@ -194,7 +195,7 @@ public void Resolve_HighDensitySourceInHalfPreview_RunsAtFullDensity_NoPreviewSp public void ClampBudget_SmallBuffer_LeavesScaleUnchanged() { // Common case: a buffer within the GPU limit is untouched. - float w = RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 1920, 1080), 2.0f); + float w = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 1920, 1080), 2.0f); Assert.That(w, Is.EqualTo(2.0f)); } @@ -202,10 +203,10 @@ public void ClampBudget_SmallBuffer_LeavesScaleUnchanged() public void ClampBudget_AnisotropicOverAllocation_IsBounded() { // Anisotropic case: ceil(8640 * 4) = 34560 > 16384, must clamp so the larger axis fits. - float w = RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 960, 8640), 4.0f); + float w = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 960, 8640), 4.0f); Assert.That(w, Is.LessThan(4.0f), "anisotropic density must be clamped to fit the GPU buffer limit"); // Hard guarantee: ceil(8640 * w) must be <= the limit. - Assert.That(Math.Ceiling(8640.0 * w), Is.LessThanOrEqualTo(RenderNodeContext.MaxBufferDimension)); + Assert.That(Math.Ceiling(8640.0 * w), Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); } [Test] @@ -217,22 +218,101 @@ public void ClampBudget_IsAHardGuarantee_AcrossFractionalBounds() foreach (float w in new[] { 1.7f, 3.3f, 4.0f, 7.9f, 12.5f }) { var bounds = new Rect(0, 0, axis, axis * 0.5f); - float clamped = RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, w); + float clamped = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, w); double allocatedAxis = Math.Ceiling((double)axis * clamped); - Assert.That(allocatedAxis, Is.LessThanOrEqualTo(RenderNodeContext.MaxBufferDimension), + Assert.That(allocatedAxis, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension), $"axis={axis}, w={w}: allocated {allocatedAxis} px must fit the GPU limit exactly"); Assert.That(clamped, Is.LessThanOrEqualTo(w), "the clamp must never raise the scale"); } } } + [Test] + public void ClampBudget_LargeCoordinateSpanDoesNotOverflowPixelWidth() + { + var bounds = new Rect(-2_000_000_000f, 0, 4_000_000_000f, 1); + + float clamped = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 1); + PixelRect footprint = PixelRect.FromRect(bounds, clamped); + + Assert.Multiple(() => + { + Assert.That(clamped, Is.GreaterThan(0).And.LessThan(1)); + Assert.That(footprint.Width, Is.GreaterThanOrEqualTo(0)); + Assert.That(footprint.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void PreserveTargetSupply_UsesTheFinerAnisotropicAxis() + { + using var target = RenderTarget.CreateNull(100, 100); + using var canvas = new ImmediateCanvas(target, 1, 1, new Size(100, 100)); + using (canvas.PushTransform(Matrix.CreateScale(4, 0.25f))) + { + Assert.That(DeviceGridAlignment.ResolveLocalDensity(canvas), Is.EqualTo(4)); + } + } + + [Test] + public void PreserveTargetSupply_UsesTheMaximumSingularValueUnderShear() + { + using var target = RenderTarget.CreateNull(100, 100); + using var canvas = new ImmediateCanvas(target, 1, 1, new Size(100, 100)); + using (canvas.PushTransform(new Matrix(1, 1, 0, 1, 0, 0))) + { + float expected = MathF.Sqrt((3 + MathF.Sqrt(5)) / 2); + Assert.That(DeviceGridAlignment.ResolveLocalDensity(canvas), Is.EqualTo(expected).Within(1e-6f)); + } + } + + [Test] + public void PreserveTargetSupply_RejectsPerspectiveInsteadOfApproximatingIt() + { + using var target = RenderTarget.CreateNull(100, 100); + using var canvas = new ImmediateCanvas(target, 1, 1, new Size(100, 100)); + var perspective = new Matrix( + 1, 0, 0.01f, + 0, 1, 0, + 0, 0, 1); + using (canvas.PushTransform(perspective)) + { + Assert.That( + () => DeviceGridAlignment.ResolveLocalDensity(canvas), + Throws.TypeOf().With.Message.Contains("perspective")); + } + } + + /// + /// Zero is not a density: the working-scale policy rejects it, so a clamp that returns it turns a bounds + /// value it merely could not fit into an exception several layers away from the rectangle that caused it. + /// + [TestCase(-8f, 6f)] + [TestCase(8f, -6f)] + [TestCase(-8f, -6f)] + [TestCase(0f, 0f)] + public void ClampBudget_NeverReturnsAnUnusableDensityForADegenerateRectangle(float width, float height) + { + var bounds = new Rect(0, 0, width, height); + + Assert.Multiple(() => + { + Assert.That( + RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, 2f), + Is.GreaterThan(0f)); + Assert.That( + RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, 2f), + Is.GreaterThan(0f)); + }); + } + [Test] public void ClampBudget_NeverIncreasesScale_AndGuardsNonFinite() { // The clamp only ever reduces; a degenerate w passes through without amplification. - Assert.That(RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), 3.0f), + Assert.That(RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), 3.0f), Is.EqualTo(3.0f)); // fits => unchanged, never raised - Assert.That(RenderNodeContext.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), float.NaN), + Assert.That(RenderScaleUtilities.ClampWorkingScaleToBufferBudget(new Rect(0, 0, 100, 100), float.NaN), Is.NaN); } @@ -242,16 +322,16 @@ public void ClampBudget_PostEffectInflation_NeedsReclampAtAllocationSite() // The node-level clamp runs against pre-effect bounds, but effect inflation (blur/shadow) can // overflow the GPU limit, so Flush re-clamps against inflated bounds. var inputBounds = new Rect(0, 0, 4000, 4000); - float wAtInput = RenderNodeContext.ClampWorkingScaleToBufferBudget(inputBounds, 3.0f); + float wAtInput = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(inputBounds, 3.0f); Assert.That(wAtInput, Is.EqualTo(3.0f), "input bounds fit at w=3 — the node-level clamp is inert"); // A large blur inflates each side by 3*sigma; Flush allocates against inflated bounds. Rect inflated = inputBounds.Inflate(new Thickness(3 * 2000, 3 * 2000)); - float wAtAllocation = RenderNodeContext.ClampWorkingScaleToBufferBudget(inflated, 3.0f); + float wAtAllocation = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(inflated, 3.0f); Assert.That(wAtAllocation, Is.LessThan(wAtInput), "the re-clamp against post-inflation bounds must reduce w so the inflated buffer stays allocatable"); double largestAxis = Math.Max(inflated.Width, inflated.Height); - Assert.That(Math.Ceiling(largestAxis * wAtAllocation), Is.LessThanOrEqualTo(RenderNodeContext.MaxBufferDimension), + Assert.That(Math.Ceiling(largestAxis * wAtAllocation), Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension), "post-inflation buffer must fit the GPU dimension limit after the allocation-site re-clamp"); } @@ -265,7 +345,7 @@ public void ResolveWorkingScale_DegenerateOutputScale_DegradesToUnitOnVectorPath { // All-vector path: degenerate request scale degrades to unit (prevents zero/NaN buffers). ReadOnlySpan vectorOnly = [EffectiveScale.Unbounded, EffectiveScale.Unbounded]; - float w = RenderNodeContext.ResolveWorkingScale(vectorOnly, badScale); + float w = RenderScaleUtilities.ResolveWorkingScale(vectorOnly, badScale); Assert.That(w, Is.EqualTo(1f)); } @@ -274,7 +354,7 @@ public void ResolveWorkingScale_DegenerateOutputScale_DoesNotDragDownAConcreteSu { // With a concrete supply, the densest input still wins regardless of the sanitized output scale. ReadOnlySpan mixed = [EffectiveScale.At(2f), EffectiveScale.Unbounded]; - float w = RenderNodeContext.ResolveWorkingScale(mixed, float.NaN); + float w = RenderScaleUtilities.ResolveWorkingScale(mixed, float.NaN); Assert.That(w, Is.EqualTo(2f)); } @@ -287,7 +367,7 @@ public void ResolveWorkingScale_DegenerateOutputScale_DoesNotDragDownAConcreteSu [TestCase(0.5f, 0.5f, 0.5f)] // 0.5 proxy at 0.5 preview: floor inert public void Resolve_ConcreteSupply_IsMaxOfSupplyAndOutput(float supply, float outputScale, float expected) { - float w = RenderNodeContext.ResolveWorkingScale([EffectiveScale.At(supply)], outputScale); + float w = RenderScaleUtilities.ResolveWorkingScale([EffectiveScale.At(supply)], outputScale); Assert.That(w, Is.EqualTo(expected).Within(1e-6)); } @@ -309,48 +389,91 @@ public void AtOrUnbounded_KeepsAValidDensity() Assert.That(EffectiveScale.AtOrUnbounded(0.5f), Is.EqualTo(EffectiveScale.At(0.5f))); } - // --- RenderNodeContext sanitizes degenerate request scale at the boundary --- + // --- RenderNodeRenderer sanitizes degenerate request scale at the boundary --- [TestCase(0f)] [TestCase(-2f)] [TestCase(float.NaN)] [TestCase(float.PositiveInfinity)] - public void RenderNodeContext_SanitizesDegenerateOutputScaleToOne(float bad) + public void RenderNodeRenderer_SanitizesDegenerateOutputScaleToOne(float bad) { - var ctx = new RenderNodeContext([], outputScale: bad); - Assert.That(ctx.OutputScale, Is.EqualTo(1f)); + using var node = new ContainerRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = bad, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + Assert.That(renderer.Options.DefaultRequest.OutputScale, Is.EqualTo(1f)); } [TestCase(float.NaN)] [TestCase(0f)] [TestCase(-1f)] - public void RenderNodeContext_DegenerateMaxWorkingScale_IsTreatedAsNoCeiling(float bad) + public void RenderNodeRenderer_DegenerateMaxWorkingScale_IsTreatedAsNoCeiling(float bad) { - var ctx = new RenderNodeContext([], outputScale: 1f, maxWorkingScale: bad); - Assert.That(ctx.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + using var node = new ContainerRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = bad, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); // and it must not pull a resolved working scale to zero / NaN - float w = RenderNodeContext.ResolveWorkingScale([EffectiveScale.At(3f)], 1f, ctx.MaxWorkingScale); + float w = RenderScaleUtilities.ResolveWorkingScale( + [EffectiveScale.At(3f)], + 1f, + renderer.Options.DefaultRequest.MaxWorkingScale); Assert.That(w, Is.EqualTo(3f)); } [TestCase(float.NaN)] [TestCase(0f)] [TestCase(-1f)] - public void RenderNodeProcessor_DegenerateMaxWorkingScale_IsTreatedAsNoCeiling(float bad) + public void RenderNodeRenderer_DegenerateMaxWorkingScale_IsStableAcrossNodeKinds(float bad) { - using var node = new OperationWrapperRenderNode(); - var processor = new RenderNodeProcessor(node, false, 1f, bad); - Assert.That(processor.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); + using var node = new PassThroughRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1, + MaxWorkingScale = bad, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + Assert.That(renderer.Options.DefaultRequest.MaxWorkingScale, Is.EqualTo(float.PositiveInfinity)); } [TestCase(float.NaN)] [TestCase(0f)] [TestCase(-1f)] - public void RenderNodeProcessor_DegenerateOutputScale_DefaultsToOne(float bad) + public void RenderNodeRenderer_DegenerateOutputScale_DefaultsToOne(float bad) { - using var node = new OperationWrapperRenderNode(); - var processor = new RenderNodeProcessor(node, false, bad); - Assert.That(processor.OutputScale, Is.EqualTo(1f)); + using var node = new PassThroughRenderNode(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = bad, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + Assert.That(renderer.Options.DefaultRequest.OutputScale, Is.EqualTo(1f)); } // --- Shader device-buffer dimensions (the size SKSL/GLSL resolution uniforms must report) ------------ @@ -358,63 +481,60 @@ public void RenderNodeProcessor_DegenerateOutputScale_DefaultsToOne(float bad) [Test] public void DeviceBufferSize_MatchesCreateTargetFormula() { - // Must match CreateTarget: (int) truncation at w==1, ceil(bounds * w) at w!=1. + // Legacy custom effects size local buffers from dimensions, not from rounded global edges. Assert.That(CustomFilterEffectContext.DeviceBufferSize(new Rect(0, 0, 100.7f, 50.2f), 1f), - Is.EqualTo((100, 50)), "w == 1 truncates"); + Is.EqualTo((100, 50)), "the historical w == 1 path truncates fractional dimensions"); Assert.That(CustomFilterEffectContext.DeviceBufferSize(new Rect(0, 0, 100.0f, 50.0f), 2f), Is.EqualTo((200, 100)), "integral bounds * w stays integral"); Assert.That(CustomFilterEffectContext.DeviceBufferSize(new Rect(0, 0, 100.3f, 50.1f), 2f), Is.EqualTo((201, 101)), "fractional bounds * w ceils up"); + Assert.That(CustomFilterEffectContext.DeviceBufferSize(new Rect(10.25f, 20.25f, 8, 6), 2f), + Is.EqualTo((16, 12)), "fractional origins do not affect a local buffer's dimensions"); } // --- Flatten nodes own no buffer and re-rasterize at any scale: must report Unbounded supply --------- [Test] - public void LayerRenderNode_Process_EmitsUnboundedEffectiveScale() + public void LayerRenderNode_Measure_ReportsUnboundedEffectiveScale() { // SaveLayer flatten owns no buffer and re-rasterizes at any working scale, so it must report // Unbounded supply density; a wrongly-concrete value would inflate the upstream working scale. using var node = new LayerRenderNode(new Rect(0, 0, 100, 100)); - RenderNodeOperation[] result = node.Process(new RenderNodeContext([])); - try - { - Assert.That(result, Has.Length.EqualTo(1)); - Assert.That(result[0].EffectiveScale.IsUnbounded, Is.True, - "LayerRenderNode flattens via SaveLayer and re-rasterizes at any scale, so it emits Unbounded."); - } - finally - { - foreach (RenderNodeOperation r in result) - r.Dispose(); - } + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.True, + "LayerRenderNode replays at the eventual target density, so it reports Unbounded."); } - // --- Node-graph input boundary: EffectiveScale must survive the RefCountedProxy re-wrap --------------- + // --- Pass-through input boundary: EffectiveScale must survive request-local facade remapping ---------- [Test] - public void OperationWrapperProxy_ForwardsEffectiveScale() - { - // The proxy must forward the wrapped op's supply density verbatim. - using var node = new OperationWrapperRenderNode(); - var op = RenderNodeOperation.CreateLambda( - new Rect(0, 0, 10, 10), - render: _ => { }, - effectiveScale: EffectiveScale.At(0.5f)); - node.SetOperations([op]); - - RenderNodeOperation[] result = node.Process(new RenderNodeContext([])); - try - { - Assert.That(result, Has.Length.EqualTo(1)); - Assert.That(result[0].EffectiveScale.IsUnbounded, Is.False, - "the proxy must not collapse a concrete supply density to Unbounded"); - Assert.That(result[0].EffectiveScale.Value, Is.EqualTo(0.5f)); - } - finally - { - foreach (RenderNodeOperation r in result) - r.Dispose(); - } + public void PassThroughNode_ForwardsRecordedInputEffectiveScale() + { + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new PassThroughRenderNode(), + EffectiveScale.At(0.5f)); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "the pass-through facade must not collapse a concrete supply density to Unbounded"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(0.5f)); + } + + private sealed class PassThroughRenderNode : RenderNode + { + public override void Process(RenderNodeContext context) + => context.PassThrough(); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ShaderMigrationPhysicalFootprintTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ShaderMigrationPhysicalFootprintTests.cs new file mode 100644 index 0000000000..1f4f42d61f --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/ShaderMigrationPhysicalFootprintTests.cs @@ -0,0 +1,309 @@ +using Beutl.Composition; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.Pixel; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +[NonParallelizable] +public sealed class ShaderMigrationPhysicalFootprintTests +{ + [Test] + public void SourceLessSkslScript_UsesActualFootprintScaleAndCoversCompleteBacking() + { + const string script = + """ + uniform float width; + uniform float height; + uniform float2 iResolution; + uniform float iScale; + + half4 main(float2 fragCoord) { + if (width != 5.0 || height != 4.0 || + iResolution.x != 5.0 || iResolution.y != 4.0 || + iScale != 1.0) { + return half4(1.0, 0.0, 1.0, 1.0); + } + + return fragCoord.x >= 4.0 && fragCoord.y >= 3.0 + ? half4(1.0, 0.0, 0.0, 1.0) + : half4(0.0, 0.0, 1.0, 1.0); + } + """; + var bounds = new Rect(0.25f, 0.5f, 4, 3); + var deviceBounds = new PixelRect(-2, -1, 9, 8); + using var backing = new CpuRenderTarget(deviceBounds.Width, deviceBounds.Height); + backing.Value.Canvas.Clear(SKColors.Transparent); + backing.Value.Canvas.Flush(); + using EffectTargets targets = CreateTargets(backing, bounds, deviceBounds, scale: 2); + var effect = new SKSLScriptEffect(); + effect.Script.CurrentValue = script; + + ApplyDirect(effect, bounds, targets, workingScale: 1); + + EffectTarget actual = targets.Single(); + using Bitmap bitmap = actual.RenderTarget!.Snapshot(); + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + ushort one = BitConverter.HalfToUInt16Bits((Half)1); + ushort[] firstPixel = pixels[..4].ToArray(); + ushort[] finalPixel = pixels[^4..].ToArray(); + + Assert.Multiple(() => + { + Assert.That(actual.DeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 1))); + Assert.That(actual.Scale, Is.EqualTo(EffectiveScale.At(1))); + Assert.That(bitmap.Width, Is.EqualTo(5)); + Assert.That(bitmap.Height, Is.EqualTo(4)); + Assert.That(firstPixel, Is.EqualTo(new ushort[] { 0, 0, one, one }), + "metadata mismatches must not route through the magenta failure branch"); + Assert.That(finalPixel, Is.EqualTo(new ushort[] { one, 0, 0, one }), + "RenderToTarget must cover the final physical backing pixel"); + }); + } + + [Test] + public void SkslScript_SourceSamplingOutsideInputPreservesClampEdges() + { + const string script = + """ + uniform shader src; + + half4 main(float2 fragCoord) { + return src.eval(float2(-1.0, 0.0)); + } + """; + var bounds = new Rect(0, 0, 2, 1); + PixelRect deviceBounds = PixelRect.FromRect(bounds, 1); + using CpuRenderTarget backing = CreatePatternRenderTarget( + deviceBounds.Width, + deviceBounds.Height); + using EffectTargets targets = CreateTargets(backing, bounds, deviceBounds); + var effect = new SKSLScriptEffect(); + effect.Script.CurrentValue = script; + + ApplyDirect(effect, bounds, targets, workingScale: 1); + + EffectTarget actual = targets.Single(); + using Bitmap bitmap = actual.RenderTarget!.Snapshot(); + RgbaF16[] pixels = bitmap.GetPixelSpan().ToArray(); + + Assert.Multiple(() => + { + Assert.That(pixels, Has.Length.EqualTo(2)); + Assert.That(pixels.Select(static pixel => (float)pixel.R), Is.All.EqualTo(1).Within(0.01f)); + Assert.That(pixels.Select(static pixel => (float)pixel.G), Is.All.EqualTo(0).Within(0.01f)); + Assert.That(pixels.Select(static pixel => (float)pixel.B), Is.All.EqualTo(0).Within(0.01f)); + Assert.That(pixels.Select(static pixel => (float)pixel.A), Is.All.EqualTo(1).Within(0.01f), + "out-of-bounds script samples must repeat the nearest source edge"); + }); + } + + [Test] + public void InvertIdentity_ApronBackedInput_PreservesSemanticPixels() + { + var bounds = new Rect(1, 1, 10, 10); + PixelRect tightDeviceBounds = PixelRect.FromRect(bounds, 1); + using var tightBacking = new CpuRenderTarget( + tightDeviceBounds.Width, + tightDeviceBounds.Height); + DrawSeparatedContent(tightBacking.Value.Canvas, 0, 0); + + PixelRect apronDeviceBounds = RenderScaleUtilities.AddRasterApron(tightDeviceBounds); + using var apronBacking = new CpuRenderTarget( + apronDeviceBounds.Width, + apronDeviceBounds.Height); + DrawSeparatedContent(apronBacking.Value.Canvas, 1, 1); + + var effect = new Invert(); + effect.Amount.CurrentValue = 0; + TargetSnapshot tight = ApplyEffect(effect, bounds, tightBacking, tightDeviceBounds); + TargetSnapshot apron = ApplyEffect(effect, bounds, apronBacking, apronDeviceBounds); + + Assert.Multiple(() => + { + AssertFiniteVisiblePixels(tight.Pixels); + Assert.That(apron.Bounds, Is.EqualTo(tight.Bounds)); + Assert.That(apron.DeviceBounds, Is.EqualTo(tightDeviceBounds)); + Assert.That(apron.RasterBounds, Is.EqualTo(tight.RasterBounds)); + Assert.That(apron.Pixels.SequenceEqual(tight.Pixels), Is.True, + "an identity CurrentPixel stage must discard only the physical apron"); + }); + } + + private static void AssertFiniteVisiblePixels(ushort[] pixels) + { + Assert.That(pixels, Is.Not.Empty); + Assert.That(pixels.Length % 4, Is.Zero); + bool hasVisiblePixel = false; + for (int i = 0; i < pixels.Length; i++) + { + float channel = (float)BitConverter.UInt16BitsToHalf(pixels[i]); + Assert.That(float.IsFinite(channel), Is.True, + $"pixel channel {i} must be finite before apron parity is accepted"); + if ((i % 4) == 3 && channel > 0) + hasVisiblePixel = true; + } + + Assert.That(hasVisiblePixel, Is.True, + "the tight apron-parity fixture must retain visible source content"); + } + + [Test] + public void ColorShift_MovedSource_IsTranslationEquivalent() + { + var allocationBounds = new Rect(5.25f, 6.5f, 10, 10); + var translation = new Vector(20, 30); + PixelRect deviceBounds = PixelRect.FromRect(allocationBounds, 1); + using CpuRenderTarget backing = CreatePatternRenderTarget( + deviceBounds.Width, + deviceBounds.Height); + var effect = new ColorShift(); + effect.RedOffset.CurrentValue = new PixelPoint(-2, 1); + effect.GreenOffset.CurrentValue = new PixelPoint(1, -1); + effect.BlueOffset.CurrentValue = new PixelPoint(2, 2); + effect.AlphaOffset.CurrentValue = new PixelPoint(-1, -2); + + TargetSnapshot origin = ApplyMovedEffect( + effect, + allocationBounds, + allocationBounds, + backing, + deviceBounds); + TargetSnapshot translated = ApplyMovedEffect( + effect, + allocationBounds, + allocationBounds.Translate(translation), + backing, + deviceBounds); + + Assert.Multiple(() => + { + Assert.That(origin.Pixels, Has.Some.Not.Zero); + Assert.That(translated.Bounds, Is.EqualTo(origin.Bounds.Translate(translation))); + Assert.That(translated.RasterBounds, Is.EqualTo(origin.RasterBounds.Translate(translation))); + Assert.That(translated.Pixels.SequenceEqual(origin.Pixels), Is.True, + "mapped SKSL input coordinates must follow current RasterBounds rather than immutable DeviceBounds"); + }); + } + + private static TargetSnapshot ApplyMovedEffect( + FilterEffect effect, + Rect allocationBounds, + Rect currentBounds, + RenderTarget backing, + PixelRect deviceBounds) + { + using EffectTargets targets = CreateTargets(backing, allocationBounds, deviceBounds); + targets[0].Bounds = currentBounds; + ApplyDirect(effect, currentBounds, targets); + + return Snapshot(targets.Single()); + } + + private static TargetSnapshot ApplyEffect( + FilterEffect effect, + Rect bounds, + RenderTarget backing, + PixelRect deviceBounds) + { + using EffectTargets targets = CreateTargets(backing, bounds, deviceBounds); + ApplyDirect(effect, bounds, targets); + return Snapshot(targets.Single()); + } + + private static TargetSnapshot Snapshot(EffectTarget target) + { + using Bitmap bitmap = target.RenderTarget!.Snapshot(); + return new TargetSnapshot( + target.Bounds, + target.DeviceBounds, + target.RasterBounds, + bitmap.GetPixelSpan().ToArray()); + } + + private static void ApplyDirect( + FilterEffect effect, + Rect bounds, + EffectTargets targets, + float workingScale = 1) + { + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var context = new FilterEffectContext(bounds, outputScale: 1, workingScale); + context.ApplyTransactional(effect, resource); + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + workingScale, + maxWorkingScale: 1); + activator.Apply(context); + activator.Flush(false); + } + + private static EffectTargets CreateTargets( + RenderTarget backing, + Rect bounds, + PixelRect deviceBounds, + float scale = 1) + { + return new EffectTargets + { + new EffectTarget(backing, bounds, EffectiveScale.At(scale), deviceBounds) + { + OriginalBounds = new Rect(default, bounds.Size), + }, + }; + } + + private static CpuRenderTarget CreatePatternRenderTarget(int width, int height) + { + var renderTarget = new CpuRenderTarget(width, height); + SKCanvas canvas = renderTarget.Value.Canvas; + canvas.Clear(SKColors.Transparent); + using (var red = new SKPaint { Color = SKColors.Red }) + using (var blue = new SKPaint { Color = SKColors.Blue }) + { + canvas.DrawRect(SKRect.Create(0, 0, 1, height), red); + canvas.DrawRect(SKRect.Create(1, 0, width - 1, height), blue); + } + + canvas.Flush(); + return renderTarget; + } + + private static void DrawSeparatedContent(SKCanvas canvas, float offsetX, float offsetY) + { + canvas.Clear(SKColors.Transparent); + using var paint = new SKPaint { Color = SKColors.White }; + canvas.DrawRect(SKRect.Create(offsetX + 1, offsetY + 1, 3, 3), paint); + canvas.DrawRect(SKRect.Create(offsetX + 6, offsetY + 6, 3, 3), paint); + canvas.Flush(); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget(CreateSurface(width, height), width, height) + { + private static SKSurface CreateSurface(int width, int height) + => SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("A CPU test surface could not be created."); + } + + private readonly record struct TargetSnapshot( + Rect Bounds, + PixelRect DeviceBounds, + Rect RasterBounds, + ushort[] Pixels); +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SlotBackedHitTestTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SlotBackedHitTestTests.cs new file mode 100644 index 0000000000..9961abcb55 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SlotBackedHitTestTests.cs @@ -0,0 +1,138 @@ +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +// A hit test that has to read a resource must reach it through a slot, because the definition that +// declares the test outlives every call that binds one. These pin that the slot resolves against the +// bindings of the call being tested, not against anything the definition captured. +[TestFixture] +public sealed class SlotBackedHitTestTests +{ + private static readonly Rect s_bounds = new(0, 0, 100, 100); + + [Test] + public void OneDefinitionResolvesTheHitShapeEachCallBound() + { + var lowerLeft = new HitShape(new Rect(0, 0, 40, 40)); + var upperRight = new HitShape(new Rect(60, 60, 40, 40)); + + Assert.Multiple(() => + { + Assert.That(Hit(lowerLeft, new Point(20, 20)), Is.True, "lower-left shape at its own point"); + Assert.That(Hit(lowerLeft, new Point(80, 80)), Is.False, "lower-left shape at the other point"); + Assert.That(Hit(upperRight, new Point(80, 80)), Is.True, "upper-right shape at its own point"); + Assert.That(Hit(upperRight, new Point(20, 20)), Is.False, "upper-right shape at the other point"); + }); + } + + [Test] + public void AnUnboundSlotFailsInsteadOfSilentlyMissing() + { + var slot = new RenderResourceSlot(); + RenderHitTestContract contract = RenderHitTestContract.FromSlot( + slot, + static (shape, point) => shape.Contains(point)); + + KeyNotFoundException? exception = Assert.Throws( + () => contract.Evaluate(s_bounds, [], [], new Point(20, 20))); + + Assert.That(exception!.Message, Does.Contain("slot")); + } + + [Test] + public void TheContextOverloadSeesTheOperationBoundsAlongsideTheBoundResource() + { + var slot = new RenderResourceSlot(); + RenderHitTestContract contract = RenderHitTestContract.FromSlot( + slot, + static (shape, context, point) => context.OutputBounds.Contains(point) && shape.Contains(point)); + + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterBorrowed(new HitShape(new Rect(0, 0, 200, 200))); + registry.Commit(token); + RenderResourceBinding[] bindings = [slot.Bind(token)]; + + Assert.Multiple(() => + { + Assert.That( + contract.Evaluate(s_bounds, [], bindings, new Point(50, 50)), + Is.True, + "inside both the bounds and the shape"); + Assert.That( + contract.Evaluate(s_bounds, [], bindings, new Point(150, 150)), + Is.False, + "inside the shape but outside the operation bounds"); + }); + } + + [Test] + public void ASlotBackedTestCannotSmuggleAResourceThroughItsClosure() + { + using var registry = new RenderRequestResourceRegistry(); + RenderResource token = registry.RegisterBorrowed(new HitShape(s_bounds)); + registry.Commit(token); + var slot = new RenderResourceSlot(); + + Assert.That( + () => RenderHitTestContract.FromSlot( + slot, + (shape, point) => token is not null && shape.Contains(point)), + Throws.ArgumentException); + } + + private static bool Hit(HitShape shape, Point point) + { + using var node = new SlotHitTestNode(shape); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 1f, + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + return renderer.HitTest(point); + } + + private sealed class HitShape(Rect bounds) + { + public bool Contains(Point point) => bounds.Contains(point); + } + + private sealed class SlotHitTestNode(HitShape shape) : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + private static readonly RenderResourceSlot s_shapeSlot = new(); + + private static readonly OpaqueRenderDefinition s_definition = + OpaqueRenderDefinition.Create( + static (session, bounds) => + session.UseResource(s_fillSlot, fill => + { + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(canvas => canvas.DrawRectangle(bounds, fill, null)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(s_bounds), + RenderHitTestContract.FromSlot( + s_shapeSlot, + static (shape, point) => shape.Contains(point)), + RenderValueCardinality.Single, + RenderScaleContract.Vector, + resources: [s_fillSlot, s_shapeSlot]); + + public override void Process(RenderNodeContext context) + { + RenderResource fill = context.Borrow(Brushes.Resource.White); + RenderResource shapeResource = context.Borrow(shape); + context.Publish(context.OpaqueSource(s_definition.Call( + s_bounds, + [s_fillSlot.Bind(fill), s_shapeSlot.Bind(shapeResource)]))); + } + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs index 61cb14eeb3..47037919ca 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs @@ -3,6 +3,7 @@ using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; using Beutl.Graphics.Transformation; using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; @@ -11,17 +12,15 @@ namespace Beutl.UnitTests.Engine.Graphics.Rendering; -// Integration tests: concrete At(d) source through a real Custom effect, asserting w = max(s_out, supply). +// Integration tests: concrete At(d) sources through real recorded nodes, asserting +// w = min(max(s_out, supply), maxWorkingScale) at the resulting materialization boundary. [NonParallelizable] [TestFixture] public class SourceEffectiveScaleFlowTests { - private static RenderNodeOperation SourceOp(float density) - => RenderNodeOperation.CreateLambda( - new Rect(0, 0, 120, 90), - canvas => canvas.DrawRectangle(new Rect(0, 0, 120, 90), Brushes.Resource.White, null), - hitTest: _ => false, - effectiveScale: EffectiveScale.At(density)); + private static int s_throwingWorkingScaleResolverCalls; + private static float s_legacyCustomWorkingScale; + private static List? s_scaleResolverObservations; private static FilterEffectRenderNode MosaicNode() { @@ -30,553 +29,2328 @@ private static FilterEffectRenderNode MosaicNode() return new FilterEffectRenderNode(mosaic.ToResource(CompositionContext.Default)); } - [TestCase(0.5f, 1.0f)] // sub-output supply floored to the deliverable 1.0 + [TestCase(0.5f, 1.0f)] [TestCase(1.0f, 1.0f)] - [TestCase(2.0f, 2.0f)] // high-density source stays 2.0 (supply wins) + [TestCase(2.0f, 2.0f)] public void ConcreteAtSource_ResolvesWorkingScaleToSupplyOrOutputFloor(float density, float expectedW) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - using FilterEffectRenderNode node = MosaicNode(); - var context = new RenderNodeContext([SourceOp(density)], outputScale: 1.0f); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty, "the effect dropped the input op"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), - $"At({density}) source resolved the wrong working scale"); - - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, - "a concrete source density was lost (treated as re-rasterizable vector)"); - - foreach (RenderNodeOperation op in ops) - { - op.Dispose(); - } - }); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + MosaicNode(), + EffectiveScale.At(density)); + + Assert.That(measurement.HasFragments, Is.True, "the effect dropped the input fragment"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + $"At({density}) source resolved the wrong working scale"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "a concrete source density was lost (treated as re-rasterizable vector)"); } - // Output scale is not a ceiling: a 2.0 source at 0.5 output still flows at 2.0. [Test] public void HighDensitySource_NotClampedByReducedOutputScale() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - using FilterEffectRenderNode node = MosaicNode(); - var context = new RenderNodeContext([SourceOp(2.0f)], outputScale: 0.5f); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(2.0f).Within(1e-4), - "a 2.0 source was clamped down by the 0.5 output scale — s_out must not cap an intermediate"); - - DisposeAll(ops); - }); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + MosaicNode(), + EffectiveScale.At(2), + outputScale: 0.5f); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(2).Within(1e-4), + "a 2.0 source was clamped down by the 0.5 output scale — s_out must not cap an intermediate"); } - // w = max(s_out, supply) at supersample outputs. - [TestCase(0.5f, 2.0f, 2.0f)] // sub-output proxy floored to 2.0 - [TestCase(1.0f, 2.0f, 2.0f)] // 1:1 source floored to 2.0 - [TestCase(2.0f, 2.0f, 2.0f)] // supply matches output - [TestCase(2.0f, 4.0f, 4.0f)] // supply below output: floored to 4.0 - [TestCase(2.0f, 1.5f, 2.0f)] // supply above output: supply wins - public void ConcreteAtSource_AtSupersampleOutput_ResolvesMaxOfSupplyAndOutput(float density, float outputScale, float expectedW) + [TestCase(0.5f, 2.0f, 2.0f)] + [TestCase(1.0f, 2.0f, 2.0f)] + [TestCase(2.0f, 2.0f, 2.0f)] + [TestCase(2.0f, 4.0f, 4.0f)] + [TestCase(2.0f, 1.5f, 2.0f)] + public void ConcreteAtSource_AtSupersampleOutput_ResolvesMaxOfSupplyAndOutput( + float density, + float outputScale, + float expectedW) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + MosaicNode(), + EffectiveScale.At(density), + outputScale); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + $"At({density}) @ outputScale {outputScale} resolved the wrong working scale"); + if (expectedW != 1) { - using FilterEffectRenderNode node = MosaicNode(); - var context = new RenderNodeContext([SourceOp(density)], outputScale: outputScale); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), - $"At({density}) @ outputScale {outputScale} resolved the wrong working scale"); - if (expectedW != 1.0f) - { - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, - "a non-unit source density was lost at supersample output"); - } - - DisposeAll(ops); - }); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "a non-unit source density was lost at supersample output"); + } } - // MaxWorkingScale caps the resolved working scale through the real node. - [TestCase(float.PositiveInfinity, 4.0f)] // export: no ceiling - [TestCase(2.0f, 2.0f)] // preview: ceiling caps 4.0 source to 2.0 + [TestCase(float.PositiveInfinity, 4.0f)] + [TestCase(2.0f, 2.0f)] public void MaxWorkingScale_CapsThroughTheNode(float maxWorkingScale, float expectedW) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - using FilterEffectRenderNode node = MosaicNode(); - var context = new RenderNodeContext([SourceOp(4.0f)], outputScale: 1.0f, maxWorkingScale: maxWorkingScale); - - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), - $"maxWorkingScale {maxWorkingScale} did not cap the working scale through the node"); - - DisposeAll(ops); - }); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + MosaicNode(), + EffectiveScale.At(4), + outputScale: 1, + maxWorkingScale); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + $"maxWorkingScale {maxWorkingScale} did not cap the working scale through the node"); } - // At(1) and Unbounded must render identically at w == 1. [Test] public void At1Source_IsByteIdenticalToUnbounded_AtOutputScale1() { VulkanTestEnvironment.EnsureAvailable(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - using Bitmap at1 = RenderThroughMosaicAtScale1(EffectiveScale.At(1f)); + using Bitmap at1 = RenderThroughMosaicAtScale1(EffectiveScale.At(1)); using Bitmap unbounded = RenderThroughMosaicAtScale1(EffectiveScale.Unbounded); - ReadOnlySpan a = at1.GetPixelSpan(); - ReadOnlySpan b = unbounded.GetPixelSpan(); - Assert.That(a.SequenceEqual(b), Is.True, + Assert.That(at1.GetPixelSpan().SequenceEqual(unbounded.GetPixelSpan()), Is.True, "At(1) source diverged from Unbounded at s_out == 1"); }); } - private static Bitmap RenderThroughMosaicAtScale1(EffectiveScale srcScale) + private static Bitmap RenderThroughMosaicAtScale1(EffectiveScale sourceScale) { - var srcOp = RenderNodeOperation.CreateLambda( - new Rect(0, 0, 120, 90), - canvas => canvas.DrawRectangle(new Rect(0, 0, 120, 90), Brushes.Resource.White, null), - hitTest: _ => false, - onDispose: null, - effectiveScale: srcScale); - - using FilterEffectRenderNode node = MosaicNode(); - RenderNodeOperation[] ops = node.Process(new RenderNodeContext([srcOp], outputScale: 1f)); - + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(sourceScale), + MosaicNode()); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); using RenderTarget target = RenderTarget.Create(120, 90)!; - using (var canvas = new ImmediateCanvas(target, 1f)) + using (var canvas = new ImmediateCanvas(target, 1)) { canvas.Clear(Colors.Black); - foreach (RenderNodeOperation op in ops) - { - op.Render(canvas); - op.Dispose(); - } + renderer.Render(canvas); } return target.Snapshot(); } - // TransformRenderNode rescales a bitmap child's density by the inverse transform scale. - [TestCase(0.5f, 2.0f, 4.0f)] // shrink 0.5x: At(2) -> At(4) - [TestCase(2.0f, 2.0f, 1.0f)] // enlarge 2x: At(2) -> At(1) - [TestCase(1.0f, 2.0f, 2.0f)] // identity: unchanged - [TestCase(0.25f, 1.0f, 4.0f)] // shrink 0.25x: At(1) -> At(4) + [TestCase(0.5f, 2.0f, 4.0f)] + [TestCase(2.0f, 2.0f, 1.0f)] + [TestCase(1.0f, 2.0f, 2.0f)] + [TestCase(0.25f, 1.0f, 4.0f)] public void TransformRenderNode_ScalesChildDensity_ByInverseScale(float scale, float density, float expected) { - var transform = new TransformRenderNode(Matrix.CreateScale(scale, scale), TransformOperator.Prepend); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new TransformRenderNode(Matrix.CreateScale(scale, scale), TransformOperator.Prepend), + EffectiveScale.At(density)); - RenderNodeOperation[] ops = transform.Process(new RenderNodeContext([SourceOp(density)])); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expected).Within(1e-4), + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expected).Within(1e-4), $"Scale({scale}) on At({density}) must resolve to At({expected}) (density = px / logical unit)"); - DisposeAll(ops); } - // An anisotropic transform projects onto the densest axis (smallest scale factor). [Test] public void TransformRenderNode_AnisotropicScale_TakesDensestAxis() { - var transform = new TransformRenderNode(Matrix.CreateScale(0.5f, 0.25f), TransformOperator.Prepend); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new TransformRenderNode(Matrix.CreateScale(0.5f, 0.25f), TransformOperator.Prepend), + EffectiveScale.At(1)); - RenderNodeOperation[] ops = transform.Process(new RenderNodeContext([SourceOp(1.0f)])); - // min(0.5, 0.25) = 0.25 -> At(1 / 0.25) = At(4) - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(4.0f).Within(1e-4), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(4).Within(1e-4), "an anisotropic transform must project to the densest (most-shrunk) axis"); - DisposeAll(ops); } - // Pure rotation leaves density unchanged. [Test] public void TransformRenderNode_PureRotation_LeavesDensityUnchanged() { - var transform = new TransformRenderNode(Matrix.CreateRotation(MathF.PI / 4f), TransformOperator.Prepend); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new TransformRenderNode(Matrix.CreateRotation(MathF.PI / 4), TransformOperator.Prepend), + EffectiveScale.At(2)); - RenderNodeOperation[] ops = transform.Process(new RenderNodeContext([SourceOp(2.0f)])); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(2.0f).Within(1e-4), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(2).Within(1e-4), "a pure rotation must not change the supply density"); - DisposeAll(ops); } - // A degenerate transform (zero / non-finite scale) must not corrupt the density. [Test] public void TransformRenderNode_DegenerateScale_LeavesDensityUnchanged() { - // Zero scale: singular matrix, density unchanged. - var zero = new TransformRenderNode(Matrix.CreateScale(0f, 0f), TransformOperator.Prepend); - RenderNodeOperation[] z = zero.Process(new RenderNodeContext([SourceOp(2.0f)])); - Assert.That(z[0].EffectiveScale.Value, Is.EqualTo(2.0f).Within(1e-4)); - Assert.That(float.IsFinite(z[0].EffectiveScale.Value), Is.True); - DisposeAll(z); - - // Non-finite scale: density unchanged, never At(0). - var inf = new TransformRenderNode( - Matrix.CreateScale(float.PositiveInfinity, float.PositiveInfinity), TransformOperator.Prepend); - RenderNodeOperation[] f = inf.Process(new RenderNodeContext([SourceOp(2.0f)])); - Assert.That(f[0].EffectiveScale.Value, Is.EqualTo(2.0f).Within(1e-4), + EffectiveScale zero = TransformRenderNode.RescaleDensity( + EffectiveScale.At(2), + Matrix.CreateScale(0, 0)); + Assert.That(zero.Value, Is.EqualTo(2).Within(1e-4)); + Assert.That(float.IsFinite(zero.Value), Is.True); + + EffectiveScale infinite = TransformRenderNode.RescaleDensity( + EffectiveScale.At(2), + Matrix.CreateScale(float.PositiveInfinity, float.PositiveInfinity)); + Assert.That(infinite.Value, Is.EqualTo(2).Within(1e-4), "an infinite transform scale must not collapse the density to At(0)"); - DisposeAll(f); } - // Vector content stays Unbounded through a transform. [Test] public void TransformRenderNode_VectorChild_StaysUnbounded() { - var transform = new TransformRenderNode(Matrix.CreateScale(0.5f, 0.5f), TransformOperator.Prepend); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new TransformRenderNode(Matrix.CreateScale(0.5f, 0.5f), TransformOperator.Prepend), + EffectiveScale.Unbounded); - var vectorOp = RenderNodeOperation.CreateLambda(new Rect(0, 0, 10, 10), _ => { }, _ => false); - RenderNodeOperation[] ops = transform.Process(new RenderNodeContext([vectorOp])); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.True, "a vector child must stay Unbounded through a transform"); - DisposeAll(ops); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.True, + "a vector child must stay Unbounded through a transform"); } - // CustomTransformRenderNode must rescale density identically to TransformRenderNode. [Test] public void CustomTransformRenderNode_ScalesChildDensity_LikeTransformRenderNode() { - Transform.Resource scale = new ScaleTransform(50, 50).ToResource(CompositionContext.Default); // 0.5x both axes - using var node = new DrawableGroup.CustomTransformRenderNode( - scale, default, new Size(120, 90), AlignmentX.Left, AlignmentY.Top, - new MemoryNode(new Rect(0, 0, 120, 90))); - - RenderNodeOperation[] ops = node.Process(new RenderNodeContext([SourceOp(2.0f)])); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, + Transform.Resource scale = new ScaleTransform(50, 50).ToResource(CompositionContext.Default); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new DrawableGroup.CustomTransformRenderNode( + scale, + default, + new Size(120, 90), + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(new Rect(0, 0, 120, 90))), + EffectiveScale.At(2)); + + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, "the group/decorator transform dropped a concrete density to Unbounded"); - // 0.5x shrink doubles density: At(2) -> At(4) - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(4.0f).Within(1e-4), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(4).Within(1e-4), "CustomTransformRenderNode must rescale density by the inverse transform scale, like TransformRenderNode"); - DisposeAll(ops); } - // Vector child stays Unbounded through the element wrapper too. [Test] public void CustomTransformRenderNode_VectorChild_StaysUnbounded() { Transform.Resource scale = new ScaleTransform(50, 50).ToResource(CompositionContext.Default); - using var node = new DrawableGroup.CustomTransformRenderNode( - scale, default, new Size(10, 10), AlignmentX.Left, AlignmentY.Top, - new MemoryNode(new Rect(0, 0, 10, 10))); - - var vectorOp = RenderNodeOperation.CreateLambda(new Rect(0, 0, 10, 10), _ => { }, _ => false); - RenderNodeOperation[] ops = node.Process(new RenderNodeContext([vectorOp])); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.True, + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new DrawableGroup.CustomTransformRenderNode( + scale, + default, + new Size(10, 10), + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(new Rect(0, 0, 10, 10))), + EffectiveScale.Unbounded); + + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.True, "a vector child must stay Unbounded through the group/decorator transform"); - DisposeAll(ops); } - // A Custom+Skia chain (e.g. [Mosaic, Blur]) must report concrete At(w), not Unbounded. + [Test] + public void CustomTransformRenderNode_TargetScopeChild_ReplaysInsideTransform() + { + var domain = new Rect(0, 0, 120, 90); + var clip = new RectClipRenderNode(domain, ClipOperation.Intersect); + clip.AddChild(ScaleRecordingTestHelper.Source(EffectiveScale.At(1), domain)); + using var transform = new DrawableGroup.CustomTransformRenderNode( + null, + default, + domain.Size, + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(domain)); + transform.AddChild(clip); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure( + transform, + targetDomain: domain); + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.HasTargetEffects, Is.True); + }); + } + + [Test] + public void CustomTransformRenderNode_FullTargetLayerChild_ReplaysInsideTransform() + { + var domain = new Rect(0, 0, 120, 90); + using Transform.Resource translation = new TranslateTransform(10, 0) + .ToResource(CompositionContext.Default); + var layer = new LayerRenderNode(default); + layer.AddChild(new ClearRenderNode(Colors.White)); + using var transform = new DrawableGroup.CustomTransformRenderNode( + translation, + default, + domain.Size, + AlignmentX.Left, + AlignmentY.Top, + new MemoryNode(domain)); + transform.AddChild(layer); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure( + transform, + targetDomain: domain); + + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: domain, + cachePolicy: RenderCacheOptions.Disabled)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(transform); + using (CompiledRenderRequest compiled = new RenderRequestCompiler().Compile(request, graph)) + { + IReadOnlyDictionary references = graph.Fragments + .ToDictionary( + static fragment => fragment.Id, + static fragment => (RenderFragmentReference)fragment.Payload!); + RenderFragmentReference root = references[graph.PublicationRoots.Single()]; + RenderFragmentReference targetLayer = root.Inputs.Single(); + TargetScopePlan transformedScope = compiled.TargetDependencies.Scopes.Single( + scope => scope.OwnerFragmentId == root.Id); + TargetScopePlan targetLayerScope = compiled.TargetDependencies.Scopes.Single( + scope => scope.OwnerFragmentId == targetLayer.Id); + + Assert.Multiple(() => + { + Assert.That(root.Kind, Is.EqualTo(RenderFragmentKind.TargetScope)); + Assert.That( + ((TargetScopeRenderFragmentPayload)root.Payload!).Description.IsValueReplayMap, + Is.True); + Assert.That(root.CanBeUsedAsValueInput, Is.False); + Assert.That(root.ContributesValuesToTarget, Is.False); + Assert.That(targetLayer.Kind, Is.EqualTo(RenderFragmentKind.TargetLayerScope)); + Assert.That( + references.Values.Any(static reference => + reference.Kind is RenderFragmentKind.Layer or RenderFragmentKind.OpaqueMap), + Is.False); + Assert.That(targetLayerScope.ParentId, Is.EqualTo(transformedScope.Id)); + Assert.That(targetLayerScope.ResolvedDomain, Is.EqualTo(new Rect(-10, 0, 120, 90))); + }); + } + + Assert.Multiple(() => + { + Assert.That(measurement.OutputBounds, Is.EqualTo(domain)); + Assert.That(measurement.HasContributingValues, Is.False); + Assert.That(measurement.HasTargetEffects, Is.True); + }); + + using var renderer = new RenderNodeRenderer( + transform, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = domain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap!; + Span row = bitmap.GetRow((int)domain.Height / 2); + float leftAlpha = (float)BitConverter.UInt16BitsToHalf(row[3]); + float rightAlpha = (float)BitConverter.UInt16BitsToHalf(row[(((int)domain.Width - 1) * 4) + 3]); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(domain)); + Assert.That(leftAlpha, Is.GreaterThan(0.99f)); + Assert.That(rightAlpha, Is.GreaterThan(0.99f)); + }); + } + [TestCase(0.5f, 1.0f)] [TestCase(1.0f, 1.0f)] [TestCase(2.0f, 2.0f)] public void CustomThenSkiaChain_ReportsConcreteDensity_NotUnbounded(float density, float expectedW) { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - var group = new FilterEffectGroup(); - var mosaic = new MosaicEffect(); - mosaic.TileSize.CurrentValue = new Size(10, 10); - var blur = new Blur(); - blur.Sigma.CurrentValue = new Size(3, 3); - group.Children.Add(mosaic); - group.Children.Add(blur); + var group = new FilterEffectGroup(); + var mosaic = new MosaicEffect(); + mosaic.TileSize.CurrentValue = new Size(10, 10); + var blur = new Blur(); + blur.Sigma.CurrentValue = new Size(3, 3); + group.Children.Add(mosaic); + group.Children.Add(blur); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new FilterEffectRenderNode(group.ToResource(CompositionContext.Default)), + EffectiveScale.At(density)); + + Assert.That(measurement.HasFragments, Is.True, "the [Mosaic, Blur] chain dropped the input fragment"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "a flushed At(w) buffer behind a trailing Skia filter was over-reported as re-rasterizable Unbounded"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + $"the [Mosaic, Blur] chain resolved the wrong working scale for At({density})"); + } - using var node = new FilterEffectRenderNode(group.ToResource(CompositionContext.Default)); - var context = new RenderNodeContext([SourceOp(density)], outputScale: 1.0f); + [Test] + public void StrokeEffect_OverBudgetBounds_ClampsWorkingScaleBelowNominal_DoesNotThrow() + { + var pen = new Pen(); + pen.Thickness.CurrentValue = 8; + pen.Brush.CurrentValue = Brushes.Red; + var stroke = new StrokeEffect(); + stroke.Pen.CurrentValue = pen; + stroke.Offset.CurrentValue = new Point(20000, 0); + + RenderNodeMeasurement measurement = default; + Assert.DoesNotThrow(() => measurement = ScaleRecordingTestHelper.MeasureThrough( + new FilterEffectRenderNode(stroke.ToResource(CompositionContext.Default)), + EffectiveScale.At(1))); + + Assert.That(measurement.HasFragments, Is.True, "the over-budget StrokeEffect dropped its fragment"); + Assert.That(measurement.EffectiveScale.Value, Is.GreaterThan(0)); + Assert.That(measurement.EffectiveScale.Value, Is.LessThan(1), + "the over-budget stroke bounds did not clamp the working scale below the nominal 1.0"); + } - RenderNodeOperation[] ops = node.Process(context); + private sealed class ClampToOutputRenderNode(FilterEffect.Resource effect) : FilterEffectRenderNode(effect) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => MathF.Min( + RenderScaleUtilities.ResolveWorkingScale( + metadata.InputSupplies.ToArray(), + metadata.OutputScale, + metadata.MaxWorkingScale), + metadata.OutputScale)); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; + } - Assert.That(ops, Is.Not.Empty, "the [Mosaic, Blur] chain dropped the input op"); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, - "a flushed At(w) buffer behind a trailing Skia filter was over-reported as re-rasterizable Unbounded"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), - $"the [Mosaic, Blur] chain resolved the wrong working scale for At({density})"); + [TestCase(1.0f, 1.0f)] + [TestCase(0.5f, 0.5f)] + public void CustomRenderNode_OverridesSupplyDriven_WithClampToOutput(float outputScale, float expectedW) + { + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new ClampToOutputRenderNode(new MosaicEffect().ToResource(CompositionContext.Default)), + EffectiveScale.At(2), + outputScale); - DisposeAll(ops); - }); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + "the custom render node did not apply its clamp-to-output working scale — the CreateRenderNode() escape hatch is broken"); } - // An over-budget StrokeEffect (bounds exceed GPU per-axis limit) must clamp working scale down. [Test] - public void StrokeEffect_OverBudgetBounds_ClampsWorkingScaleBelowNominal_DoesNotThrow() + public void CustomRenderNode_NoOpEffect_PassesInputThroughWithoutApplyingScaleContract() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new ClampToOutputRenderNode(new FilterEffectGroup().ToResource(CompositionContext.Default)), + EffectiveScale.At(2)); + + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2)), + "an effect that recorded no items must pass through the original input, not its custom working-scale map"); + } + + [Test] + public void CustomWorkingScale_CanResolveBelowOutputScale() + { + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 0.5f), + EffectiveScale.At(1), + outputScale: 1); + + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(0.5f)), + "an explicit Custom contract must not inherit the default output-scale floor"); + } + + [Test] + public void CustomWorkingScalePolicy_InvokesResolverOncePerBranchWithBranchBounds() + { + Rect firstInputBounds = new(2, 3, 20, 10); + Rect secondInputBounds = new(100, 200, 8, 6); + Rect firstBufferBounds = new(1, 2, 22, 12); + Rect secondBufferBounds = new(99, 199, 10, 8); + s_scaleResolverObservations = []; + try { - var pen = new Pen(); - pen.Thickness.CurrentValue = 8; - pen.Brush.CurrentValue = Brushes.Red; - var stroke = new StrokeEffect(); - stroke.Pen.CurrentValue = pen; - // Inflate X past the 16384 per-axis limit at w=1, keeping Y short (allocatable). - stroke.Offset.CurrentValue = new Point(20000, 0); + var policy = new FilterEffectWorkingScalePolicy(RenderScaleContract.Custom( + ObserveBranchWorkingScale)); - using var node = new FilterEffectRenderNode(stroke.ToResource(CompositionContext.Default)); + EffectiveScale resolved = policy.Resolve( + [EffectiveScale.At(0.5f), EffectiveScale.At(2)], + [firstInputBounds, secondInputBounds], + [firstBufferBounds, secondBufferBounds], + outputScale: 1, + maxWorkingScale: 4); - RenderNodeOperation[] ops = null!; - Assert.DoesNotThrow(() => + Assert.Multiple(() => { - ops = node.Process(new RenderNodeContext([SourceOp(1f)], outputScale: 1f)); + Assert.That(resolved, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(s_scaleResolverObservations, Has.Count.EqualTo(2)); + Assert.That( + s_scaleResolverObservations.Select(static item => item.InputSupplies), + Is.All.Matches>(static supplies => supplies.Count == 1)); + Assert.That( + s_scaleResolverObservations.Select(static item => item.InputSupplies.Single()), + Is.EqualTo(new[] { EffectiveScale.At(0.5f), EffectiveScale.At(2) })); + Assert.That( + s_scaleResolverObservations.Select(static item => item.OutputBounds), + Is.EqualTo(new[] { firstInputBounds, secondInputBounds })); }); + } + finally + { + s_scaleResolverObservations = null; + } + } - Assert.That(ops, Is.Not.Empty, "the over-budget StrokeEffect dropped its op"); - Assert.That(ops[0].EffectiveScale.Value, Is.GreaterThan(0f)); - Assert.That(ops[0].EffectiveScale.Value, Is.LessThan(1f), - "the over-budget stroke bounds did not clamp the working scale below the nominal 1.0"); - - DisposeAll(ops); + [Test] + public void LegacyBufferBudgets_UseLocalOriginsAndIncludeIntermediateMaterializations() + { + Rect inputBounds = new(100, 20, 100, 10); + var policy = new FilterEffectWorkingScalePolicy(RenderScaleContract.Custom( + static _ => 2)); + + using var localOriginContext = new FilterEffectContext(inputBounds); + localOriginContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds.WithWidth(bounds.Width + (bounds.X == 0 ? 20_000 : 0))); + Rect[] localOriginFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [inputBounds], + localOriginContext.GetOrderedItems(), + localOriginContext.Bounds); + + using var intermediateContext = new FilterEffectContext(inputBounds); + intermediateContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds.WithWidth(20_000)); + intermediateContext.CustomEffect( + 0, + static (_, _) => { }, + static (_, bounds) => bounds.WithWidth(100)); + Rect[] intermediateFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [inputBounds], + intermediateContext.GetOrderedItems(), + intermediateContext.Bounds); + + Rect firstBounds = new(0, 0, 100, 10); + Rect secondBounds = new(20_000, 0, 100, 10); + Rect combinedBounds = firstBounds.Union(secondBounds); + using var combinedContext = new FilterEffectContext(combinedBounds); + combinedContext.CustomEffect( + 0, + static (_, _) => { }, + static (_, bounds) => bounds); + combinedContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds); + Rect[] combinedFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [firstBounds, secondBounds], + combinedContext.GetOrderedItems(), + combinedContext.Bounds); + + Rect nonlinearFirstBounds = new(0, 0, 100, 10); + Rect nonlinearSecondBounds = new(100, 0, 100, 10); + using var nonlinearContext = new FilterEffectContext( + nonlinearFirstBounds.Union(nonlinearSecondBounds)); + nonlinearContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds.WithWidth(bounds.Width + bounds.X)); + nonlinearContext.CustomEffect( + 0, + static (_, _) => { }, + static (_, bounds) => bounds); + Rect[] nonlinearFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [nonlinearFirstBounds, nonlinearSecondBounds], + nonlinearContext.GetOrderedItems(), + nonlinearContext.Bounds); + + Rect largeInputBounds = new(50, 10, 20_000, 10); + using var immediateCustomContext = new FilterEffectContext(largeInputBounds); + immediateCustomContext.CustomEffect( + 0, + static (_, _) => { }, + static (_, bounds) => bounds.WithWidth(100)); + Rect[] immediateCustomFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [largeInputBounds], + immediateCustomContext.GetOrderedItems(), + immediateCustomContext.Bounds); + + Rect fractionalInputBounds = new(100, 0, 8_192, 10); + using var fractionalOriginContext = new FilterEffectContext(fractionalInputBounds); + fractionalOriginContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds.X == 0 ? bounds.WithX(0.25f) : bounds); + Rect[] fractionalOriginFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [fractionalInputBounds], + fractionalOriginContext.GetOrderedItems(), + fractionalOriginContext.Bounds); + EffectiveScale fractionalOriginScale = policy.Resolve( + [EffectiveScale.At(1)], + [fractionalInputBounds], + fractionalOriginFootprints, + outputScale: 1, + maxWorkingScale: 4); + + Rect retainedBackingInputBounds = new(0, 0, RenderScaleUtilities.MaxBufferDimension, 1); + Rect movedSemanticBounds = new(0.5f, 0, 1, 1); + using var retainedBackingContext = new FilterEffectContext(retainedBackingInputBounds); + retainedBackingContext.CustomEffect( + 0, + static (_, _) => { }, + (_, _) => movedSemanticBounds); + Rect[] retainedBackingFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [retainedBackingInputBounds], + retainedBackingContext.GetOrderedItems(), + retainedBackingContext.Bounds); + var unitScalePolicy = new FilterEffectWorkingScalePolicy(RenderScaleContract.Custom( + static _ => 1)); + EffectiveScale retainedBackingScale = unitScalePolicy.Resolve( + [EffectiveScale.At(1)], + [retainedBackingInputBounds], + retainedBackingFootprints, + outputScale: 1, + maxWorkingScale: 4); + + Rect retainedThenInflatedInputBounds = new( + 0, + 0, + RenderScaleUtilities.MaxBufferDimension - 4, + 1); + Rect shrunkenSemanticBounds = new(0, 0, 1, 1); + using var retainedThenInflatedContext = new FilterEffectContext(retainedThenInflatedInputBounds); + retainedThenInflatedContext.CustomEffect( + 0, + static (_, _) => { }, + (_, _) => shrunkenSemanticBounds); + retainedThenInflatedContext.AppendSkiaFilter( + 0, + static (_, input, _) => input, + static (_, bounds) => bounds.Inflate(new Thickness(3))); + Rect[] retainedThenInflatedFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [retainedThenInflatedInputBounds], + retainedThenInflatedContext.GetOrderedItems(), + retainedThenInflatedContext.Bounds); + EffectiveScale retainedThenInflatedScale = unitScalePolicy.Resolve( + [EffectiveScale.At(1)], + [retainedThenInflatedInputBounds], + retainedThenInflatedFootprints, + outputScale: 1, + maxWorkingScale: 4); + + Rect negativeFractionalInputBounds = new( + -0.25f, + 0, + RenderScaleUtilities.MaxBufferDimension, + 1); + Rect integerMovedSemanticBounds = new(0, 0, 1, 1); + using var negativeFractionalContext = new FilterEffectContext(negativeFractionalInputBounds); + negativeFractionalContext.CustomEffect( + 0, + static (_, _) => { }, + (_, _) => integerMovedSemanticBounds); + Rect[] negativeFractionalFootprints = FilterEffectWorkingScalePolicy.CalculateLegacyBufferBounds( + [negativeFractionalInputBounds], + negativeFractionalContext.GetOrderedItems(), + negativeFractionalContext.Bounds); + EffectiveScale negativeFractionalScale = unitScalePolicy.Resolve( + [EffectiveScale.At(1)], + [negativeFractionalInputBounds], + negativeFractionalFootprints, + outputScale: 1, + maxWorkingScale: 4); + PixelRect negativeFractionalRaster = PixelRect.FromRect(negativeFractionalInputBounds, 1); + float negativeFractionalOffsetX = negativeFractionalRaster.X - negativeFractionalInputBounds.X; + + EffectiveScale localOriginScale = policy.Resolve( + [EffectiveScale.At(1)], + [inputBounds], + localOriginFootprints, + outputScale: 1, + maxWorkingScale: 4); + EffectiveScale intermediateScale = policy.Resolve( + [EffectiveScale.At(1)], + [inputBounds], + intermediateFootprints, + outputScale: 1, + maxWorkingScale: 4); + + Assert.Multiple(() => + { + Assert.That(localOriginFootprints.Max(static bounds => bounds.Width), Is.EqualTo(20_100)); + Assert.That(localOriginScale.Value, Is.LessThan(1), + "the runtime local-origin footprint must participate in the device-axis clamp"); + Assert.That(intermediateFootprints.Max(static bounds => bounds.Width), Is.EqualTo(20_000)); + Assert.That(intermediateFootprints, Has.Some.Matches(static bounds => bounds.Width == 100)); + Assert.That(intermediateScale.Value, Is.LessThan(1), + "a large pre-Custom Flush footprint must not be lost when the final bounds shrink"); + Assert.That(combinedFootprints.Max(static bounds => bounds.Width), Is.EqualTo(20_100), + "an arbitrary Custom operation may combine branches, so later footprints must use aggregate bounds"); + Assert.That(nonlinearFootprints.Max(static bounds => bounds.Width), Is.EqualTo(300), + "Custom collapse must union transformed branch results, not transform the original sparse union once"); + Assert.That(immediateCustomFootprints.Max(static bounds => bounds.Width), Is.EqualTo(20_000), + "Custom performs a forced pre-callback Flush even without pending Skia work"); + Assert.That(fractionalOriginFootprints, Has.Some.Matches(static bounds => bounds.X == 0.25)); + Assert.That(fractionalOriginScale.Value, Is.LessThan(2), + "a transformed fractional allocation origin can add one device pixel at the axis limit"); + Assert.That(retainedBackingFootprints, Has.Some.Matches(bounds => + bounds.Position == movedSemanticBounds.Position + && bounds.Width == RenderScaleUtilities.MaxBufferDimension), + "Custom may retain an input backing while moving or shrinking only its semantic bounds"); + Assert.That(retainedBackingScale.Value, Is.LessThan(1), + "a retained axis-limit backing moved to a fractional origin must reserve its extra device pixel"); + Assert.That(retainedThenInflatedFootprints, Has.Some.Matches(bounds => + bounds.Width == RenderScaleUtilities.MaxBufferDimension + 2), + "a Skia operation after Custom must transform the retained physical backing, not only semantics"); + Assert.That(retainedThenInflatedScale.Value, Is.LessThan(1), + "the transformed retained backing must participate in the device-axis clamp"); + Assert.That(negativeFractionalFootprints, Has.Some.Matches(bounds => + bounds.X == integerMovedSemanticBounds.X + negativeFractionalOffsetX + && bounds.Width == negativeFractionalRaster.Width), + "reanchoring a retained backing must preserve its raster-to-semantic origin offset"); + Assert.That(negativeFractionalScale.Value, Is.LessThan(1), + "a retained fractional raster offset can add one pixel after Custom repositions semantics"); }); } - // Escape hatch: a FilterEffectRenderNode subclass that overrides Process to use a non-supply w. - private sealed class ClampToOutputRenderNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) + [Test] + public void VectorWorkingScale_FallsBackToOutputScaleWhenEveryBranchIsUnbounded() { - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - var scales = context.Input.Select(i => i.EffectiveScale).ToArray(); - float supplyW = RenderNodeContext.ResolveWorkingScale(scales, context.OutputScale, context.MaxWorkingScale); - float clampedW = MathF.Min(supplyW, context.OutputScale); - return context.Input.Select(input => RenderNodeOperation.CreateLambda( - input.Bounds, - input.Render, - hitTest: input.HitTest, - onDispose: input.Dispose, - effectiveScale: EffectiveScale.At(clampedW))) - .ToArray(); - } + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + new VectorWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default)), + EffectiveScale.Unbounded, + outputScale: 1.5f); + + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(1.5f))); } - // Custom render node overrides supply-driven w with clamp-to-output. - [TestCase(1.0f, 1.0f)] // supply 2 clamped to s_out 1 - [TestCase(0.5f, 0.5f)] // supply 2 clamped to s_out 0.5 - public void CustomRenderNode_OverridesSupplyDriven_WithClampToOutput(float outputScale, float expectedW) + [Test] + public void NoOpEffect_DoesNotEvaluateUnobservedWorkingScaleHookOrResolver() { - var fe = new MosaicEffect().ToResource(CompositionContext.Default); - using var node = new ClampToOutputRenderNode(fe); + s_throwingWorkingScaleResolverCalls = 0; + using var node = new ThrowingWorkingScaleRenderNode( + new FilterEffectGroup().ToResource(CompositionContext.Default)); - RenderNodeOperation[] ops = node.Process(new RenderNodeContext([SourceOp(2.0f)], outputScale: outputScale)); + RenderNodeMeasurement measurement = default; + Assert.DoesNotThrow(() => measurement = ScaleRecordingTestHelper.MeasureThrough( + node, + EffectiveScale.At(2))); - Assert.That(ops, Is.Not.Empty); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), - "the custom render node did not apply its clamp-to-output working scale — the CreateRenderNode() escape hatch is broken"); - DisposeAll(ops); + Assert.Multiple(() => + { + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(node.HookCalls, Is.Zero); + Assert.That(s_throwingWorkingScaleResolverCalls, Is.Zero); + }); } - // End-to-end escape hatch: runs a real Mosaic at w = max(supplyDriven, 2 * s_out) (SSAA-on-demand). - private sealed class OversampleMosaicRenderNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) + [TestCase(false)] + [TestCase(true)] + public void NoOpEffect_DoesNotCommitFiniteOrOwningTargetIsolation(bool owningTargetDomain) { - public override RenderNodeOperation[] Process(RenderNodeContext context) + Rect bounds = new(2, 3, 12, 8); + Rect targetDomain = new(0, 0, 24, 16); + RenderExecutionStatistics baseline = RasterizeForStatistics( + new TargetCommandSourceRenderNode(bounds, owningTargetDomain), + targetDomain); + var owned = new TrackingDisposable(); + var noOp = new WorkingScaleProbeEffect(context => _ = context.Own(owned)); + RenderExecutionStatistics filtered = RasterizeForStatistics( + ScaleRecordingTestHelper.Pipeline( + new TargetCommandSourceRenderNode(bounds, owningTargetDomain), + new FilterEffectRenderNode(noOp.ToResource(CompositionContext.Default))), + targetDomain); + + Assert.Multiple(() => { - if (FilterEffect == null || !FilterEffect.Value.Resource.IsEnabled) - { - return context.Input; - } + Assert.That( + filtered.IntermediateTargetAcquisitions, + Is.EqualTo(baseline.IntermediateTargetAcquisitions), + "a no-op effect must not commit an extra GPU pass"); + Assert.That( + filtered.ShaderRunExecutions, + Is.EqualTo(baseline.ShaderRunExecutions), + "a no-op effect must not commit an extra shader run"); + Assert.That(owned.DisposeCount, Is.EqualTo(1), + "a no-op effect must roll back resources that never enter the committed request"); + }); + } - Span inputScales = context.Input.Length <= 16 - ? stackalloc EffectiveScale[context.Input.Length] - : new EffectiveScale[context.Input.Length]; - for (int i = 0; i < context.Input.Length; i++) + [Test] + public void LegacyFilter_MaterializesUnboundedInputAtResolvedFragmentScale() + { + float observedWorkingScale = 0; + PixelRect observedDeviceBounds = default; + Rect bounds = new(3, 5, 12, 8); + var effect = new WorkingScaleProbeEffect(context => context.Brightness(0.75f)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + bounds, + session => + { + observedWorkingScale = session.WorkingScale; + observedDeviceBounds = session.DeviceBounds; + }), + new ConstantWorkingScaleRenderNode( + effect.ToResource(CompositionContext.Default), + 2)); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions { - inputScales[i] = context.Input[i].EffectiveScale; - } - - float supplyDriven = RenderNodeContext.ResolveWorkingScale( - inputScales, context.OutputScale, context.MaxWorkingScale); - // Oversample to 2x the deliverable density, bounded by the global ceiling. - float workingScale = MathF.Min( - MathF.Max(supplyDriven, 2f * context.OutputScale), context.MaxWorkingScale); + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + TargetDomain = bounds, + }, + TargetFactory = new CpuTargetFactory(), + }); - Rect bounds = context.CalculateBounds(); - workingScale = RenderNodeContext.ClampWorkingScaleToBufferBudget(bounds, workingScale); + using RenderNodeRasterization result = renderer.Rasterize(); - using var feContext = new FilterEffectContext(bounds, context.OutputScale, workingScale); - FilterEffect.Value.Resource.GetOriginal().ApplyTo(feContext, FilterEffect.Value.Resource); - var effectTargets = new EffectTargets(); - effectTargets.AddRange(context.Input.Select(i => new EffectTarget(i))); + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(observedWorkingScale, Is.EqualTo(2)); + Assert.That(observedDeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 2))); + Assert.That(observedDeviceBounds.Size, Is.EqualTo(new PixelSize(24, 16))); + }); + } - using (var builder = new SKImageFilterBuilder()) - using (var activator = new FilterEffectActivator( - effectTargets, builder, context.OutputScale, workingScale, context.MaxWorkingScale)) + [TestCase(2f, 2f)] + [TestCase(1.5f, 1.5f)] + public void UnboundedFanOut_MaterializesOnceAtHighestConsumerDensity( + float maxWorkingScale, + float expectedWorkingScale) + { + var observedWorkingScales = new List(); + Rect bounds = new(0, 0, 12, 8); + using var node = new DivergentScaleFanOutNode(bounds, observedWorkingScales); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions { - activator.Apply(feContext); - - if (builder.HasFilter()) - { - var imageFilter = builder.GetFilter(); - return activator.CurrentTargets.Select(t => - { - var paint = new SKPaint(); - paint.ImageFilter = imageFilter; - return RenderNodeOperation.CreateLambda( - bounds: t.Bounds, - render: canvas => - { - using (canvas.PushBlendMode(BlendMode.SrcOver)) - using (canvas.PushTransform(Matrix.CreateTranslation( - t.Bounds.X - t.OriginalBounds.X, - t.Bounds.Y - t.OriginalBounds.Y))) - using (canvas.PushPaint(paint)) - { - t.Draw(canvas); - } - }, - hitTest: t.Bounds.Contains, - onDispose: () => - { - t.Dispose(); - paint.Dispose(); - }, - effectiveScale: t.Scale); - }).ToArray(); - } - else + DefaultRequest = new RenderNodeRenderRequest { - return activator.CurrentTargets.Select(i => - i.NodeOperation ?? - RenderNodeOperation.CreateFromRenderTarget(i.Bounds, i.Bounds.Position, i.RenderTarget!, i.Scale)) - .ToArray(); - } - } - } + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + MaxWorkingScale = maxWorkingScale, + TargetDomain = bounds, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(observedWorkingScales, Is.EqualTo(new[] { expectedWorkingScale }), + "A shared vector producer must select the highest downstream density before memoization."); + }); } - // End-to-end: OversampleMosaicRenderNode runs the real Mosaic at w = 2 * s_out = 2.0. GPU-gated. [Test] - public void OversampleMosaicRenderNode_RunsRealEffect_AboveSupply_AtTwiceOutputScale() + public void TargetCommand_UnboundedLayerUsesDensestConcreteInputSupply() { - VulkanTestEnvironment.EnsureAvailable(); - VulkanTestEnvironment.InvokeOnRenderThread(() => - { - var mosaic = new MosaicEffect(); - mosaic.TileSize.CurrentValue = new Size(10, 10); - using FilterEffectRenderNode node = - new OversampleMosaicRenderNode(mosaic.ToResource(CompositionContext.Default)); - // At(1) source at s_out 1.0: supply-driven gives w=1; oversample hatch lifts to 2.0. - var context = new RenderNodeContext([SourceOp(1.0f)], outputScale: 1.0f); + EffectiveScale observedScale = EffectiveScale.Unbounded; + Rect bounds = new(0, 0, 12, 8); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(2), bounds), + new LayerTargetCommandProbeNode( + bounds, + scale => observedScale = scale)); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + TargetDomain = bounds, + }, + TargetFactory = new CpuTargetFactory(), + }); - RenderNodeOperation[] ops = node.Process(context); + using RenderNodeRasterization result = renderer.Rasterize(); - Assert.That(ops, Is.Not.Empty, "the oversample escape hatch dropped its op — the effect did not apply"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(2.0f).Within(1e-4), - "the oversample hatch must run the effect at w = 2 * s_out"); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False, - "an oversampled effect buffer must be concrete At(w), not Unbounded"); + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(observedScale, Is.EqualTo(EffectiveScale.At(2))); + }); + } - // Render into a real target to verify the flush/blit path executes. - using RenderTarget target = RenderTarget.Create(120, 90)!; - using (var canvas = new ImmediateCanvas(target, 1f)) + [Test] + public void FiniteLayerCache_LargeDomainUsesAllocationClampedDensityAcrossColdAndWarmFrames() + { + var childBounds = new Rect(0, 0, 64, 1); + var layerDomain = new Rect(0, 0, 10_000, 1); + const float requestedDensity = 2; + float expectedDensity = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + layerDomain, + requestedDensity); + var observedWorkingScales = new List(); + RenderNode source = ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + childBounds, + session => observedWorkingScales.Add(session.WorkingScale)); + RenderNode layer = ScaleRecordingTestHelper.Layer(layerDomain); + WarmForCacheCapture(layer); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, layer); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions { - canvas.Clear(Colors.Black); - foreach (RenderNodeOperation op in ops) + DefaultRequest = new RenderNodeRenderRequest { - op.Render(canvas); - } - } + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + OutputScale = requestedDensity, + MaxWorkingScale = requestedDensity, + TargetDomain = childBounds, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); - using Bitmap snapshot = target.Snapshot(); - // Vacuity guard: the oversampled Mosaic must leave visible content (not all-black). - using RenderTarget blackTarget = RenderTarget.Create(120, 90)!; - using (var blackCanvas = new ImmediateCanvas(blackTarget)) - blackCanvas.Clear(Colors.Black); - using Bitmap black = blackTarget.Snapshot(); - Assert.That(ImageMetrics.MeanAbsoluteError(snapshot, black), Is.GreaterThan(0.01), - "the oversampled effect produced an all-black buffer (it silently failed to draw)"); + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); - DisposeAll(ops); + Assert.Multiple(() => + { + Assert.That(expectedDensity, Is.LessThan(requestedDensity)); + Assert.That(cold.IsEmpty, Is.False); + Assert.That(warm.IsEmpty, Is.False); + Assert.That(observedWorkingScales, Has.Count.EqualTo(1)); + Assert.That(observedWorkingScales.Single(), Is.EqualTo(expectedDensity).Within(1e-4)); + Assert.That(layer.Cache.IsCached, Is.True); + Assert.That(layer.Cache.IdentityDensity, Is.EqualTo(expectedDensity)); }); } - // End-to-end FR-036 escape hatch: FilterEffect.Resource.Push must build the render node via the - // overridden CreateRenderNode(), and that custom node's non-supply working scale must then drive the - // pipeline. The other escape-hatch tests instantiate the custom node directly (bypassing Push), so a - // regression hardcoding Push to 'new FilterEffectRenderNode(this)' would pass them; this one fails. [Test] - public void Push_RoutesThroughOverriddenCreateRenderNode_AndCustomWorkingScaleApplies() + public void FiniteLayerCache_SmallRoiUsesFullDomainForCacheRules() { - var effect = new ClampToOutputEffect(); - using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); - using var container = new ContainerRenderNode(); - using var context = new GraphicsContext2D(container, new Size(120, 90), outputScale: 1f); + var childBounds = new Rect(0, 0, 64, 1); + var layerDomain = new Rect(0, 0, 10_000, 1); + var requestedRegion = new Rect(0, 0, 1, 1); + const float requestedDensity = 2; + int executeCount = 0; + RenderNode source = ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + childBounds, + _ => executeCount++); + RenderNode layer = ScaleRecordingTestHelper.Layer(layerDomain); + WarmForCacheCapture(layer); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, layer); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = new RenderCacheOptions( + true, + new RenderCacheRules(MaxPixels: 1_000, MinPixels: 1)), + OutputScale = requestedDensity, + MaxWorkingScale = requestedDensity, + TargetDomain = childBounds, + RequestedRegion = requestedRegion, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); - using (resource.Push(context)) + using RenderNodeRasterization first = renderer.Rasterize(); + using RenderNodeRasterization second = renderer.Rasterize(); + + float expectedDensity = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + layerDomain, + requestedDensity); + PixelSize allocationSize = PixelRect.FromRect(layerDomain, expectedDensity).Size; + Assert.Multiple(() => { - } + Assert.That(requestedRegion.Width * requestedRegion.Height, Is.LessThan(1_000)); + Assert.That((long)allocationSize.Width * allocationSize.Height, Is.GreaterThan(1_000)); + Assert.That(first.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(second.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(executeCount, Is.EqualTo(2)); + Assert.That(layer.Cache.IsCached, Is.False); + }); + } - Assert.That(container.Children, Has.Count.EqualTo(1), "Push added no render node"); - Assert.That(container.Children[0], Is.TypeOf(), - "Push bypassed the overridden CreateRenderNode() escape hatch (FR-036)"); + [Test] + public void MaterializedInputCache_InBudgetTargetUsesActualDensityOnWarmHit() + { + var bounds = new Rect(0, 0, 8_000, 1); + EffectiveScale sourceScale = EffectiveScale.At(2); + PixelRect sourceDeviceBounds = PixelRect.FromRect(bounds, sourceScale.Value); + using RenderTarget source = new CpuRenderTarget( + sourceDeviceBounds.Width, + sourceDeviceBounds.Height); + using var node = new MaterializedSourceRenderNode(source, bounds, sourceScale); + WarmForCacheCapture(node); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + OutputScale = 1, + MaxWorkingScale = 4, + TargetDomain = bounds, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); - var node = (FilterEffectRenderNode)container.Children[0]; - // At(2) supply at s_out 1: supply-driven w would be 2.0; the custom node clamps to s_out = 1.0. - RenderNodeOperation[] ops = node.Process(new RenderNodeContext([SourceOp(2.0f)], outputScale: 1.0f)); + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); - Assert.That(ops, Is.Not.Empty); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(1.0f).Within(1e-4), - "the overridden render node's clamp-to-output working scale did not drive the pipeline end-to-end"); - DisposeAll(ops); + Assert.Multiple(() => + { + Assert.That(sourceDeviceBounds.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(cold.IsEmpty, Is.False); + Assert.That(warm.IsEmpty, Is.False); + Assert.That(node.Cache.IsCached, Is.True); + Assert.That(node.Cache.IdentityDensity, Is.EqualTo(sourceScale.Value)); + Assert.That( + warm.Bitmap!.GetPixelSpan().SequenceEqual(cold.Bitmap!.GetPixelSpan()), + Is.True); + }); } - private static void DisposeAll(RenderNodeOperation[] ops) + [Test] + public void MaterializedInputOpacityCache_NormalizesOversizedSupplyBeforeColdWarmCapture() { - foreach (RenderNodeOperation op in ops) + var bounds = new Rect(0, 0, 10_000, 1); + EffectiveScale sourceScale = EffectiveScale.At(2); + float expectedDensity = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + bounds, + sourceScale.Value); + PixelRect sourceDeviceBounds = PixelRect.FromRect(bounds, sourceScale.Value); + using RenderTarget source = new CpuRenderTarget( + sourceDeviceBounds.Width, + sourceDeviceBounds.Height); + var materializedInput = new MaterializedSourceRenderNode(source, bounds, sourceScale); + var opacity = new OpacityRenderNode(2); + WarmForCacheCapture(opacity); + using var pipeline = ScaleRecordingTestHelper.Pipeline(materializedInput, opacity); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + OutputScale = 1, + MaxWorkingScale = 4, + TargetDomain = bounds, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization cold = renderer.Rasterize(); + using RenderNodeRasterization warm = renderer.Rasterize(); + + Assert.Multiple(() => { - op.Dispose(); - } + Assert.That(sourceDeviceBounds.Width, Is.GreaterThan(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(expectedDensity, Is.LessThan(sourceScale.Value)); + Assert.That(cold.IsEmpty, Is.False); + Assert.That(warm.IsEmpty, Is.False); + Assert.That(opacity.Cache.IsCached, Is.True); + Assert.That(opacity.Cache.IdentityDensity, Is.EqualTo(expectedDensity)); + Assert.That( + warm.Bitmap!.GetPixelSpan().SequenceEqual(cold.Bitmap!.GetPixelSpan()), + Is.True); + }); } -} -// A FilterEffect whose Resource overrides only CreateRenderNode() (not Push), so the inherited -// FilterEffect.Resource.Push is the path under test. Mirrors the NodeGraphFilterEffect pattern -// (manual Resource + SuppressResourceClassGeneration). -[SuppressResourceClassGeneration] -internal sealed partial class ClampToOutputEffect : FilterEffect -{ - public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + [Test] + public void MaterializedInputCache_OversizedTargetBypassesCaptureAcrossRepeatedFrames() { + var bounds = new Rect(0, 0, 10_000, 1); + EffectiveScale sourceScale = EffectiveScale.At(2); + PixelRect sourceDeviceBounds = PixelRect.FromRect(bounds, sourceScale.Value); + using RenderTarget source = new CpuRenderTarget( + sourceDeviceBounds.Width, + sourceDeviceBounds.Height); + using var node = new MaterializedSourceRenderNode(source, bounds, sourceScale); + WarmForCacheCapture(node); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Enabled, + OutputScale = 1, + MaxWorkingScale = 4, + TargetDomain = bounds, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization first = renderer.Rasterize(); + using RenderNodeRasterization second = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(sourceDeviceBounds.Width, Is.GreaterThan(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(first.IsEmpty, Is.False); + Assert.That(second.IsEmpty, Is.False); + Assert.That(node.Cache.IsCached, Is.False); + Assert.That( + second.Bitmap!.GetPixelSpan().SequenceEqual(first.Bitmap!.GetPixelSpan()), + Is.True); + }); } - public override Resource ToResource(CompositionContext context) + [Test] + public void MaterializedInputCache_SmallRoiUsesFullCaptureFootprintForCacheRules() { - var resource = new Resource(); - bool updateOnly = false; - resource.Update(this, context, ref updateOnly); - return resource; + var bounds = new Rect(0, 0, 64, 64); + var requestedRegion = new Rect(0, 0, 1, 1); + EffectiveScale sourceScale = EffectiveScale.At(1); + PixelRect sourceDeviceBounds = PixelRect.FromRect(bounds, sourceScale.Value); + using RenderTarget source = new CpuRenderTarget( + sourceDeviceBounds.Width, + sourceDeviceBounds.Height); + using var node = new MaterializedSourceRenderNode(source, bounds, sourceScale); + WarmForCacheCapture(node); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = new RenderCacheOptions( + true, + new RenderCacheRules(MaxPixels: 1_000, MinPixels: 1)), + OutputScale = 1, + MaxWorkingScale = 1, + TargetDomain = bounds, + RequestedRegion = requestedRegion, + Purpose = RenderRequestPurpose.Frame, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization first = renderer.Rasterize(); + using RenderNodeRasterization second = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(requestedRegion.Width * requestedRegion.Height, Is.LessThan(1_000)); + Assert.That(sourceDeviceBounds.Width * sourceDeviceBounds.Height, Is.GreaterThan(1_000)); + Assert.That(first.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(second.Bounds, Is.EqualTo(requestedRegion)); + Assert.That(node.Cache.IsCached, Is.False); + }); } - public new sealed class Resource : FilterEffect.Resource + [Test] + public void CustomWorkingScale_LegacyOperationPreservesSubOutputScale() { - public override FilterEffectRenderNode CreateRenderNode() => new ClampToOutputEscapeHatchNode(this); + float observedWorkingScale = 0; + PixelRect observedDeviceBounds = default; + Rect bounds = new(3, 5, 12, 8); + var effect = new WorkingScaleProbeEffect(context => context.Brightness(0.75f)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + bounds, + session => + { + observedWorkingScale = session.WorkingScale; + observedDeviceBounds = session.DeviceBounds; + }), + new ConstantWorkingScaleRenderNode( + effect.ToResource(CompositionContext.Default), + 0.5f)); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + TargetDomain = bounds, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(0.5f))); + Assert.That(observedWorkingScale, Is.EqualTo(0.5f)); + Assert.That(observedDeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 0.5f))); + }); } -} -// Escape-hatch render node: overrides the supply-driven working scale with clamp-to-output -// (w = min(supply, s_out)), so its effect on the resolved scale is observable end-to-end. -internal sealed class ClampToOutputEscapeHatchNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) -{ - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - EffectiveScale[] scales = context.Input.Select(i => i.EffectiveScale).ToArray(); - float supplyW = RenderNodeContext.ResolveWorkingScale(scales, context.OutputScale, context.MaxWorkingScale); - float clampedW = MathF.Min(supplyW, context.OutputScale); - return context.Input.Select(input => RenderNodeOperation.CreateLambda( - input.Bounds, - input.Render, - hitTest: input.HitTest, - onDispose: input.Dispose, - effectiveScale: EffectiveScale.At(clampedW))) - .ToArray(); + [Test] + public void LegacyFilter_PlannedScaleMatchesIntermediateFlushRuntimeScale() + { + float observedSourceScale = 0; + Rect bounds = new(100, 20, 100, 10); + Rect intermediateFootprint = new(0, 0, 20_000, 10); + float widthOnlyClamp = RenderScaleUtilities.ClampWorkingScaleToBufferBudget( + intermediateFootprint, + 2); + s_legacyCustomWorkingScale = 0; + var effect = new WorkingScaleProbeEffect(context => + { + context.AppendSkiaFilter( + 0, + static (_, input, _) => SKImageFilter.CreateBlur(1, 1, input), + static (_, current) => current.WithWidth(20_000)); + context.CustomEffect( + 0, + RecordAndShrinkLegacyTargets, + static (_, current) => current.WithWidth(100)); + }); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + bounds, + session => observedSourceScale = session.WorkingScale), + new ConstantWorkingScaleRenderNode( + effect.ToResource(CompositionContext.Default), + 2)); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + TargetDomain = bounds, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization result = renderer.Rasterize(); + float plannedScale = measurement.EffectiveScale.Value; + + Assert.Multiple(() => + { + Assert.That(plannedScale, Is.LessThanOrEqualTo(widthOnlyClamp)); + Assert.That(plannedScale, Is.LessThan(1), "the fixture must clamp below OutputScale"); + Assert.That(result.IsEmpty, Is.False); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(observedSourceScale, Is.EqualTo(plannedScale)); + Assert.That(s_legacyCustomWorkingScale, Is.EqualTo(plannedScale)); + }); } + + [Test] + public void LegacyFilter_MultiInputClampUsesEachBufferInsteadOfSparseUnion() + { + Rect firstBounds = new(0, 0, 100, 100); + Rect secondBounds = new(100_000, 0, 100, 100); + var observedSources = new List<(float WorkingScale, PixelRect DeviceBounds)>(); + var effect = new WorkingScaleProbeEffect(context => context.Brightness(0.75f)); + using var pipeline = ScaleRecordingTestHelper.MultiInputPipeline( + [ + ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + firstBounds, + session => observedSources.Add((session.WorkingScale, session.DeviceBounds))), + ScaleRecordingTestHelper.Source( + EffectiveScale.Unbounded, + secondBounds, + session => observedSources.Add((session.WorkingScale, session.DeviceBounds))), + ], + new ConstantWorkingScaleRenderNode( + effect.ToResource(CompositionContext.Default), + 2)); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + RequestedRegion = firstBounds, + MaxWorkingScale = 4, + }, + TargetFactory = new CpuTargetFactory(), + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + using RenderNodeRasterization raster = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(raster.IsEmpty, Is.False); + Assert.That(measurement.OutputBounds, Is.EqualTo(firstBounds.Union(secondBounds))); + Assert.That(measurement.EffectiveScale, Is.EqualTo(EffectiveScale.At(2)), + "the empty gap between independent buffers must not consume the dimension budget"); + Assert.That(observedSources, Has.Count.EqualTo(1), + "the backward region excludes the buffer that cannot reach the requested region"); + Assert.That(observedSources.Select(static item => item.WorkingScale), Is.All.EqualTo(2)); + Assert.That( + observedSources.Select(static item => item.DeviceBounds), + Is.EqualTo(new[] { PixelRect.FromRect(firstBounds, 2) })); + }); + } + + [Test] + public void CustomWorkingScale_NoOp_DoesNotRecordOrPlanAnExtraBoundary() + { + RenderExecutionStatistics baseline = RasterizeForStatistics( + ScaleRecordingTestHelper.Source(EffectiveScale.At(2))); + RenderExecutionStatistics filtered = RasterizeForStatistics( + ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(2)), + new ClampToOutputRenderNode( + new FilterEffectGroup().ToResource(CompositionContext.Default)))); + + Assert.Multiple(() => + { + Assert.That( + filtered.IntermediateTargetAcquisitions, + Is.EqualTo(baseline.IntermediateTargetAcquisitions), + "a no-op clamp must not commit an extra GPU pass"); + Assert.That( + filtered.ShaderRunExecutions, + Is.EqualTo(baseline.ShaderRunExecutions), + "a no-op clamp must not commit an extra shader run"); + }); + } + + [Test] + public void CustomWorkingScale_CurrentPixelShader_DoesNotAddAnOpaqueMapPass() + { + var baselineEffect = CreateIdentityShaderEffect(); + RenderExecutionStatistics baseline = RasterizeForStatistics( + ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1)), + new FilterEffectRenderNode( + baselineEffect.ToResource(CompositionContext.Default)))); + var effect = CreateIdentityShaderEffect(); + RenderExecutionStatistics snapshot = RasterizeForStatistics( + ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1)), + new FixedWorkingScaleRenderNode( + effect.ToResource(CompositionContext.Default)))); + + Assert.Multiple(() => + { + Assert.That( + snapshot.ShaderRunExecutions, + Is.EqualTo(baseline.ShaderRunExecutions), + "the working-scale hook must not add an opaque identity-map pass"); + Assert.That( + snapshot.FusedShaderRunExecutions, + Is.EqualTo(baseline.FusedShaderRunExecutions), + "the working-scale hook must not add an opaque identity-map pass"); + }); + } + + [Test] + public void CustomWorkingScale_CurrentPixelShader_ProducesDeclaredDensityAndDeviceFootprint() + { + EffectiveScale observedInputScale = default; + PixelRect observedInputDeviceBounds = default; + float observedWorkingScale = default; + PixelRect observedOutputDeviceBounds = default; + Rect bounds = new(3, 5, 12, 8); + var effect = new WorkingScaleProbeEffect(context => + { + context.Shader(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }")); + context.Geometry(GeometryDescription.CreateRequestLocal( + session => + { + observedInputScale = session.Input.EffectiveScale; + observedInputDeviceBounds = session.Input.DeviceBounds; + observedWorkingScale = session.WorkingScale; + observedOutputDeviceBounds = session.DeviceBounds; + session.Canvas.Use(session.Input.Draw); + }, + RenderBoundsContract.Identity, + RenderHitTestContract.AnyInput)); + }); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds), + new FixedWorkingScaleRenderNode(effect.ToResource(CompositionContext.Default))); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization result = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(result.IsEmpty, Is.False); + Assert.That(observedInputScale, Is.EqualTo(EffectiveScale.At(2))); + Assert.That(observedWorkingScale, Is.EqualTo(2)); + Assert.That(observedInputDeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 2))); + Assert.That(observedOutputDeviceBounds, Is.EqualTo(PixelRect.FromRect(bounds, 2))); + Assert.That(observedInputDeviceBounds.Size, Is.EqualTo(new PixelSize(24, 16))); + }); + } + + [Test] + public void FusionPlanner_SplitsConcreteScaleTransitionsButPreservesSafeFusion() + { + using var mismatch = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1)), + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 2), + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 1)); + using CompiledRenderRequest mismatchRequest = Compile(mismatch); + + using var sameDensity = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1)), + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 2), + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 2)); + using CompiledRenderRequest sameDensityRequest = Compile(sameDensity); + + using var adoptedVector = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.Unbounded), + new DirectShaderRenderNode(), + new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 2)); + using CompiledRenderRequest adoptedVectorRequest = Compile(adoptedVector); + + Assert.Multiple(() => + { + Assert.That(mismatchRequest.ExecutionPlan.ShaderRuns.Count(), Is.EqualTo(2)); + Assert.That(mismatchRequest.ExecutionPlan.ShaderRuns, Has.All.Matches( + static run => run.Stages.Length == 1)); + Assert.That(mismatchRequest.ExecutionPlan.Boundaries, Has.Some.Matches( + static boundary => boundary.Reason == ExecutionIslandBoundaryReason.ScaleTransition)); + Assert.That(sameDensityRequest.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(2)); + Assert.That(adoptedVectorRequest.ExecutionPlan.ShaderRuns.Single().Stages, Has.Length.EqualTo(2), + "an Unbounded predecessor may adopt its concrete successor's density without a split"); + }); + } + + [Test] + public void FusedShaderBinders_ObserveStageLocalScaleContext_WithDisabledParity() + { + Rect bounds = new(3, 5, 12, 8); + var enabledObservations = new List(); + using var enabledNode = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds), + new ConstantWorkingScaleRenderNode( + CreateContextProbeEffect(enabledObservations) + .ToResource(CompositionContext.Default), + 2), + new FilterEffectRenderNode( + CreateContextProbeEffect(enabledObservations) + .ToResource(CompositionContext.Default))); + using var enabled = CreateCpuRenderer(enabledNode, bounds, FusionMode.Enabled); + + var disabledObservations = new List(); + using var disabledNode = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds), + new ConstantWorkingScaleRenderNode( + CreateContextProbeEffect(disabledObservations) + .ToResource(CompositionContext.Default), + 2), + new FilterEffectRenderNode( + CreateContextProbeEffect(disabledObservations) + .ToResource(CompositionContext.Default))); + using var disabled = CreateCpuRenderer(disabledNode, bounds, FusionMode.Disabled); + + using RenderNodeRasterization enabledRaster = enabled.Rasterize(); + using RenderNodeRasterization disabledRaster = disabled.Rasterize(); + + Assert.That(enabledRaster.Bitmap, Is.Not.Null); + Assert.That(disabledRaster.Bitmap, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(enabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + Assert.That(disabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(2)); + Assert.That(disabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.Zero); + Assert.That(enabledObservations, Is.EqualTo(disabledObservations)); + Assert.That( + enabledObservations.Select(static item => item.InputEffectiveScale), + Is.EqualTo(new[] { EffectiveScale.At(1), EffectiveScale.At(2) })); + Assert.That( + enabledObservations.Select(static item => item.WorkingScale), + Is.EqualTo(new[] { 2f, 2f })); + Assert.That( + enabledObservations.Select(static item => item.DeviceBounds), + Is.All.EqualTo(PixelRect.FromRect(bounds, 2))); + Assert.That( + enabledRaster.Bitmap!.GetPixelSpan().SequenceEqual(disabledRaster.Bitmap!.GetPixelSpan()), + Is.True); + }); + } + + [Test] + public void FusedShaderBinders_ObserveRuntimeClampedScaleContext_WithDisabledParity() + { + Rect bounds = new(0, 0, 10_000, 1); + EffectiveScale sourceScale = EffectiveScale.At(2); + PixelRect sourceDeviceBounds = PixelRect.FromRect(bounds, sourceScale.Value); + float clampedScale = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, sourceScale.Value); + PixelRect clampedDeviceBounds = PixelRect.FromRect(bounds, clampedScale); + using RenderTarget source = new CpuRenderTarget(sourceDeviceBounds.Width, sourceDeviceBounds.Height); + + var enabledObservations = new List(); + using var enabledNode = ScaleRecordingTestHelper.Pipeline( + new MaterializedSourceRenderNode(source, bounds, sourceScale), + new DirectShaderRenderNode( + CreateContextProbeShaderDescription(enabledObservations)), + new DirectShaderRenderNode( + CreateContextProbeShaderDescription(enabledObservations))); + using var enabled = CreateCpuRenderer(enabledNode, bounds, FusionMode.Enabled); + + var disabledObservations = new List(); + using var disabledNode = ScaleRecordingTestHelper.Pipeline( + new MaterializedSourceRenderNode(source, bounds, sourceScale), + new DirectShaderRenderNode( + CreateContextProbeShaderDescription(disabledObservations)), + new DirectShaderRenderNode( + CreateContextProbeShaderDescription(disabledObservations))); + using var disabled = CreateCpuRenderer(disabledNode, bounds, FusionMode.Disabled); + + using RenderNodeRasterization enabledRaster = enabled.Rasterize(); + using RenderNodeRasterization disabledRaster = disabled.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(clampedScale, Is.LessThan(sourceScale.Value), + "the fixture must exceed the per-buffer device-axis limit"); + Assert.That(enabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(1)); + Assert.That(enabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + Assert.That(disabled.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(2)); + Assert.That(disabled.LastExecutionStatistics.FusedShaderRunExecutions, Is.Zero); + Assert.That(enabledObservations, Is.EqualTo(disabledObservations)); + Assert.That( + enabledObservations.Select(static item => item.InputEffectiveScale), + Is.EqualTo(new[] { sourceScale, EffectiveScale.At(clampedScale) })); + Assert.That( + enabledObservations.Select(static item => item.WorkingScale), + Is.All.EqualTo(clampedScale)); + Assert.That( + enabledObservations.Select(static item => item.DeviceBounds), + Is.All.EqualTo(clampedDeviceBounds)); + Assert.That( + enabledRaster.Bitmap!.GetPixelSpan().SequenceEqual(disabledRaster.Bitmap!.GetPixelSpan()), + Is.True); + }); + } + + [Test] + public void StructuralPlanCache_RecompilesWhenScaleCompatibilityChanges() + { + Rect bounds = new(0, 0, 12, 8); + using var node = new MutableScaleFusionNode(bounds, density: 1); + using var renderer = CreateCpuRenderer(node, bounds, FusionMode.Enabled); + + using (renderer.Rasterize()) + { + Assert.That(renderer.LastExecutionStatistics.FusedShaderRunExecutions, Is.EqualTo(1)); + } + + node.UpdateDensity(2); + using (renderer.Rasterize()) + { + Assert.That(renderer.LastExecutionStatistics.ShaderRunExecutions, Is.EqualTo(2)); + Assert.That(renderer.LastExecutionStatistics.FusedShaderRunExecutions, Is.Zero); + } + + Assert.Multiple(() => + { + Assert.That(renderer.StructuralPlanCacheStatistics.Compilations, Is.EqualTo(2)); + Assert.That(renderer.StructuralPlanCacheStatistics.Misses, Is.EqualTo(2)); + Assert.That(renderer.StructuralPlanCacheStatistics.Replacements, Is.EqualTo(1)); + Assert.That(renderer.StructuralPlanCacheStatistics.Hits, Is.Zero); + }); + } + + private static RenderExecutionStatistics RasterizeForStatistics( + RenderNode root, + Rect? targetDomain = null) + { + RenderExecutionStatistics statistics = default; + using (root) + using (var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + TargetDomain = targetDomain, + }, + TargetFactory = new CpuTargetFactory(), + })) + using (renderer.Rasterize()) + { + statistics = renderer.LastExecutionStatistics; + } + + return statistics; + } + + private static WorkingScaleProbeEffect CreateIdentityShaderEffect() + => new(static context => context.Shader(ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"))); + + private static WorkingScaleProbeEffect CreateContextProbeEffect( + ICollection observations) + => new(context => context.Shader(CreateContextProbeShaderDescription(observations))); + + private static ShaderDescription CreateContextProbeShaderDescription( + ICollection observations) + => ShaderDescription.CurrentPixel( + "uniform float gain; half4 apply(half4 color) { return color * gain; }", + bindings => bindings.Uniform( + "gain", + 1f, + (writer, _, execution) => + { + observations.Add(ShaderContextObservation.Capture(execution)); + writer.Set(1f); + })); + + private static RenderNodeRenderer CreateCpuRenderer( + RenderNode node, + Rect targetDomain, + FusionMode fusionMode) + => new( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + OutputScale = 1, + MaxWorkingScale = 4, + TargetDomain = targetDomain, + FusionMode = fusionMode, + }, + TargetFactory = new CpuTargetFactory(), + }); + + private static void WarmForCacheCapture(RenderNode node) + { + for (int i = 0; i < RenderNodeCache.StableRequestCount; i++) + { + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } + } + + private static CompiledRenderRequest Compile(RenderNode node) + { + var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 4, + fusionMode: FusionMode.Enabled)); + try + { + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + return new RenderRequestCompiler().Compile(request, graph); + } + catch + { + request.Dispose(); + throw; + } + } + + private sealed class FixedWorkingScaleRenderNode(FilterEffect.Resource effect) + : FilterEffectRenderNode(effect) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static _ => 2); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; + } + + private sealed class ConstantWorkingScaleRenderNode : FilterEffectRenderNode + { + private readonly RenderScaleContract _scale; + + public ConstantWorkingScaleRenderNode(FilterEffect.Resource effect, float scale) + : base(effect) + { + _scale = RenderScaleContract.Custom( + new ConstantWorkingScaleResolver(scale).Resolve); + } + + protected override RenderScaleContract? GetWorkingScaleContract() => _scale; + } + + private sealed class VectorWorkingScaleRenderNode(FilterEffect.Resource effect) + : FilterEffectRenderNode(effect) + { + protected override RenderScaleContract? GetWorkingScaleContract() => RenderScaleContract.Vector; + } + + private sealed class PreserveWorkingScaleRenderNode(FilterEffect.Resource effect) + : FilterEffectRenderNode(effect) + { + protected override RenderScaleContract? GetWorkingScaleContract() + => RenderScaleContract.PreserveInputSupply; + } + + private sealed class ThrowingWorkingScaleRenderNode(FilterEffect.Resource effect) + : FilterEffectRenderNode(effect) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + ThrowWorkingScaleResolver); + + public int HookCalls { get; private set; } + + protected override RenderScaleContract? GetWorkingScaleContract() + { + HookCalls++; + return s_scale; + } + } + + private sealed class TargetCommandSourceRenderNode(Rect bounds, bool owningTargetDomain) : RenderNode + { + private readonly TargetCommandSourceIdentity _state = new(bounds, owningTargetDomain); + private readonly TargetCommandDefinition _definition = + TargetCommandDefinition.Create( + static (session, _) => session.Canvas.Use(static _ => { }), + owningTargetDomain ? TargetRegion.Full : TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.None); + + public override void Process(RenderNodeContext context) + => context.Publish(context.TargetCommand([], _definition.Call(_state))); + } + + private sealed class LayerTargetCommandProbeNode( + Rect bounds, + Action observe) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle layer = context.Layer(context.Inputs, bounds); + TargetCommandDescription command = TargetCommandDescription.CreateRequestLocal( + session => + { + observe(session.Inputs.Single().EffectiveScale); + session.Canvas.Use(static _ => { }); + }, + TargetRegion.Region(bounds), + bounds, + RenderHitTestContract.None); + context.Publish(context.TargetCommand([layer], command)); + } + } + + private sealed class DirectShaderRenderNode(ShaderDescription? description = null) : RenderNode + { + private static readonly ShaderDescription s_shader = ShaderDescription.CurrentPixel( + "half4 apply(half4 color) { return color; }"); + + private readonly ShaderDescription _description = description ?? s_shader; + + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.Inputs) + context.Publish(context.Shader(input, _description)); + } + } + + private sealed class MaterializedSourceRenderNode( + RenderTarget source, + Rect bounds, + EffectiveScale scale) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderResource target = context.Borrow(source); + context.Publish(context.MaterializedInput(MaterializedInputDescription.FromRenderTarget( + target, + bounds, + scale, + PixelRect.FromRect(bounds, scale.Value), + default, + RenderHitTestContract.OutputBounds))); + } + } + + private sealed class MutableScaleFusionNode : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + private readonly Rect _bounds; + private readonly PreserveWorkingScaleRenderNode _preserve; + private readonly ConstantWorkingScaleRenderNode _fixed; + private float _density; + + public MutableScaleFusionNode(Rect bounds, float density) + { + _bounds = bounds; + _density = density; + _preserve = new PreserveWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default)); + _fixed = new ConstantWorkingScaleRenderNode( + CreateIdentityShaderEffect().ToResource(CompositionContext.Default), + 1); + } + + public void UpdateDensity(float density) + { + _density = density; + HasChanges = true; + } + + public override void Process(RenderNodeContext context) + { + Brush.Resource fill = Brushes.Resource.White; + RenderResource fillToken = context.Borrow(fill); + float density = _density; + var definition = OpaqueRenderDefinition<(Rect Bounds, float Density)>.Create( + static (session, state) => session.UseResource(s_fillSlot, currentFill => + { + using OpaqueRenderOutput output = session.CreateOutput(state.Bounds); + output.Canvas.Use(canvas => canvas.DrawRectangle(state.Bounds, currentFill, null)); + session.Publish(output); + }), + OpaqueRenderBoundsContract.Source(_bounds), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.Custom( + new ConstantWorkingScaleResolver(density).Resolve), + resources: [s_fillSlot]); + RenderFragmentHandle current = context.OpaqueSource(definition.Call( + (_bounds, density), + [s_fillSlot.Bind(fillToken)])); + current = context.RecordNode(_preserve, [current]).Single(); + current = context.RecordNode(_fixed, [current]).Single(); + context.Publish(current); + } + + protected override void OnDispose(bool disposing) + { + _fixed.Dispose(); + _preserve.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class DivergentScaleFanOutNode : RenderNode + { + private readonly VectorSourceNode _source; + + public DivergentScaleFanOutNode( + Rect bounds, + ICollection observedWorkingScales) + { + _source = new VectorSourceNode(bounds, observedWorkingScales); + } + + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle shared = context.RecordNode(_source, []).Single(); + context.Publish(context.OpaqueMap(shared, CreateConsumer(1))); + context.Publish(context.OpaqueMap(shared, CreateConsumer(2))); + } + + protected override void OnDispose(bool disposing) + { + _source.Dispose(); + base.OnDispose(disposing); + } + + private static OpaqueRenderDescription CreateConsumer(float scale) + => OpaqueRenderDescription.CreateRequestLocal( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs.Single().Draw); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Custom( + new ConstantScaleResolver(scale).Resolve)); + + private sealed record ConstantScaleResolver(float Scale) + { + public float Resolve(RenderScaleContext _) => Scale; + } + + private sealed class VectorSourceNode( + Rect bounds, + ICollection observedWorkingScales) : RenderNode + { + public override void Process(RenderNodeContext context) + { + OpaqueRenderDescription source = OpaqueRenderDescription.CreateRequestLocal( + session => + { + observedWorkingScales.Add(session.WorkingScale); + using OpaqueRenderOutput output = session.CreateOutput(bounds); + output.Canvas.Use(static canvas => canvas.Clear()); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.Vector); + context.Publish(context.OpaqueSource(source)); + } + } + } + + private sealed class TrackingDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + private static float ThrowWorkingScaleResolver(RenderScaleContext _) + { + s_throwingWorkingScaleResolverCalls++; + throw new InvalidOperationException("The no-op resolver must remain lazy."); + } + + private static void RecordAndShrinkLegacyTargets(int _, CustomFilterEffectContext context) + { + s_legacyCustomWorkingScale = context.WorkingScale; + for (int index = 0; index < context.Targets.Count; index++) + { + EffectTarget current = context.Targets[index]; + EffectTarget replacement = context.CreateTarget(current.Bounds.WithWidth(100)); + using (ImmediateCanvas canvas = context.Open(replacement)) + canvas.Clear(); + current.Dispose(); + context.Targets[index] = replacement; + } + } + + private static float ObserveBranchWorkingScale(RenderScaleContext context) + { + s_scaleResolverObservations!.Add(new ScaleResolverObservation( + context.InputSupplies.ToArray(), + context.OutputBounds)); + EffectiveScale supply = context.InputSupplies.Single(); + return supply.IsUnbounded ? context.OutputScale : supply.Value; + } + + private sealed record ConstantWorkingScaleResolver(float Value) + { + public float Resolve(RenderScaleContext _) => Value; + } + + private readonly record struct ShaderContextObservation( + Rect InputBounds, + Rect OutputBounds, + Rect RequiredRegion, + PixelRect DeviceBounds, + EffectiveScale InputEffectiveScale, + float OutputScale, + float WorkingScale, + float MaxWorkingScale) + { + public static ShaderContextObservation Capture(ShaderExecutionContext context) + => new( + context.InputBounds, + context.OutputBounds, + context.RequiredRegion, + context.DeviceBounds, + context.InputEffectiveScale, + context.OutputScale, + context.WorkingScale, + context.MaxWorkingScale); + } + + private readonly record struct ScaleResolverObservation( + IReadOnlyList InputSupplies, + Rect OutputBounds); + + private readonly record struct TargetCommandSourceIdentity(Rect Bounds, bool OwningTargetDomain); + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize.Width, allocation.DeviceSize.Height); + } + + private sealed class CpuRenderTarget(int width, int height) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + width, + height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())), + width, + height); + + private sealed class OversampleMosaicRenderNode(FilterEffect.Resource effect) : FilterEffectRenderNode(effect) + { + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => MathF.Min( + MathF.Max( + RenderScaleUtilities.ResolveWorkingScale( + metadata.InputSupplies.ToArray(), + metadata.OutputScale, + metadata.MaxWorkingScale), + 2 * metadata.OutputScale), + metadata.MaxWorkingScale)); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; + } + + [Test] + public void OversampleMosaicRenderNode_RunsRealEffect_AboveSupply_AtTwiceOutputScale() + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var mosaic = new MosaicEffect(); + mosaic.TileSize.CurrentValue = new Size(10, 10); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1)), + new OversampleMosaicRenderNode(mosaic.ToResource(CompositionContext.Default))); + using var renderer = new RenderNodeRenderer( + pipeline, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + Assert.That(measurement.HasFragments, Is.True, + "the oversample escape hatch dropped its fragment — the effect did not apply"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(2).Within(1e-4), + "the oversample hatch must run the effect at w = 2 * s_out"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, + "an oversampled effect buffer must be concrete At(w), not Unbounded"); + + using RenderTarget target = RenderTarget.Create(120, 90)!; + using (var canvas = new ImmediateCanvas(target, 1)) + { + canvas.Clear(Colors.Black); + renderer.Render(canvas); + } + + using Bitmap snapshot = target.Snapshot(); + using RenderTarget blackTarget = RenderTarget.Create(120, 90)!; + using (var blackCanvas = new ImmediateCanvas(blackTarget)) + blackCanvas.Clear(Colors.Black); + using Bitmap black = blackTarget.Snapshot(); + Assert.That(ImageMetrics.MeanAbsoluteError(snapshot, black), Is.GreaterThan(0.01), + "the oversampled effect produced an all-black buffer (it silently failed to draw)"); + }); + } + + [Test] + public void Push_RoutesThroughOverriddenCreateRenderNode_AndCustomWorkingScaleApplies() + { + var effect = new ClampToOutputEffect(); + using FilterEffect.Resource resource = effect.ToResource(CompositionContext.Default); + using var container = new ContainerRenderNode(); + using var context = new GraphicsContext2D(container, new Size(120, 90), outputScale: 1); + + using (resource.Push(context)) + { + } + + Assert.That(container.Children, Has.Count.EqualTo(1), "Push added no render node"); + Assert.That(container.Children[0], Is.TypeOf(), + "Push bypassed the overridden CreateRenderNode() escape hatch (FR-036)"); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + container.Children[0], + EffectiveScale.At(2)); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(1).Within(1e-4), + "the overridden render node's clamp-to-output working scale did not drive the pipeline end-to-end"); + } +} + +[SuppressResourceClassGeneration] +internal sealed partial class ClampToOutputEffect : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + context.Brightness(0.75f); + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + + public override FilterEffectRenderNode CreateRenderNode() => new ClampToOutputEscapeHatchNode(this); + } +} + +internal sealed class ClampToOutputEscapeHatchNode(FilterEffect.Resource effect) : FilterEffectRenderNode(effect) +{ + private static readonly RenderScaleContract s_scale = RenderScaleContract.Custom( + static metadata => MathF.Min( + RenderScaleUtilities.ResolveWorkingScale( + metadata.InputSupplies.ToArray(), + metadata.OutputScale, + metadata.MaxWorkingScale), + metadata.OutputScale)); + + protected override RenderScaleContract? GetWorkingScaleContract() => s_scale; +} + +[SuppressResourceClassGeneration] +internal sealed partial class WorkingScaleProbeEffect(Action apply) : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + => apply(context); + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + } +} + +internal static class ScaleRecordingTestHelper +{ + private static readonly Rect s_defaultBounds = new(0, 0, 120, 90); + + public static RenderNode Source( + EffectiveScale scale, + Rect? bounds = null, + Action? observe = null) + => new FixedScaleSourceRenderNode(bounds ?? s_defaultBounds, scale, observe); + + public static RenderNode Materialize() + => new MaterializeInputsRenderNode(); + + public static RenderNode Layer(Rect domain) + => new FiniteLayerInputsRenderNode(domain); + + public static RecordingPipelineRenderNode Pipeline(params RenderNode[] stages) + => new(stages); + + public static RenderNode MultiInputPipeline(RenderNode[] sources, RenderNode stage) + => new MultiInputRecordingPipelineRenderNode(sources, stage); + + public static RecordingSubtreePipelineRenderNode SubtreePipeline( + RenderNode subtree, + params RenderNode[] stages) + => new(subtree, stages); + + public static RenderNodeMeasurement MeasureThrough( + RenderNode node, + EffectiveScale sourceScale, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity) + { + using var pipeline = Pipeline(Source(sourceScale), node); + return Measure(pipeline, outputScale, maxWorkingScale); + } + + public static RenderNodeMeasurement Measure( + RenderNode root, + float outputScale = 1, + float maxWorkingScale = float.PositiveInfinity, + Rect? targetDomain = null) + { + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = outputScale, + MaxWorkingScale = maxWorkingScale, + TargetDomain = targetDomain, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + return renderer.Measure(); + } + + internal sealed class RecordingPipelineRenderNode(params RenderNode[] stages) : RenderNode + { + private readonly RenderNode[] _stages = stages; + + public override void Process(RenderNodeContext context) + { + IReadOnlyList inputs = []; + foreach (RenderNode stage in _stages) + inputs = context.RecordNode(stage, inputs); + + context.PublishRange(inputs); + } + + protected override void OnDispose(bool disposing) + { + foreach (RenderNode stage in _stages) + stage.Dispose(); + } + } + + internal sealed class RecordingSubtreePipelineRenderNode( + RenderNode subtree, + params RenderNode[] stages) : RenderNode + { + private readonly RenderNode _subtree = subtree; + private readonly RenderNode[] _stages = stages; + + public override void Process(RenderNodeContext context) + { + IReadOnlyList inputs = context.RecordSubtree(_subtree); + foreach (RenderNode stage in _stages) + inputs = context.RecordNode(stage, inputs); + + context.PublishRange(inputs); + } + + protected override void OnDispose(bool disposing) + { + _subtree.Dispose(); + foreach (RenderNode stage in _stages) + stage.Dispose(); + } + } + + private sealed class MultiInputRecordingPipelineRenderNode( + RenderNode[] sources, + RenderNode stage) : RenderNode + { + public override void Process(RenderNodeContext context) + { + RenderFragmentHandle[] inputs = sources + .SelectMany(source => context.RecordNode(source, [])) + .ToArray(); + context.PublishRange(context.RecordNode(stage, inputs)); + } + + protected override void OnDispose(bool disposing) + { + stage.Dispose(); + foreach (RenderNode source in sources) + source.Dispose(); + base.OnDispose(disposing); + } + } + + private sealed class FixedScaleSourceRenderNode( + Rect bounds, + EffectiveScale scale, + Action? observe) : RenderNode + { + private static readonly RenderResourceSlot s_fillSlot = new(); + private static readonly RenderResourceSlot> s_probeSlot = new(); + private readonly SessionProbe _probe = new(observe); + + public override void Process(RenderNodeContext context) + { + Brush.Resource fill = Brushes.Resource.White; + RenderResource fillToken = context.Borrow(fill); + RenderResource> probeToken = context.Borrow(_probe); + RenderScaleContract scaleContract = scale.IsUnbounded + ? RenderScaleContract.Vector + : RenderScaleContract.Custom( + new FixedScaleResolver(scale.Value).Resolve); + var definition = OpaqueRenderDefinition.Create( + static (session, currentBounds) => + session.UseResource(s_probeSlot, probe => + session.UseResource(s_fillSlot, currentFill => + { + probe.Observe(session); + using OpaqueRenderOutput output = session.CreateOutput(currentBounds); + output.Canvas.Use(canvas => canvas.DrawRectangle(currentBounds, currentFill, null)); + session.Publish(output); + })), + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.None, + RenderValueCardinality.Single, + scaleContract, + resources: [s_fillSlot, s_probeSlot]); + context.Publish(context.OpaqueSource(definition.Call( + bounds, + [s_fillSlot.Bind(fillToken), s_probeSlot.Bind(probeToken)]))); + } + } + + private sealed class MaterializeInputsRenderNode : RenderNode + { + public override void Process(RenderNodeContext context) + { + foreach (RenderFragmentHandle input in context.Inputs) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + execute: static session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs[0].Draw); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.MaterializeAtWorkingScale); + context.Publish(context.OpaqueMap(input, description)); + } + } + } + + private sealed class FiniteLayerInputsRenderNode(Rect domain) : RenderNode + { + public override void Process(RenderNodeContext context) + => context.Publish(context.Layer(context.Inputs, domain)); + } + + private sealed record FixedScaleResolver(float Value) + { + public float Resolve(RenderScaleContext _) => Value; + } + } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/StrokeEffectOffsetBoundsTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/StrokeEffectOffsetBoundsTests.cs index 6049579d38..c092fcc093 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/StrokeEffectOffsetBoundsTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/StrokeEffectOffsetBoundsTests.cs @@ -28,7 +28,7 @@ private static Rect TransformedBounds(Point offset, float penOffset = 0f) var resource = effect.ToResource(CompositionContext.Default); var context = new FilterEffectContext(Source); // CustomEffect updates context.Bounds via TransformBounds — no GPU needed for the bounds pass. - resource.GetOriginal().ApplyTo(context, resource); + resource.GetOriginal()!.ApplyTo(context, resource); return context.Bounds; } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/TextRenderNodeTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/TextRenderNodeTests.cs new file mode 100644 index 0000000000..17c5c11b5b --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/TextRenderNodeTests.cs @@ -0,0 +1,148 @@ +using System.Reflection; +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.Media.TextFormatting; + +namespace Beutl.UnitTests.Engine.Graphics.Rendering; + +[TestFixture] +public class TextRenderNodeTests +{ + /// + /// The bounds a fragment publishes are what place it, so anything density-dependent in them moves the + /// composition between a 50% preview, a 100% preview and a 2x export. Hinting needs a couple of logical + /// units of extra room, and a different amount at each density, so that room is declared for the buffer + /// only rather than published. + /// + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + public void PublishedBounds_AreTheTextsOwnBoundsAtEveryOutputScale(float outputScale) + { + using FormattedText text = CreateText(); + using var node = new TextRenderNode(text, Brushes.Resource.White, null); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: outputScale, + maxWorkingScale: outputScale, + owner: owner)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + + Assert.Multiple(() => + { + Assert.That(RootOf(graph).RecordedBounds, Is.EqualTo(text.ActualBounds)); + Assert.That(text.GetRasterBounds(outputScale), Is.Not.EqualTo(text.ActualBounds), + "The fixture must exercise a density whose mask reaches outside the text's own bounds."); + }); + } + + [TestCase(0.5f)] + [TestCase(1f)] + [TestCase(2f)] + public void DeclaredRasterOutset_CoversTheMaskAtTheRecordedScale(float outputScale) + { + using FormattedText text = CreateText(); + using var node = new TextRenderNode(text, Brushes.Resource.White, null); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: outputScale, + maxWorkingScale: outputScale, + owner: owner)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentReference root = RootOf(graph); + var payload = (OpaqueRenderFragmentPayload)root.Payload!; + Rect footprint = root.RecordedBounds.Inflate(payload.Description.Bounds.RasterOutset); + + Assert.That( + footprint.Contains(text.GetRasterBounds(outputScale)), + Is.True, + $"The buffer must still clear the glyph masks measured at {outputScale}."); + } + + private static FormattedText CreateText() + => new() + { + Font = TypefaceProvider.Typeface().FontFamily, + Size = 48f, + Text = "Raster footprint", + }; + + private static RenderFragmentReference RootOf(RecordedRenderGraph graph) + { + RenderFragmentId rootId = graph.PublicationRoots.Single(); + return (RenderFragmentReference)graph.Fragments + .Single(fragment => fragment.Id == rootId) + .Payload!; + } + + [Test] + public void Measure_UsesRasterBoundsForTheEmptinessGate() + { + using var text = new FormattedText + { + Font = TypefaceProvider.Typeface().FontFamily, + Size = 48f, + Text = "Raster footprint", + }; + Rect rasterBounds = text.RasterBounds; + + // Current public font backends did not expose a glyph with a degenerate outline but a non-empty + // hinted mask. Inject that valid measured-state relationship to isolate which published bound the + // render node gates on; the source allocation still comes from the real measured RasterBounds. + FieldInfo actualBoundsField = typeof(FormattedText).GetField( + "_actualBounds", + BindingFlags.Instance | BindingFlags.NonPublic)!; + actualBoundsField.SetValue( + text, + new Rect(rasterBounds.X, rasterBounds.Y, 0, rasterBounds.Height)); + + using var node = new TextRenderNode(text, Brushes.Resource.White, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(rasterBounds)); + }); + } + +} + +internal sealed partial class TextBrushBoundsProbeDrawable : Drawable +{ + private readonly ICollection _observedSizes; + + public TextBrushBoundsProbeDrawable(ICollection observedSizes) + { + _observedSizes = observedSizes; + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) + { + _observedSizes.Add(availableSize); + return new Size(1, 1); + } + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + context.DrawRectangle(new Rect(0, 0, 1, 1), Brushes.Resource.White, null); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/VideoSourceRenderNodeTest.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/VideoSourceRenderNodeTest.cs index 091b5262dd..d18d9b7a21 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/VideoSourceRenderNodeTest.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/VideoSourceRenderNodeTest.cs @@ -31,15 +31,24 @@ public void TearDown() // A decoded video frame reports concrete At(1) density, not Unbounded. [Test] - public void Process_OpReportsConcreteNativeDensity_NotUnbounded() + public void Measure_ReportsConcreteNativeDensity_NotUnbounded() { - var node = new VideoSourceRenderNode(_resource!, frame: 0, Brushes.Resource.White, null); - var operations = node.Process(new Beutl.Graphics.Rendering.RenderNodeContext([])); + using var node = new VideoSourceRenderNode(_resource!, frame: 0, Brushes.Resource.White, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); - Assert.That(operations, Is.Not.Empty); - Assert.That(operations[0].EffectiveScale.IsUnbounded, Is.False, + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, "a video source must report a concrete density, not the vector Unbounded sentinel"); - Assert.That(operations[0].EffectiveScale.Value, Is.EqualTo(1f), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(1f), "a video frame drawn at its native 1:1 size has supply density 1"); } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs index ea9d760180..a7129b7380 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/WorkingScaleClampConsistencyTests.cs @@ -3,6 +3,7 @@ using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; +using SkiaSharp; namespace Beutl.UnitTests.Engine.Graphics.Rendering; @@ -14,32 +15,257 @@ public class WorkingScaleClampConsistencyTests // 4000 logical px × w 8 = 32000 px > MaxBufferDimension (16384) → the clamp must fire. private static readonly Rect s_pathologicalBounds = new(0, 0, 4000, 10); + [Test] + public void ExactClamp_NegativeOriginPreservesDensityWhenDeviceFootprintFits() + { + var bounds = new Rect( + -0.5f, + 0, + RenderScaleUtilities.MaxBufferDimension - 0.5f, + 1); + PixelSize deviceSize = PixelRect.FromRect(bounds, 1).Size; + float coarse = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 1); + float exact = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, 1); + EffectiveScale planned = FilterEffectWorkingScalePolicy.ResolveMaterialized( + [EffectiveScale.At(1)], + [bounds], + outputScale: 1, + maxWorkingScale: 1); + using var targets = new EffectTargets(); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + workingScale: 1); + + Assert.Multiple(() => + { + Assert.That(deviceSize.Width, Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(coarse, Is.EqualTo(1)); + Assert.That(exact, Is.EqualTo(1)); + Assert.That(planned, Is.EqualTo(EffectiveScale.At(1))); + Assert.That(context.ResolveTargetDensity(bounds), Is.EqualTo(1)); + }); + } + + [Test] + public void ExactClamp_TightensBelowTheCoarseEstimateWhenANegativeOriginAddsADevicePixel() + { + var bounds = new Rect( + -0.5f, + 0, + RenderScaleUtilities.MaxBufferDimension, + 1); + PixelSize deviceSize = PixelRect.FromRect(bounds, 1).Size; + float coarse = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(bounds, 1); + float exact = RenderScaleUtilities.ClampWorkingScaleToExactBufferBudget(bounds, 1); + EffectiveScale planned = FilterEffectWorkingScalePolicy.ResolveMaterialized( + [EffectiveScale.At(1)], + [bounds], + outputScale: 1, + maxWorkingScale: 1); + + Assert.Multiple(() => + { + Assert.That(deviceSize.Width, Is.EqualTo(RenderScaleUtilities.MaxBufferDimension + 1)); + Assert.That(coarse, Is.LessThan(1), "the estimate must account for the straddled pixel"); + Assert.That(exact, Is.LessThan(1)); + Assert.That(exact, Is.LessThanOrEqualTo(coarse)); + Assert.That( + PixelRect.FromRect(bounds, coarse).Width, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That( + PixelRect.FromRect(bounds, exact).Width, + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That(planned.Value, Is.LessThan(1)); + }); + } + + [Test] + public void MaterializationPolicy_PreservesExactFitAtNegativeOrigin() + { + var bounds = new Rect( + -0.5f, + 0, + RenderScaleUtilities.MaxBufferDimension - 0.5f, + 1); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + targetDomain: bounds, + owner: owner)); + var transaction = new NodeRecordingTransaction( + new RenderRequestRecorder(request), + new object(), + []); + var context = new RenderNodeContext(transaction); + RenderFragmentHandle handle = context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + static _ => { }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.None, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale)); + RenderFragmentReference reference = transaction.GetReference(handle); + + Assert.Multiple(() => + { + Assert.That(reference.EffectiveScale, Is.EqualTo(EffectiveScale.At(1))); + Assert.That(RenderMaterializationDensityPolicy.Clamp(reference, 1), Is.EqualTo(1)); + Assert.That( + PixelRect.FromRect(reference.Bounds, 1).Width, + Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [Test] + public void RasterApronClamp_PreservesDensityWhenExactApronedFootprintFits() + { + var bounds = new Rect( + -0.5f, + 0, + RenderScaleUtilities.MaxBufferDimension - 2.5f, + 1); + PixelRect footprint = RenderScaleUtilities.AddRasterApron(PixelRect.FromRect(bounds, 1)); + + Assert.Multiple(() => + { + Assert.That(footprint.Width, Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); + Assert.That( + RenderScaleUtilities.ClampWorkingScaleToRasterApronBudget(bounds, 1), + Is.EqualTo(1)); + }); + } + [Test] public void Flush_ClampWriteback_KeepsWorkingScaleEqualToBufferDensity() { VulkanTestEnvironment.EnsureAvailable(); VulkanTestEnvironment.InvokeOnRenderThread(() => { - RenderNodeOperation op = RenderNodeOperation.CreateLambda( - s_pathologicalBounds, - canvas => canvas.DrawRectangle(s_pathologicalBounds, Brushes.Resource.White, null), - hitTest: _ => false); - - using var targets = new EffectTargets { new EffectTarget(op) }; + using RenderTarget source = RenderTarget.Create(4000, 10)!; + using var targets = new EffectTargets + { + new EffectTarget(source, s_pathologicalBounds, EffectiveScale.At(1)), + }; using var builder = new SKImageFilterBuilder(); using var activator = new FilterEffectActivator( - targets, builder, outputScale: 1f, workingScale: 8f, maxWorkingScale: 8f); + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 8f, + maxWorkingScale: 8f); activator.Flush(); - float expected = RenderNodeContext.ClampWorkingScaleToBufferBudget(s_pathologicalBounds, 8f); + float expected = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(s_pathologicalBounds, 8f); Assert.That(expected, Is.LessThan(8f), "the fixture must actually trigger the clamp"); Assert.That(activator.WorkingScale, Is.EqualTo(expected)); Assert.That(activator.CurrentTargets, Has.Count.EqualTo(1)); Assert.That(activator.CurrentTargets[0].Scale.Value, Is.EqualTo(activator.WorkingScale), "the flushed buffer's density and the activator's WorkingScale must agree"); Assert.That(activator.CurrentTargets[0].RenderTarget!.Width, - Is.LessThanOrEqualTo(RenderNodeContext.MaxBufferDimension)); + Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); + }); + } + + [TestCase(1f, false)] + [TestCase(0.5f, false)] + [TestCase(1f, true)] + [TestCase(0.5f, true)] + public void ForcedFlush_ApronBackedInput_UsesBoundaryAppropriateFootprint( + float density, + bool hasFilter) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var bounds = new Rect(0, 0, 20, 12); + PixelRect canonical = PixelRect.FromRect(bounds, density); + var apron = new PixelRect( + canonical.X - 1, + canonical.Y - 1, + canonical.Width + 2, + canonical.Height + 2); + using RenderTarget source = RenderTarget.Create(apron.Width, apron.Height)!; + var input = new EffectTarget( + source, + bounds, + EffectiveScale.At(density), + apron); + using var targets = new EffectTargets { input }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: density, + workingScale: density); + if (hasFilter) + { + builder.AppendSKColorFilter( + 0, + activator, + static (_, _) => SKColorFilter.CreateLinearToSrgbGamma()); + } + + activator.Flush(); + + EffectTarget actual = activator.CurrentTargets.Single(); + PixelRect expectedDeviceBounds = hasFilter ? apron : canonical; + Assert.Multiple(() => + { + Assert.That(actual, Is.Not.SameAs(input)); + Assert.That(actual.Scale, Is.EqualTo(EffectiveScale.At(density))); + Assert.That(actual.DeviceBounds, Is.EqualTo(expectedDeviceBounds)); + Assert.That(actual.RasterBounds, Is.EqualTo(expectedDeviceBounds.ToRect(density))); + Assert.That(actual.RenderTarget!.Width, Is.EqualTo(expectedDeviceBounds.Width)); + Assert.That(actual.RenderTarget.Height, Is.EqualTo(expectedDeviceBounds.Height)); + Assert.That(actual.PreserveLegacyRasterPlacement, Is.EqualTo(!hasFilter)); + }); + }); + } + + [TestCase(1f)] + [TestCase(0.5f)] + public void ForcedFlush_CanonicalInput_ReplacesWithLegacyCustomTarget(float density) + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + var bounds = new Rect(0, 0, 20, 12); + PixelRect canonical = PixelRect.FromRect(bounds, density); + using RenderTarget source = RenderTarget.Create(canonical.Width, canonical.Height)!; + var input = new EffectTarget( + source, + bounds, + EffectiveScale.At(density), + canonical); + using var targets = new EffectTargets { input }; + using var builder = new SKImageFilterBuilder(); + using var activator = new FilterEffectActivator( + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: density, + workingScale: density); + + activator.Flush(); + + EffectTarget actual = activator.CurrentTargets.Single(); + Assert.Multiple(() => + { + Assert.That(actual, Is.Not.SameAs(input)); + Assert.That(actual.PreserveLegacyRasterPlacement, Is.True); + Assert.That(actual.Bounds, Is.EqualTo(bounds)); + Assert.That(actual.Scale, Is.EqualTo(EffectiveScale.At(density))); + Assert.That(actual.RenderTarget!.Width, Is.EqualTo(canonical.Width)); + Assert.That(actual.RenderTarget.Height, Is.EqualTo(canonical.Height)); + }); }); } @@ -50,15 +276,20 @@ public void CreateTarget_ClampsInsteadOfFailing_AndTagsTrueDensity() VulkanTestEnvironment.InvokeOnRenderThread(() => { using var targets = new EffectTargets(); - var context = new CustomFilterEffectContext(targets, outputScale: 1f, workingScale: 8f); + var context = new CustomFilterEffectContext( + targets, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 8f); using EffectTarget target = context.CreateTarget(s_pathologicalBounds); Assert.That(target.IsEmpty, Is.False, "an oversized request must degrade density, not return an empty target"); - float expected = RenderNodeContext.ClampWorkingScaleToBufferBudget(s_pathologicalBounds, 8f); + float expected = RenderScaleUtilities.ClampWorkingScaleToBufferBudget(s_pathologicalBounds, 8f); Assert.That(target.Scale.Value, Is.EqualTo(expected)); - Assert.That(target.RenderTarget!.Width, Is.LessThanOrEqualTo(RenderNodeContext.MaxBufferDimension)); + Assert.That(target.RenderTarget!.Width, Is.LessThanOrEqualTo(RenderScaleUtilities.MaxBufferDimension)); }); } @@ -68,10 +299,21 @@ public void Flush_PreviewAllocationFailure_DropsTargetWithoutThrowing() using var targets = CreateInvalidFlushTargets(); using var builder = new SKImageFilterBuilder(); using var activator = new FilterEffectActivator( - targets, builder, outputScale: 1f, workingScale: 1f, maxWorkingScale: 8f); + targets, + builder, + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: 8f); Assert.That(() => activator.Flush(), Throws.Nothing); - Assert.That(activator.CurrentTargets, Is.Empty); + Assert.Multiple(() => + { + Assert.That(activator.Intent, Is.EqualTo(RenderIntent.Preview)); + Assert.That(activator.Purpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + Assert.That(activator.CurrentTargets, Is.Empty); + }); } [Test] @@ -80,19 +322,29 @@ public void Flush_DeliveryAllocationFailure_ThrowsInsteadOfDroppingTarget() using var targets = CreateInvalidFlushTargets(); using var builder = new SKImageFilterBuilder(); using var activator = new FilterEffectActivator( - targets, builder, outputScale: 1f, workingScale: 1f, maxWorkingScale: float.PositiveInfinity); + targets, + builder, + RenderIntent.Delivery, + RenderRequestPurpose.Auxiliary, + outputScale: 1f, + workingScale: 1f, + maxWorkingScale: float.PositiveInfinity); var ex = Assert.Throws(() => activator.Flush()); - Assert.That(ex!.Message, Does.Contain("Effect flush buffer allocation failed")); + Assert.Multiple(() => + { + Assert.That(activator.Intent, Is.EqualTo(RenderIntent.Delivery)); + Assert.That(activator.Purpose, Is.EqualTo(RenderRequestPurpose.Auxiliary)); + Assert.That(ex!.Message, Does.Contain("Effect flush buffer allocation failed")); + }); } private static EffectTargets CreateInvalidFlushTargets() { - RenderNodeOperation op = RenderNodeOperation.CreateLambda( - new Rect(0, 0, -1, 10), - _ => { }, - hitTest: _ => false); - - return new EffectTargets { new EffectTarget(op) }; + using RenderTarget source = RenderTarget.CreateNull(1, 1); + return new EffectTargets + { + new EffectTarget(source, new Rect(0, 0, -1, 10), EffectiveScale.At(1)), + }; } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/SourceVideoThumbnailTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/SourceVideoThumbnailTests.cs index f19bd2232f..bca4c9c55a 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/SourceVideoThumbnailTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/SourceVideoThumbnailTests.cs @@ -1,5 +1,7 @@ using Beutl.Graphics; +using Beutl.Graphics.Rendering; using Beutl.Media; +using Beutl.Threading; namespace Beutl.UnitTests.Engine.Graphics; @@ -49,4 +51,135 @@ public void FromSeconds_OfInfinity_ThrowsOverflow() // duration.TotalSeconds / count when count == 0 yields +Infinity. Assert.Throws(() => TimeSpan.FromSeconds(double.PositiveInfinity * 0.5)); } + + [Test] + [NonParallelizable] + public async Task ThumbnailRenderResources_AreDisposedOnRenderThread() + { + var resources = new[] + { + new DisposalThreadProbe(), + new DisposalThreadProbe(), + new DisposalThreadProbe(), + }; + + Assert.That(RenderThread.Dispatcher.CheckAccess(), Is.False, + "the fixture must begin on the thumbnail consumer's non-render thread"); + await SourceVideo.DisposeThumbnailRenderResourcesAsync(resources); + + Assert.That(resources, Has.All.Matches(static item => + item.DisposeCount == 1 && item.DisposedOnRenderThread)); + } + + [Test] + public async Task ThumbnailRenderResources_StopWaitingWhenDispatcherShutsDownBeforeQueuedCleanupRuns() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + using var blockerEntered = new ManualResetEventSlim(); + using var releaseBlocker = new ManualResetEventSlim(); + var resource = new DisposalThreadProbe(); + try + { + await dispatcher.InvokeAsync(static () => { }); + dispatcher.Dispatch(() => + { + blockerEntered.Set(); + releaseBlocker.Wait(); + }, DispatchPriority.High); + Assert.That(blockerEntered.Wait(TimeSpan.FromSeconds(5)), Is.True, + "The dispatcher fixture did not enter its blocking operation."); + + Task cleanup = SourceVideo.DisposeThumbnailRenderResourcesAsync(dispatcher, resource); + dispatcher.Shutdown(); + + await cleanup.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(resource.DisposeCount, Is.Zero, + "Thread-affine resources must not be disposed from the thumbnail consumer after shutdown."); + } + finally + { + releaseBlocker.Set(); + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True, + "The local dispatcher did not finish after its blocker was released."); + } + } + + [Test] + public async Task ThumbnailRenderResources_CopiedShutdownHandlerIsSafeAfterCleanupUnsubscribes() + { + Dispatcher dispatcher = Dispatcher.Spawn(); + using var cleanupEntered = new ManualResetEventSlim(); + using var releaseCleanup = new ManualResetEventSlim(); + using var shutdownHandlerEntered = new ManualResetEventSlim(); + using var releaseShutdownHandler = new ManualResetEventSlim(); + var resource = new BlockingDisposalProbe(cleanupEntered, releaseCleanup); + EventHandler blockingShutdownHandler = (_, _) => + { + shutdownHandlerEntered.Set(); + releaseShutdownHandler.Wait(); + }; + dispatcher.ShutdownStarted += blockingShutdownHandler; + Task? shutdown = null; + try + { + await dispatcher.InvokeAsync(static () => { }); + Task cleanup = SourceVideo.DisposeThumbnailRenderResourcesAsync(dispatcher, resource); + Assert.That(cleanupEntered.Wait(TimeSpan.FromSeconds(5)), Is.True, + "The cleanup fixture did not enter resource disposal."); + + shutdown = Task.Run(dispatcher.Shutdown); + Assert.That(shutdownHandlerEntered.Wait(TimeSpan.FromSeconds(5)), Is.True, + "Shutdown did not snapshot and enter the blocking handler."); + + releaseCleanup.Set(); + await cleanup.WaitAsync(TimeSpan.FromSeconds(5)); + releaseShutdownHandler.Set(); + await shutdown.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.That(resource.DisposeCount, Is.EqualTo(1)); + } + finally + { + releaseCleanup.Set(); + releaseShutdownHandler.Set(); + dispatcher.ShutdownStarted -= blockingShutdownHandler; + if (!dispatcher.HasShutdownStarted) + dispatcher.Shutdown(); + if (shutdown is not null) + await shutdown.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(dispatcher.Thread.Join(TimeSpan.FromSeconds(5)), Is.True, + "The local dispatcher did not finish after the shutdown race fixture was released."); + } + } + + private sealed class DisposalThreadProbe : IDisposable + { + public int DisposeCount { get; private set; } + + public bool DisposedOnRenderThread { get; private set; } + + public void Dispose() + { + DisposeCount++; + DisposedOnRenderThread = RenderThread.Dispatcher.CheckAccess(); + } + } + + private sealed class BlockingDisposalProbe( + ManualResetEventSlim entered, + ManualResetEventSlim release) : IDisposable + { + private int _disposeCount; + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public void Dispose() + { + Interlocked.Increment(ref _disposeCount); + entered.Set(); + release.Wait(); + } + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Transformation/TransformHandleMathTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Transformation/TransformHandleMathTests.cs index a4d6fc6b7d..1e99d61117 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Transformation/TransformHandleMathTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Transformation/TransformHandleMathTests.cs @@ -460,6 +460,81 @@ public void AlignUserMatrixToRenderedBounds_RotatedMatrix_OffsetComputedFromAabb Assert.That(result, Is.EqualTo(userMatrix)); } + // The shape of transforms.rot3d.depth-0050 at editor scale: a layer centred in a 256x144 frame whose + // Y rotation carries a perspective divisor, so part of it sits behind the camera plane. + private static Matrix ComposeCenteredRotation(float width, float height, float rotationY, float depth) + { + float radians = MathF.PI * rotationY / 180f; + var rotation = new Matrix( + MathF.Cos(radians), 0, MathF.Sin(radians) / depth, + 0, 1, 0, + 0, 0, 1); + return Matrix.CreateTranslation(-width / 2, -height / 2) + * rotation + * Matrix.CreateTranslation(128, 72); + } + + [TestCase(1200f, 54f, 60.0f, 500f)] + [TestCase(1200f, 54f, 89.5f, 500f)] + [TestCase(124f, 58f, 60.0f, 10f)] + public void AlignUserMatrixToRenderedBounds_PerspectiveCrossingTheCameraPlane_NeedsNoCorrection( + float width, float height, float rotationY, float depth) + { + Matrix userMatrix = ComposeCenteredRotation(width, height, rotationY, depth); + var localSize = new Size(width, height); + var local = new Rect(localSize); + Rect renderedBounds = local.TransformToAABB(userMatrix); + + // Without the clip the reference centre lands on the far side of the image, which is the + // difference this alignment would otherwise read as an effect offset. + float mirroredGap = MathF.Abs( + local.TransformToMappedCornerAABB(userMatrix).Center.X - renderedBounds.Center.X); + TestContext.WriteLine($"unclipped reference centre is off by {mirroredGap}px"); + Assert.That(mirroredGap, Is.GreaterThan(0.5f), "the fixture must move the centre past the alignment epsilon"); + + Matrix result = TransformHandleMath.AlignUserMatrixToRenderedBounds(userMatrix, localSize, renderedBounds); + + Assert.That(result, Is.EqualTo(userMatrix)); + } + + [Test] + public void AlignUserMatrixToRenderedBounds_PerspectiveWithAnEffectOffset_AppliesOnlyThatOffset() + { + Matrix userMatrix = ComposeCenteredRotation(1200, 54, 60f, 500f); + var localSize = new Size(1200, 54); + var local = new Rect(localSize); + Rect renderedBounds = local.TransformToAABB(userMatrix).Translate(new Vector(10, 5)); + + Matrix result = TransformHandleMath.AlignUserMatrixToRenderedBounds(userMatrix, localSize, renderedBounds); + + Matrix expected = userMatrix * Matrix.CreateTranslation(10, 5); + Assert.Multiple(() => + { + foreach (Point corner in new[] { local.TopLeft, local.TopRight, local.BottomRight, local.BottomLeft }) + { + Point actualCorner = result.Transform(corner); + Point expectedCorner = expected.Transform(corner); + Assert.That(actualCorner.X, Is.EqualTo(expectedCorner.X).Within(0.05f)); + Assert.That(actualCorner.Y, Is.EqualTo(expectedCorner.Y).Within(0.05f)); + } + }); + } + + [Test] + public void AlignUserMatrixToRenderedBounds_WhenNothingReachesTheNearPlane_ReturnsUserMatrix() + { + // w(x) = 0.02 - 0.0004x over a 100-wide rect: it crosses zero, and the part still in front of + // the camera is nearer than the near plane, so there is no reference box to align against. + var userMatrix = new Matrix(1, 0, -0.0004f, 0, 1, 0, 0, 0, 0.02f); + var localSize = new Size(100, 50); + Assert.That(new Rect(localSize).TransformToAABB(userMatrix).IsEmpty, Is.True); + + Matrix result = TransformHandleMath.AlignUserMatrixToRenderedBounds( + userMatrix, localSize, new Rect(-500, -500, 1000, 1000)); + + Assert.That(result, Is.EqualTo(userMatrix)); + } + // ===== LockAspect ===== [Test] diff --git a/tests/Beutl.UnitTests/Engine/Graphics3D/DetachedMeshResourceTests.cs b/tests/Beutl.UnitTests/Engine/Graphics3D/DetachedMeshResourceTests.cs new file mode 100644 index 0000000000..d9ff5fcb60 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Graphics3D/DetachedMeshResourceTests.cs @@ -0,0 +1,161 @@ +using Beutl.Composition; +using Beutl.Graphics.Backend; +using Beutl.Graphics3D; +using Beutl.Graphics3D.Meshes; +using Beutl.Graphics3D.Models; +using Beutl.Graphics3D.Nodes; +using Moq; + +namespace Beutl.UnitTests.Engine.Graphics3D; + +/// +/// Mesh had the same shape as Geometry: EnsureCached dispatched through the backing +/// engine object, so a publicly constructed resource threw. Generation now dispatches on the resource. +/// +[TestFixture] +public sealed class DetachedMeshResourceTests +{ + [Test] + public void ADetachedCube_GeneratesTheSameMeshAsItsAttachedCounterpart() + { + using var detached = new CubeMesh.Resource { Width = 2, Height = 3, Depth = 4 }; + using Mesh.Resource attached = new CubeMesh + { + Width = { CurrentValue = 2 }, + Height = { CurrentValue = 3 }, + Depth = { CurrentValue = 4 }, + }.ToResource(CompositionContext.Default); + + using (Assert.EnterMultipleScope()) + { + Assert.That(detached.GetVertices().ToArray(), Is.EqualTo(attached.GetVertices().ToArray()).AsCollection); + Assert.That(detached.GetIndices().ToArray(), Is.EqualTo(attached.GetIndices().ToArray()).AsCollection); + Assert.That(detached.GetBoundingBox().Min, Is.EqualTo(attached.GetBoundingBox().Min)); + Assert.That(detached.GetBoundingBox().Max, Is.EqualTo(attached.GetBoundingBox().Max)); + } + } + + /// + /// A draw binds whatever buffers the resource holds and asks for a count. Taking that count from the mesh + /// as it is now rather than from what an upload actually put on the device reads past the end of the + /// buffers whenever the topology grew, and leaving the previous topology's buffers behind lets a mesh with + /// nothing left in it still bind them. + /// + [Test] + public void AnUploadRecordsWhatItPutOnTheDevice_AndAnEmptyMeshKeepsNothing() + { + using var cube = new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }; + IGraphicsContext context = CreateBufferingContext(); + + using var populated = new ModelMesh.Resource + { + Vertices = [.. cube.GetVertices()], + Indices = [.. cube.GetIndices()], + }; + MeshBufferUploadHelper.Ensure(context, populated); + + // The state a resource is left in when its topology drops to nothing after an upload. + using var emptied = new ModelMesh.Resource { Vertices = [], Indices = [] }; + emptied.VertexBuffer = new FakeBuffer(1); + emptied.IndexBuffer = new FakeBuffer(1); + emptied.UploadedIndexCount = cube.IndexCount; + emptied.BuffersDirty = true; + + MeshBufferUploadHelper.Ensure(context, emptied); + + using (Assert.EnterMultipleScope()) + { + Assert.That(populated.UploadedIndexCount, Is.EqualTo(cube.IndexCount)); + Assert.That(emptied.UploadedIndexCount, Is.Zero); + Assert.That(emptied.VertexBuffer, Is.Null); + Assert.That(emptied.IndexBuffer, Is.Null); + Assert.That(emptied.BuffersDirty, Is.False, "An emptied mesh has nothing left to upload."); + } + } + + private static IGraphicsContext CreateBufferingContext() + { + var context = new Mock(); + context + .Setup(x => x.CreateBuffer(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ulong size, BufferUsage _, MemoryProperty _) => new FakeBuffer(size)); + return context.Object; + } + + private sealed class FakeBuffer(ulong size) : IBuffer + { + public ulong Size { get; } = size; + + public void Upload(ReadOnlySpan data) where T : unmanaged + { + } + + public IntPtr Map() => IntPtr.Zero; + + public void Unmap() + { + } + + public void Dispose() + { + } + } + + [Test] + public void EveryDetachedBuiltInMesh_ReportsItsCounts() + { + using var cube = new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }; + using var plane = new PlaneMesh.Resource { Width = 2, Height = 2, WidthSegments = 2, HeightSegments = 3 }; + using var sphere = new SphereMesh.Resource { Radius = 1, Segments = 8, Rings = 4 }; + using var model = new ModelMesh.Resource + { + Vertices = [.. new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }.GetVertices()], + Indices = [.. new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }.GetIndices()], + }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(cube.VertexCount, Is.EqualTo(24)); + Assert.That(plane.VertexCount, Is.EqualTo(12)); + Assert.That(sphere.VertexCount, Is.EqualTo(45)); + Assert.That(model.VertexCount, Is.EqualTo(24)); + Assert.That(model.IndexCount, Is.EqualTo(cube.IndexCount)); + } + } + + /// + /// MeshBufferUploadHelper and TransparentPass clear BuffersDirty once they have + /// uploaded the current vertices, so a regenerated mesh that leaves it clear keeps the GPU on the old + /// buffers. + /// + [Test] + public void RegeneratingAMesh_MarksItsGpuBuffersDirtyAgain() + { + using var mesh = new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }; + _ = mesh.GetVertices(); + mesh.BuffersDirty = false; + + mesh.Width = 4; + mesh.Version++; + ReadOnlySpan regenerated = mesh.GetVertices(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(mesh.GetBoundingBox().Max.X, Is.EqualTo(2)); + Assert.That(regenerated.Length, Is.EqualTo(24)); + Assert.That(mesh.BuffersDirty, Is.True); + } + } + + [Test] + public void AMeshServedFromItsCache_LeavesTheGpuBufferFlagAlone() + { + using var mesh = new CubeMesh.Resource { Width = 1, Height = 1, Depth = 1 }; + _ = mesh.GetVertices(); + mesh.BuffersDirty = false; + + _ = mesh.GetVertices(); + + Assert.That(mesh.BuffersDirty, Is.False); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Graphics3D/DrawableTextureSourceDensityTests.cs b/tests/Beutl.UnitTests/Engine/Graphics3D/DrawableTextureSourceDensityTests.cs index 1d3617403d..92542053e3 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics3D/DrawableTextureSourceDensityTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics3D/DrawableTextureSourceDensityTests.cs @@ -1,9 +1,12 @@ using Beutl.Composition; using Beutl.Graphics.Backend; +using Beutl.Graphics.Rendering; using Beutl.Graphics.Shapes; using Beutl.Graphics3D.Textures; using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering.Failure; +using Moq; namespace Beutl.UnitTests.Engine.Graphics3D; @@ -29,6 +32,21 @@ private static DrawableTextureSource.Resource MakeVectorTextureSource() return (DrawableTextureSource.Resource)source.ToResource(CompositionContext.Default); } + [Test] + public void GetTexture_EmptyDrawableReturnsNullInsideDeferredCallback() + { + var sourceDefinition = new DrawableTextureSource(); + using var source = + (DrawableTextureSource.Resource)sourceDefinition.ToResource(CompositionContext.Default); + using IDisposable callback = RenderExecutionCallbackGuard.Enter(); + + ITexture2D? texture = null; + Assert.That( + () => texture = source.GetTexture(Mock.Of()), + Throws.Nothing); + Assert.That(texture, Is.Null); + } + [Test] public void GetTexture_VectorDrawable_RasterizesAtSurfaceDensity() { @@ -59,4 +77,42 @@ public void GetTexture_VectorDrawable_RasterizesAtSurfaceDensity() Assert.That(height2, Is.EqualTo(height1 * 2)); }); } + + [Test] + public void GetTexture_NestedOversizedDrawableUsesTheClampedRecordingDensity() + { + var sourceDefinition = new DrawableTextureSource(); + sourceDefinition.TextureWidth.CurrentValue = 8192; + sourceDefinition.TextureHeight.CurrentValue = 1; + using var source = + (DrawableTextureSource.Resource)sourceDefinition.ToResource(CompositionContext.Default); + using var registry = new RenderTargetLeaseRegistry(new CpuTargetFactory()); + using RenderTargetLeaseSession session = registry.BeginSession(RenderIntent.Preview); + RenderTargetLease lease = session.Acquire( + new PixelSize(RenderScaleUtilities.MaxBufferDimension, 2)); + using var binding = new NestedRenderTargetBinding(); + binding.Stage( + lease, + source.TextureDomain, + density: 2); + binding.PrepareForSampling(); + ITexture2D? texture = null; + + Assert.That( + () => NestedRenderTargetBindingScope.Use( + source, + binding, + () => texture = source.GetTexture(Mock.Of(), surfaceDensity: 4)), + Throws.Nothing); + Assert.That(texture, Is.Null, + "A CPU-backed nested target has no GPU texture, but its resolved density must still match."); + } + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => FailureTestSupport.CreateCpuTarget( + allocation.DeviceSize.Width, + allocation.DeviceSize.Height); + } } diff --git a/tests/Beutl.UnitTests/Engine/Graphics3D/Scene3DRenderNodeScaleTests.cs b/tests/Beutl.UnitTests/Engine/Graphics3D/Scene3DRenderNodeScaleTests.cs index 8fc60d4529..79fcb7266e 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics3D/Scene3DRenderNodeScaleTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics3D/Scene3DRenderNodeScaleTests.cs @@ -1,6 +1,13 @@ using Beutl.Composition; +using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; using Beutl.Graphics3D; +using Beutl.Graphics3D.Materials; +using Beutl.Graphics3D.Primitives; +using Beutl.Graphics3D.Textures; +using Beutl.Media; using Beutl.UnitTests.Engine.Graphics.Backend; namespace Beutl.UnitTests.Engine.Graphics3D; @@ -10,40 +17,288 @@ namespace Beutl.UnitTests.Engine.Graphics3D; public class Scene3DRenderNodeScaleTests { [Test] - public void Process_RespectsMaxWorkingScale_WhenOutputScaleIsHigher() + public void AllocationFailure_IsPropagatedOnlyForDelivery() { - var graphicsContext = VulkanTestEnvironment.EnsureAvailable(); - if (!graphicsContext.Supports3DRendering) - { - Assert.Ignore("3D rendering is not supported on this GPU."); - } + var failure = new InvalidOperationException("3D allocation failed"); + + Assert.That( + () => Scene3DRenderNode.ThrowIfDeliveryAllocationFailure(RenderIntent.Preview, failure), + Throws.Nothing); + InvalidOperationException? thrown = Assert.Throws( + () => Scene3DRenderNode.ThrowIfDeliveryAllocationFailure(RenderIntent.Delivery, failure)); + Assert.That(thrown, Is.SameAs(failure)); + } + + [Test] + public void Measure_RespectsMaxWorkingScale_WhenOutputScaleIsHigher() + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 32; + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + using var node = new Scene3DRenderNode(resource); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + OutputScale = 2, + MaxWorkingScale = 0.5f, + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.HasFragments, Is.True, + "Scene3DRenderNode emitted no fragment for a valid scene"); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(0.5f).Within(1e-4)); + } - VulkanTestEnvironment.InvokeOnRenderThread(() => + [Test] + public void Recording_AllowsTheBackendToDropAFrameValue() + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + using var node = new Scene3DRenderNode(resource); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 32, 24), + cachePolicy: RenderCacheOptions.Disabled)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RenderFragmentReference reference = graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Single(static fragment => fragment.Kind == RenderFragmentKind.OpaqueSource); + var payload = (OpaqueRenderFragmentPayload)reference.Payload!; + + Assert.That( + payload.Description.ValueCardinality, + Is.EqualTo(RenderValueCardinality.ZeroOrOne)); + } + + [Test] + public void Recording_DrawableMaterialTextureUsesSceneWorkingScaleAndFullDomain() + { + var drawable = new RectShape(); + drawable.Width.CurrentValue = 5; + drawable.Height.CurrentValue = 3; + drawable.Fill.CurrentValue = Brushes.Red; + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = drawable; + texture.TextureWidth.CurrentValue = 11; + texture.TextureHeight.CurrentValue = 7; + var material = new BasicMaterial(); + material.DiffuseMap.CurrentValue = texture; + var cube = new Cube3D(); + cube.Material.CurrentValue = material; + + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + resource.Objects.Add((Object3D.Resource)cube.ToResource(CompositionContext.Default)); + using var node = new Scene3DRenderNode(resource); + using var owner = new RenderRequestOwner(); + var options = new RenderRequestOptions( + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 32, 24), + requestedRegion: new Rect(1, 2, 20, 10), + outputScale: 1.75f, + maxWorkingScale: 0.75f, + cachePolicy: RenderCacheOptions.Disabled, + fusionMode: FusionMode.Disabled, + owner: owner); + using var request = new RenderRequest(options); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RecordedNestedRenderRequest nested = graph.NestedRequests.Single(); + + Assert.Multiple(() => { - var scene = new Scene3D(); - scene.RenderWidth.CurrentValue = 32; - scene.RenderHeight.CurrentValue = 32; - var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + Assert.That(graph.PublicationRoots, Has.Length.EqualTo(1)); + Assert.That(nested.Request.State, Is.EqualTo(RenderRequestState.Recorded)); + Assert.That(nested.Request.Options.TargetDomain, Is.EqualTo(new Rect(0, 0, 11, 7))); + Assert.That(nested.Request.Options.RequestedRegion, Is.EqualTo(new Rect(0, 0, 11, 7))); + Assert.That(nested.Request.Options.Intent, Is.EqualTo(options.Intent)); + Assert.That(nested.Request.Options.Purpose, Is.EqualTo(options.Purpose)); + Assert.That(nested.Request.Options.OutputScale, Is.EqualTo(0.75f)); + Assert.That(nested.Request.Options.MaxWorkingScale, Is.EqualTo(0.75f)); + Assert.That(nested.Request.Options.CachePolicy, Is.EqualTo(options.CachePolicy)); + Assert.That(nested.Request.Options.FusionMode, Is.EqualTo(options.FusionMode)); + Assert.That(nested.Request.Options.Owner, Is.SameAs(owner)); + Assert.That(nested.Request.Options.TargetBinding, Is.Not.Null); + Assert.That(nested.Request.Options.TargetBinding!.IsReady, Is.False, + "CPU recording must not allocate, execute, or prepare the nested target."); + Assert.That(nested.Request.Options.TargetBinding.DeviceBounds, Is.EqualTo(default(PixelRect))); + }); + } - using var node = new Scene3DRenderNode(resource); - var context = new RenderNodeContext([], outputScale: 2f, maxWorkingScale: 0.5f); + [Test] + public void Recording_OversizedDrawableTextureClampsTheNestedTargetDensity() + { + var drawable = new RectShape(); + drawable.Width.CurrentValue = 1; + drawable.Height.CurrentValue = 1; + drawable.Fill.CurrentValue = Brushes.Red; + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = drawable; + texture.TextureWidth.CurrentValue = 8192; + texture.TextureHeight.CurrentValue = 1; + var material = new BasicMaterial(); + material.DiffuseMap.CurrentValue = texture; + var cube = new Cube3D(); + cube.Material.CurrentValue = material; - RenderNodeOperation[] ops = node.Process(context); + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + using var resource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + resource.Objects.Add((Object3D.Resource)cube.ToResource(CompositionContext.Default)); + using var node = new Scene3DRenderNode(resource); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Delivery, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 32, 24), + outputScale: 4, + maxWorkingScale: 4, + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); - Assert.That(ops, Is.Not.Empty, "Scene3DRenderNode emitted no operation for a valid scene"); - Assert.That(ops[0].EffectiveScale.IsUnbounded, Is.False); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(0.5f).Within(1e-4f)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + RecordedNestedRenderRequest nested = graph.NestedRequests.Single(); + float expectedDensity = RenderScaleUtilities.MaxBufferDimension / 8192f; - DisposeAll(ops); - resource.Dispose(); + Assert.Multiple(() => + { + Assert.That(nested.Request.Options.TargetDomain, Is.EqualTo(new Rect(0, 0, 8192, 1))); + Assert.That(nested.Request.Options.OutputScale, Is.EqualTo(expectedDensity)); + Assert.That(nested.Request.Options.MaxWorkingScale, Is.EqualTo(expectedDensity)); + Assert.That( + PixelRect.FromRect(nested.Request.Options.TargetDomain!.Value, expectedDensity).Width, + Is.EqualTo(RenderScaleUtilities.MaxBufferDimension)); }); } - private static void DisposeAll(RenderNodeOperation[] ops) + [Test] + public void Recording_DrawableTextureThatReferencesItsSceneFailsWithAnExplicitCycleAndRollsBack() + { + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + var texture = new DrawableTextureSource(); + texture.TextureWidth.CurrentValue = 11; + texture.TextureHeight.CurrentValue = 7; + var material = new BasicMaterial(); + var cube = new Cube3D(); + + var sceneResource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + var textureResource = (DrawableTextureSource.Resource)texture.ToResource(CompositionContext.Default); + var materialResource = (BasicMaterial.Resource)material.ToResource(CompositionContext.Default); + var cubeResource = (Cube3D.Resource)cube.ToResource(CompositionContext.Default); + textureResource.Drawable = sceneResource; + materialResource.DiffuseMap = textureResource; + cubeResource.Material = materialResource; + sceneResource.Objects.Add(cubeResource); + + try + { + using var node = new Scene3DRenderNode(sceneResource); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 32, 24), + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); + + InvalidOperationException? failure = Assert.Throws( + () => new RenderRequestRecorder(request).Record(node)); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("render-node recording cycle")); + Assert.That(failure.Message, Does.Contain("->")); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Failed)); + Assert.That(owner.IsCleanedUp, Is.True); + Assert.That(owner.CleanupFailures, Is.Empty); + }); + } + finally + { + sceneResource.Objects.Clear(); + cubeResource.Dispose(); + materialResource.Dispose(); + textureResource.Dispose(); + } + } + + [Test] + public void Recording_DisabledParentDoesNotPrepareEnabledDescendantDrawableTexture() { - foreach (RenderNodeOperation op in ops) + var scene = new Scene3D(); + scene.RenderWidth.CurrentValue = 32; + scene.RenderHeight.CurrentValue = 24; + var texture = new DrawableTextureSource(); + texture.TextureWidth.CurrentValue = 11; + texture.TextureHeight.CurrentValue = 7; + var material = new BasicMaterial(); + var cube = new Cube3D(); + var disabledGroup = new Group3D { IsEnabled = false }; + var outerGroup = new Group3D(); + + var sceneResource = (Scene3D.Resource)scene.ToResource(CompositionContext.Default); + var textureResource = (DrawableTextureSource.Resource)texture.ToResource(CompositionContext.Default); + var materialResource = (BasicMaterial.Resource)material.ToResource(CompositionContext.Default); + var cubeResource = (Cube3D.Resource)cube.ToResource(CompositionContext.Default); + var disabledGroupResource = (Group3D.Resource)disabledGroup.ToResource(CompositionContext.Default); + var outerGroupResource = (Group3D.Resource)outerGroup.ToResource(CompositionContext.Default); + textureResource.Drawable = sceneResource; + materialResource.DiffuseMap = textureResource; + cubeResource.Material = materialResource; + disabledGroupResource.Children.Add(cubeResource); + outerGroupResource.Children.Add(disabledGroupResource); + sceneResource.Objects.Add(outerGroupResource); + + try + { + using var node = new Scene3DRenderNode(sceneResource); + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain: new Rect(0, 0, 32, 24), + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); + + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + + Assert.Multiple(() => + { + Assert.That(graph.PublicationRoots, Has.Length.EqualTo(1)); + Assert.That(graph.NestedRequests, Is.Empty, + "A disabled 3D subtree must not record or allocate invisible drawable textures."); + Assert.That(request.State, Is.EqualTo(RenderRequestState.Recorded)); + Assert.That(owner.IsCleanedUp, Is.False); + }); + } + finally { - op.Dispose(); + sceneResource.Objects.Clear(); + outerGroupResource.Children.Clear(); + disabledGroupResource.Children.Clear(); + outerGroupResource.Dispose(); + disabledGroupResource.Dispose(); + cubeResource.Dispose(); + materialResource.Dispose(); + textureResource.Dispose(); } } } diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/DetachedGeometryResourceTests.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/DetachedGeometryResourceTests.cs new file mode 100644 index 0000000000..c599f4ca11 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/DetachedGeometryResourceTests.cs @@ -0,0 +1,179 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// A "detached" resource is one built through its public parameterless constructor rather than through +/// , so its backing engine object is null. At 989856e8d the +/// four public members a detached geometry resource needs — Bounds, GetRenderBounds, +/// FillContains, StrokeContains — were all non-virtual, so an out-of-tree author who built one +/// could not override the away. +/// +/// +/// Path construction now dispatches on the resource's own type, so a detached resource produces the same path +/// its attached counterpart does. +/// +[TestFixture] +public sealed class DetachedGeometryResourceTests +{ + [Test] + public void ADetachedEllipse_ProducesTheSameBoundsAsItsAttachedCounterpart() + { + using var detached = new EllipseGeometry.Resource { Width = 100, Height = 50 }; + using Geometry.Resource attached = new EllipseGeometry + { + Width = { CurrentValue = 100 }, + Height = { CurrentValue = 50 }, + }.ToResource(CompositionContext.Default); + + Assert.That(detached.Bounds, Is.EqualTo(attached.Bounds)); + } + + [Test] + public void ADetachedRect_ProducesTheSameBoundsAsItsAttachedCounterpart() + { + using var detached = new RectGeometry.Resource { Width = 30, Height = 40 }; + using Geometry.Resource attached = new RectGeometry + { + Width = { CurrentValue = 30 }, + Height = { CurrentValue = 40 }, + }.ToResource(CompositionContext.Default); + + Assert.That(detached.Bounds, Is.EqualTo(attached.Bounds)); + } + + [Test] + public void EveryPublicEntryPointOfADetachedEllipse_Answers() + { + using var pen = new Pen + { + Brush = { CurrentValue = Brushes.Black }, + Thickness = { CurrentValue = 4 }, + }.ToResource(CompositionContext.Default); + + using (Assert.EnterMultipleScope()) + { + Assert.That(Fresh().Bounds, Is.EqualTo(new Rect(0, 0, 100, 50))); + Assert.That(Fresh().GetRenderBounds(null), Is.EqualTo(new Rect(0, 0, 100, 50))); + Assert.That(Fresh().GetRenderBounds(pen), Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(Fresh().FillContains(new Point(50, 25)), Is.True); + Assert.That(Fresh().StrokeContains(null, new Point(0, 25)), Is.False); + Assert.That(Fresh().StrokeContains(pen, new Point(0, 25)), Is.True); + } + + static EllipseGeometry.Resource Fresh() => new() { Width = 100, Height = 50 }; + } + + [Test] + public void ADetachedPathGeometryWithDetachedFiguresAndSegments_BuildsThatPath() + { + using var detached = new PathGeometry.Resource + { + Figures = + { + new PathFigure.Resource + { + StartPoint = new Point(0, 0), + IsClosed = true, + Segments = + { + new LineSegment.Resource { Point = new Point(60, 0) }, + new LineSegment.Resource { Point = new Point(60, 20) }, + }, + }, + }, + }; + + Assert.That(detached.Bounds, Is.EqualTo(new Rect(0, 0, 60, 20))); + } + + [Test] + public void AnAttachedPathGeometryHoldingADetachedFigure_BuildsThatFigure() + { + var geometry = new PathGeometry(); + geometry.Figures.Add(new PathFigure + { + StartPoint = { CurrentValue = new Point(0, 0) }, + Segments = { new LineSegment(new Point(10, 0)) }, + }); + using PathGeometry.Resource resource = + (PathGeometry.Resource)geometry.ToResource(CompositionContext.Default); + resource.Figures.Add(new PathFigure.Resource + { + StartPoint = new Point(0, 0), + Segments = { new LineSegment.Resource { Point = new Point(80, 40) } }, + }); + resource.InvalidateCachedPaths(); + + Assert.That(resource.Bounds, Is.EqualTo(new Rect(0, 0, 80, 40))); + } + + [Test] + public void GeometryRenderNode_WithADetachedGeometry_RecordsAndRasterizes() + { + using var detached = new EllipseGeometry.Resource { Width = 40, Height = 30 }; + using var node = new GeometryRenderNode(detached, Brushes.Resource.White, null); + + using var owner = new RenderRequestOwner(); + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Auxiliary, + outputScale: 1, + maxWorkingScale: 1, + targetDomain: new Rect(0, 0, 64, 64), + cachePolicy: RenderCacheOptions.Disabled, + owner: owner)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(node); + + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest { CacheOptions = RenderCacheOptions.Disabled }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("Detached geometry rasterization returned no bitmap."); + ReadOnlySpan pixels = bitmap.GetPixelSpan(); + int center = ((bitmap.Height / 2 * bitmap.Width) + (bitmap.Width / 2)) * 4; + float red = (float)BitConverter.UInt16BitsToHalf(pixels[center]); + float green = (float)BitConverter.UInt16BitsToHalf(pixels[center + 1]); + float blue = (float)BitConverter.UInt16BitsToHalf(pixels[center + 2]); + float alpha = (float)BitConverter.UInt16BitsToHalf(pixels[center + 3]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(graph.PublicationRoots.Count(), Is.EqualTo(1)); + Assert.That(rasterization.Bounds, Is.EqualTo(new Rect(0, 0, 40, 30))); + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(alpha, Is.GreaterThan(0.9f), "the detached ellipse center must be opaque"); + Assert.That(red, Is.GreaterThan(0.9f), "the detached ellipse center must retain its white fill"); + Assert.That(green, Is.GreaterThan(0.9f), "the detached ellipse center must retain its white fill"); + Assert.That(blue, Is.GreaterThan(0.9f), "the detached ellipse center must retain its white fill"); + } + } + + [Test] + public void GeometryClipRenderNode_WithADetachedGeometry_Measures() + { + using var detached = new RectGeometry.Resource { Width = 30, Height = 40 }; + using var node = new GeometryClipRenderNode(detached, ClipOperation.Intersect); + node.AddChild(new RectangleRenderNode(new Rect(0, 0, 100, 100), Brushes.Resource.White, null)); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest { CacheOptions = RenderCacheOptions.Disabled }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 30, 40))); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathAllocationTests.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathAllocationTests.cs new file mode 100644 index 0000000000..1e313c5cd4 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathAllocationTests.cs @@ -0,0 +1,96 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// GetCachedPath is on the render path, so a cache hit has to stay allocation-free and a rebuild must +/// not cost more than the pre-change construction transcribed in . +/// +[TestFixture] +public sealed class GeometryPathAllocationTests +{ + private const int Iterations = 20000; + private const int Rounds = 5; + + [Test] + public void ACacheHit_DoesNotAllocate() + { + using Geometry.Resource resource = CreateGeometry().ToResource(CompositionContext.Default); + _ = resource.GetCachedPath(); + + Assert.That(Measure(() => resource.GetCachedPath(), Iterations), Is.Zero); + } + + [Test] + public void ADetachedCacheHit_DoesNotAllocate() + { + using var resource = new EllipseGeometry.Resource { Width = 100, Height = 50 }; + _ = resource.GetCachedPath(); + + Assert.That(Measure(() => resource.GetCachedPath(), Iterations), Is.Zero); + } + + [Test] + public void ARebuild_DoesNotCostMoreThanThePreChangeConstruction() + { + using Geometry.Resource shipped = CreateGeometry().ToResource(CompositionContext.Default); + using Geometry.Resource reference = CreateGeometry().ToResource(CompositionContext.Default); + + const int rebuildIterations = 2000; + long before = Measure( + () => + { + using GeometryContext context = PreChangeGeometryPath.Build(reference); + _ = context.NativeObject; + }, + rebuildIterations); + long after = Measure( + () => + { + shipped.InvalidateCachedPaths(); + _ = shipped.GetCachedPath(); + }, + rebuildIterations); + + Assert.That(after, Is.LessThanOrEqualTo(before), + $"rebuild allocated {after} bytes against the pre-change {before}"); + } + + private static PathGeometry CreateGeometry() + { + var geometry = new PathGeometry(); + geometry.Figures.Add(new PathFigure + { + StartPoint = { CurrentValue = new Point(5, 5) }, + IsClosed = { CurrentValue = true }, + Segments = + { + new LineSegment(new Point(50, 5)), + new QuadraticBezierSegment(new Point(70, 15), new Point(50, 35)), + new CubicBezierSegment(new Point(40, 55), new Point(20, 55), new Point(10, 40)), + }, + }); + return geometry; + } + + private static long Measure(Action action, int iterations) + { + for (int index = 0; index < 200; index++) + action(); + + long best = long.MaxValue; + for (int round = 0; round < Rounds; round++) + { + long start = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < iterations; index++) + action(); + best = Math.Min(best, GC.GetAllocatedBytesForCurrentThread() - start); + } + + return best; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathCacheFailureTests.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathCacheFailureTests.cs new file mode 100644 index 0000000000..cac7dc3be9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathCacheFailureTests.cs @@ -0,0 +1,167 @@ +using Beutl.Graphics; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// A failing ApplyTo must not be able to install a half-built path behind the version guard. +/// +/// +/// reproduces the field-write order this cache used at 989856e8d, so the +/// difference is measured in one process rather than read off the diff. +/// +[TestFixture] +public sealed class GeometryPathCacheFailureTests +{ + [Test] + public void PreChangeOrdering_ServedWhateverWasBuiltBeforeTheThrow() + { + using var partial = new PreChangeOrdering(); + using var immediate = new PreChangeOrdering(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => partial.GetCachedPath(ThrowingApplyTo)); + Assert.That(partial.GetCachedPath(ThrowingApplyTo).TightBounds.ToGraphicsRect(), + Is.EqualTo(new Rect(0, 0, 20, 10)), + "the guard passes on the next call and hands back the two segments recorded before the throw"); + Assert.That(partial.GetCachedPath(ThrowingApplyTo).TightBounds.ToGraphicsRect(), + Is.EqualTo(new Rect(0, 0, 20, 10))); + + Assert.Throws( + () => immediate.GetCachedPath(static _ => throw new InvalidOperationException("author failure"))); + Assert.That( + immediate.GetCachedPath(static _ => throw new InvalidOperationException("author failure")) + .TightBounds.ToGraphicsRect(), + Is.EqualTo(default(Rect)), + "an author that throws before recording anything degrades to an empty path instead"); + } + } + + [Test] + public void ShippedOrdering_KeepsThrowingInsteadOfServingAnEmptyPath() + { + using var resource = new ThrowingGeometryResource(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => _ = resource.Bounds); + Assert.Throws(() => _ = resource.Bounds); + Assert.Throws(() => _ = resource.FillContains(new Point(1, 1))); + Assert.Throws(() => _ = resource.GetRenderBounds(null)); + Assert.That(resource.Calls, Is.EqualTo(4), "each entry point must retry the build, not serve a stale one"); + } + } + + [Test] + public void ARebuildThatSucceedsAfterAFailure_ProducesTheCompletePath() + { + using var resource = new ThrowingGeometryResource(); + + Assert.Throws(() => _ = resource.Bounds); + resource.Throw = false; + + Assert.That(resource.Bounds, Is.EqualTo(new Rect(0, 0, 20, 10))); + } + + [Test] + public void AFailedRebuild_DoesNotReleaseThePathAPreviousBuildProduced() + { + using var resource = new ThrowingGeometryResource { Throw = false }; + SKPath first = resource.GetCachedPath(); + + resource.Throw = true; + resource.InvalidateCachedPaths(); + Assert.Throws(() => _ = resource.Bounds); + + // SKPath.Handle is zero once the owning GeometryContext has disposed it. This must stop the test + // rather than collect into a multiple scope: reading TightBounds off a released path crashes the host. + Assert.That(first.Handle, Is.Not.EqualTo(IntPtr.Zero), + "the failed rebuild released the path the previous successful build produced"); + Assert.That(first.TightBounds.ToGraphicsRect(), Is.EqualTo(new Rect(0, 0, 20, 10))); + + resource.Throw = false; + resource.InvalidateCachedPaths(); + Assert.That(resource.Bounds, Is.EqualTo(new Rect(0, 0, 20, 10))); + } + + [Test] + public void AFallbackSegment_MakesTheEnclosingGeometryKeepThrowing() + { + using var resource = new PathGeometry.Resource + { + Figures = + { + new PathFigure.Resource + { + StartPoint = new Point(0, 0), + Segments = { new FallbackPathSegment.Resource() }, + }, + }, + }; + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => _ = resource.Bounds); + Assert.Throws(() => _ = resource.Bounds); + } + } + + private static void ThrowingApplyTo(IGeometryContext context) + { + context.MoveTo(new Point(0, 0)); + context.LineTo(new Point(20, 10)); + throw new InvalidOperationException("author failure"); + } + + private sealed class ThrowingGeometryResource : Geometry.Resource + { + public ThrowingGeometryResource() + { + } + + public bool Throw { get; set; } = true; + + public int Calls { get; private set; } + + public override void ApplyTo(IGeometryContext context) + { + Calls++; + context.MoveTo(new Point(0, 0)); + context.LineTo(new Point(20, 10)); + if (Throw) + throw new InvalidOperationException("author failure"); + } + } + + /// + /// The cache orchestration Geometry.Resource.GetCachedPath used at 989856e8d, transcribed so its + /// behaviour on a throwing build can be observed alongside the shipped one. + /// + private sealed class PreChangeOrdering : IDisposable + { + private int? _capturedVersion; + private GeometryContext? _cachedPath; + + public int Version { get; set; } + + public SKPath GetCachedPath(Action applyTo) + { + if (_capturedVersion != Version || _cachedPath == null) + { + _capturedVersion = Version; + _cachedPath?.Dispose(); + + _cachedPath = new GeometryContext(); + applyTo(_cachedPath); + } + + return _cachedPath.NativeObject; + } + + public void Dispose() => _cachedPath?.Dispose(); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathParityTests.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathParityTests.cs new file mode 100644 index 0000000000..41d432a929 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryPathParityTests.cs @@ -0,0 +1,265 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// Compares the shipped path against , a transcription of the same +/// construction as it stood at 989856e8d, in one process and element by element. +/// +[TestFixture] +public sealed class GeometryPathParityTests +{ + private static IEnumerable Cases() + { + yield return new TestCaseData((Func)(() => new EllipseGeometry + { + Width = { CurrentValue = 100 }, + Height = { CurrentValue = 50 }, + })).SetName("Ellipse"); + + yield return new TestCaseData((Func)(() => new EllipseGeometry + { + Width = { CurrentValue = float.PositiveInfinity }, + Height = { CurrentValue = 50 }, + })).SetName("EllipseWithInfiniteWidth"); + + yield return new TestCaseData((Func)(() => new RectGeometry + { + Width = { CurrentValue = 30 }, + Height = { CurrentValue = 40 }, + })).SetName("Rect"); + + yield return new TestCaseData((Func)(() => + { + var geometry = new RectGeometry + { + Width = { CurrentValue = 30 }, + Height = { CurrentValue = 40 }, + }; + geometry.FillType.CurrentValue = PathFillType.EvenOdd; + geometry.Transform.CurrentValue = new RotationTransform { Rotation = { CurrentValue = 30 } }; + return geometry; + })).SetName("RectWithTransformAndFillType"); + + yield return new TestCaseData((Func)(() => new RoundedRectGeometry + { + Width = { CurrentValue = 120 }, + Height = { CurrentValue = 80 }, + CornerRadius = { CurrentValue = new CornerRadius(12, 4, 30, 0) }, + Smoothing = { CurrentValue = 60 }, + })).SetName("RoundedRectSmoothed"); + + yield return new TestCaseData((Func)(() => new RoundedRectGeometry + { + Width = { CurrentValue = 120 }, + Height = { CurrentValue = 80 }, + CornerRadius = { CurrentValue = new CornerRadius(0) }, + Smoothing = { CurrentValue = 0 }, + })).SetName("RoundedRectSquare"); + + yield return new TestCaseData((Func)(() => + PathGeometry.Parse("M 10 10 L 60 10 Q 80 30 60 50 C 40 60 20 60 10 50 Z"))) + .SetName("PathGeometryParsed"); + + yield return new TestCaseData((Func)AllSegmentKinds).SetName("PathGeometryAllSegmentKinds"); + + yield return new TestCaseData((Func)(() => + { + PathGeometry geometry = AllSegmentKinds(); + geometry.Figures[0].StartPoint.CurrentValue = new Point(float.NaN, float.NaN); + return geometry; + })).SetName("PathGeometryWithoutStartPoint"); + + yield return new TestCaseData((Func)(() => + { + PathGeometry geometry = AllSegmentKinds(); + geometry.Figures[0].StartPoint.CurrentValue = new Point(float.NaN, float.NaN); + geometry.Figures[0].IsClosed.CurrentValue = true; + return geometry; + })).SetName("PathGeometryClosedWithoutStartPoint"); + + yield return new TestCaseData((Func)(() => + { + PathGeometry geometry = AllSegmentKinds(); + geometry.Figures.Add(new PathFigure + { + StartPoint = { CurrentValue = new Point(200, 200) }, + Segments = { new LineSegment(new Point(260, 240)) }, + }); + return geometry; + })).SetName("PathGeometryMultipleFigures"); + } + + private static PathGeometry AllSegmentKinds() + { + var geometry = new PathGeometry(); + geometry.Figures.Add(new PathFigure + { + StartPoint = { CurrentValue = new Point(5, 5) }, + Segments = + { + new LineSegment(new Point(50, 5)), + new QuadraticBezierSegment(new Point(70, 15), new Point(50, 35)), + new CubicBezierSegment(new Point(40, 55), new Point(20, 55), new Point(10, 40)), + new ConicSegment(new Point(0, 25), new Point(5, 5), 0.7f), + new ArcSegment + { + Radius = { CurrentValue = new Size(20, 12) }, + RotationAngle = { CurrentValue = 15 }, + IsLargeArc = { CurrentValue = true }, + SweepClockwise = { CurrentValue = false }, + Point = { CurrentValue = new Point(40, 20) }, + }, + }, + }); + return geometry; + } + + [TestCaseSource(nameof(Cases))] + public void ShippedPath_MatchesThePreChangePathElementByElement(Func create) + { + Geometry geometry = create(); + using Geometry.Resource resource = geometry.ToResource(CompositionContext.Default); + using GeometryContext expected = PreChangeGeometryPath.Build(resource); + + IReadOnlyList before = PreChangeGeometryPath.Describe(expected.NativeObject); + IReadOnlyList after = PreChangeGeometryPath.Describe(resource.GetCachedPath()); + + Assert.That(after, Is.EqualTo(before).AsCollection); + } + + [TestCaseSource(nameof(Cases))] + public void DetachedResource_ProducesTheSamePathAsItsAttachedCounterpart(Func create) + { + Geometry geometry = create(); + using Geometry.Resource attached = geometry.ToResource(CompositionContext.Default); + IReadOnlyList expected = PreChangeGeometryPath.Describe(attached.GetCachedPath()); + Geometry.Resource detached = Detach(attached); + try + { + IReadOnlyList actual = PreChangeGeometryPath.Describe(detached.GetCachedPath()); + + Assert.That(actual, Is.EqualTo(expected).AsCollection); + } + finally + { + detached.Dispose(); + } + } + + [TestCaseSource(nameof(Cases))] + public void StrokeAndHitTestResults_MatchThePreChangePath(Func create) + { + Geometry geometry = create(); + using Geometry.Resource resource = geometry.ToResource(CompositionContext.Default); + using Pen.Resource pen = new Pen + { + Brush = { CurrentValue = Brushes.Black }, + Thickness = { CurrentValue = 6 }, + }.ToResource(CompositionContext.Default); + using GeometryContext expected = PreChangeGeometryPath.Build(resource); + Rect expectedBounds = expected.NativeObject.TightBounds.ToGraphicsRect(); + using SKPath expectedStroke = PenHelper.CreateStrokePath(expected.NativeObject, pen, expectedBounds); + + using (Assert.EnterMultipleScope()) + { + Assert.That(resource.Bounds, Is.EqualTo(expectedBounds)); + Assert.That( + PreChangeGeometryPath.Describe(resource.GetCachedStrokePath(pen)), + Is.EqualTo(PreChangeGeometryPath.Describe(expectedStroke)).AsCollection); + Assert.That( + resource.GetRenderBounds(pen), + Is.EqualTo(expectedStroke.TightBounds.ToGraphicsRect())); + Assert.That( + resource.FillContains(expectedBounds.Center), + Is.EqualTo(expected.NativeObject.Contains(expectedBounds.Center.X, expectedBounds.Center.Y))); + } + } + + /// + /// Rebuilds 's value graph into resources that never went through + /// ToResource, which is the shape a plugin author constructs by hand. + /// + private static Geometry.Resource Detach(Geometry.Resource attached) + { + Geometry.Resource copy = attached switch + { + EllipseGeometry.Resource r => new EllipseGeometry.Resource { Width = r.Width, Height = r.Height }, + RectGeometry.Resource r => new RectGeometry.Resource { Width = r.Width, Height = r.Height }, + RoundedRectGeometry.Resource r => new RoundedRectGeometry.Resource + { + Width = r.Width, + Height = r.Height, + CornerRadius = r.CornerRadius, + Smoothing = r.Smoothing, + }, + PathGeometry.Resource r => DetachPath(r), + _ => throw new NotSupportedException(attached.GetType().ToString()), + }; + + if (attached.Transform is { } transform) + { + copy.Transform = new Transform.Resource { Matrix = transform.Matrix }; + } + + copy.FillType = attached.FillType; + return copy; + } + + private static PathGeometry.Resource DetachPath(PathGeometry.Resource source) + { + var copy = new PathGeometry.Resource(); + foreach (PathFigure.Resource figure in source.Figures) + { + var figureCopy = new PathFigure.Resource + { + StartPoint = figure.StartPoint, + IsClosed = figure.IsClosed, + }; + foreach (PathSegment.Resource segment in figure.Segments) + { + figureCopy.Segments.Add(segment switch + { + LineSegment.Resource s => new LineSegment.Resource { Point = s.Point }, + QuadraticBezierSegment.Resource s => new QuadraticBezierSegment.Resource + { + ControlPoint = s.ControlPoint, + EndPoint = s.EndPoint, + }, + CubicBezierSegment.Resource s => new CubicBezierSegment.Resource + { + ControlPoint1 = s.ControlPoint1, + ControlPoint2 = s.ControlPoint2, + EndPoint = s.EndPoint, + }, + ConicSegment.Resource s => new ConicSegment.Resource + { + ControlPoint = s.ControlPoint, + EndPoint = s.EndPoint, + Weight = s.Weight, + }, + ArcSegment.Resource s => new ArcSegment.Resource + { + Radius = s.Radius, + RotationAngle = s.RotationAngle, + IsLargeArc = s.IsLargeArc, + SweepClockwise = s.SweepClockwise, + Point = s.Point, + }, + _ => throw new NotSupportedException(segment.GetType().ToString()), + }); + } + + copy.Figures.Add(figureCopy); + } + + return copy; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryStrokePathCacheTests.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryStrokePathCacheTests.cs new file mode 100644 index 0000000000..622fa404f9 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/GeometryStrokePathCacheTests.cs @@ -0,0 +1,126 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Media; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// Geometry.Resource's stroke-path cache keys on the pen. Both halves of that key are exercised here: +/// which pen the cached stroke belongs to, and whether the fill path it was derived from is still current. +/// +[TestFixture] +public sealed class GeometryStrokePathCacheTests +{ + [Test] + public void TwoDetachedPens_DoNotShareOneCachedStroke() + { + using Geometry.Resource geometry = CreateAttachedEllipse(); + using var thin = CreateDetachedPen(thickness: 4); + using var thick = CreateDetachedPen(thickness: 20); + + Rect first = geometry.GetRenderBounds(thin); + Rect second = geometry.GetRenderBounds(thick); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(second, Is.EqualTo(new Rect(-10, -10, 120, 70)), + "keying the cache on GetOriginal() reads null for both detached pens and serves the thin stroke"); + } + } + + [Test] + public void TwoAttachedPens_DoNotShareOneCachedStroke() + { + using Geometry.Resource geometry = CreateAttachedEllipse(); + using Pen.Resource thin = CreateAttachedPen(thickness: 4); + using Pen.Resource thick = CreateAttachedPen(thickness: 20); + + using (Assert.EnterMultipleScope()) + { + Assert.That(geometry.GetRenderBounds(thin), Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(geometry.GetRenderBounds(thick), Is.EqualTo(new Rect(-10, -10, 120, 70))); + } + } + + [Test] + public void OneDetachedPenReused_StillHitsTheCache() + { + using Geometry.Resource geometry = CreateAttachedEllipse(); + using var pen = CreateDetachedPen(thickness: 4); + + using (Assert.EnterMultipleScope()) + { + Assert.That(geometry.GetRenderBounds(pen), Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(geometry.GetRenderBounds(pen), Is.EqualTo(new Rect(-2, -2, 104, 54))); + } + } + + [Test] + public void ADetachedPenWhoseVersionMoved_RebuildsTheStroke() + { + using Geometry.Resource geometry = CreateAttachedEllipse(); + using var pen = CreateDetachedPen(thickness: 4); + Rect before = geometry.GetRenderBounds(pen); + + pen.Thickness = 20; + pen.Version++; + + using (Assert.EnterMultipleScope()) + { + Assert.That(before, Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(geometry.GetRenderBounds(pen), Is.EqualTo(new Rect(-10, -10, 120, 70))); + } + } + + [Test] + public void RebuildingTheFillPath_DropsTheStrokeBuiltFromTheOldOne() + { + using var geometry = new RectGeometry.Resource { Width = 100, Height = 50 }; + using Pen.Resource pen = CreateAttachedPen(thickness: 4); + Rect before = geometry.GetRenderBounds(pen); + + geometry.Width = 200; + geometry.InvalidateCachedPaths(); + // Rebuilding the fill path first is what leaves a stale stroke reachable: GetCachedStrokePath then + // sees a matching version, a live fill path, a live stroke path, and the same pen. + _ = geometry.Bounds; + + using (Assert.EnterMultipleScope()) + { + Assert.That(before, Is.EqualTo(new Rect(-2, -2, 104, 54))); + Assert.That(geometry.GetRenderBounds(pen), Is.EqualTo(new Rect(-2, -2, 204, 54))); + } + } + + private static Geometry.Resource CreateAttachedEllipse() + { + return new EllipseGeometry + { + Width = { CurrentValue = 100 }, + Height = { CurrentValue = 50 }, + }.ToResource(CompositionContext.Default); + } + + private static Pen.Resource CreateAttachedPen(float thickness) + { + return new Pen + { + Brush = { CurrentValue = Brushes.Black }, + Thickness = { CurrentValue = thickness }, + }.ToResource(CompositionContext.Default); + } + + private static Pen.Resource CreateDetachedPen(float thickness) + { + return new Pen.Resource + { + Brush = Colors.Black.ToBrushResource(), + Thickness = thickness, + MiterLimit = 10, + TrimEnd = 100, + }; + } +} diff --git a/tests/Beutl.UnitTests/Engine/Media/Geometry/PreChangeGeometryPath.cs b/tests/Beutl.UnitTests/Engine/Media/Geometry/PreChangeGeometryPath.cs new file mode 100644 index 0000000000..9b617345f4 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Media/Geometry/PreChangeGeometryPath.cs @@ -0,0 +1,395 @@ +using Beutl.Graphics; +using Beutl.Media; +using Beutl.Utilities; +using SkiaSharp; + +namespace Beutl.UnitTests.Engine.Media.Geometry; + +using Geometry = Beutl.Media.Geometry; + +/// +/// A verbatim copy of the geometry path construction as it stood at 989856e8d, when +/// Geometry.Resource.GetCachedPath dispatched through GetOriginal().ApplyTo(context, this). +/// +/// +/// The copy is the comparison baseline for : dispatch moved onto the +/// resource, and every value the old engine-object overrides read already came from the resource, so the two +/// must agree element for element. Edit this only to correct a transcription error — it is not a second +/// implementation to keep in step with the shipped one. +/// +internal static class PreChangeGeometryPath +{ + public static GeometryContext Build(Geometry.Resource resource) + { + var context = new GeometryContext { FillType = resource.FillType }; + ApplyGeometry(context, resource); + if (resource.Transform != null) + { + context.Transform(resource.Transform.Matrix); + } + + return context; + } + + private static void ApplyGeometry(IGeometryContext context, Geometry.Resource resource) + { + switch (resource) + { + case EllipseGeometry.Resource r: + ApplyEllipse(context, r); + break; + case RectGeometry.Resource r: + ApplyRect(context, r); + break; + case RoundedRectGeometry.Resource r: + ApplyRoundedRect(context, r); + break; + case PathGeometry.Resource r: + ApplyPathGeometry(context, r); + break; + default: + throw new NotSupportedException($"{resource.GetType()} has no transcribed pre-change path."); + } + } + + private static void ApplyEllipse(IGeometryContext context, EllipseGeometry.Resource r) + { + float width = r.Width; + float height = r.Height; + if (float.IsInfinity(width)) + width = 0; + + if (float.IsInfinity(height)) + height = 0; + + float radiusX = width / 2; + float radiusY = height / 2; + var radius = new Size(radiusX, radiusY); + + context.MoveTo(new Point(radiusX, 0)); + context.ArcTo(radius, 0, true, false, new Point(radiusX, height)); + context.ArcTo(radius, 0, true, false, new Point(radiusX, 0)); + context.Close(); + } + + private static void ApplyRect(IGeometryContext context, RectGeometry.Resource r) + { + float width = r.Width; + float height = r.Height; + if (float.IsInfinity(width)) + width = 0; + + if (float.IsInfinity(height)) + height = 0; + + context.MoveTo(new Point(0, 0)); + context.LineTo(new Point(width, 0)); + context.LineTo(new Point(width, height)); + context.LineTo(new Point(0, height)); + context.LineTo(new Point(0, 0)); + context.Close(); + } + + private static void ApplyPathGeometry(IGeometryContext context, PathGeometry.Resource r) + { + foreach (PathFigure.Resource item in r.Figures) + { + ApplyFigure(context, item); + } + } + + private static void ApplyFigure(IGeometryContext context, PathFigure.Resource resource) + { + bool skipFirst = false; + if (!resource.StartPoint.IsInvalid) + { + context.MoveTo(resource.StartPoint); + } + else if (resource.Segments.Count > 0) + { + if (resource.IsClosed) + { + var endPoint = resource.Segments[^1].GetEndPoint(); + if (endPoint.HasValue) + { + context.MoveTo(endPoint.Value); + } + } + else + { + var endPoint = resource.Segments[0].GetEndPoint(); + if (endPoint.HasValue) + { + context.MoveTo(endPoint.Value); + skipFirst = true; + } + } + } + + foreach (PathSegment.Resource item in resource.Segments) + { + if (skipFirst) + { + skipFirst = false; + continue; + } + + ApplySegment(context, item); + } + + if (resource.IsClosed) + context.Close(); + } + + private static void ApplySegment(IGeometryContext context, PathSegment.Resource resource) + { + switch (resource) + { + case LineSegment.Resource r: + context.LineTo(r.Point); + break; + case QuadraticBezierSegment.Resource r: + context.QuadraticTo(r.ControlPoint, r.EndPoint); + break; + case CubicBezierSegment.Resource r: + context.CubicTo(r.ControlPoint1, r.ControlPoint2, r.EndPoint); + break; + case ConicSegment.Resource r: + context.ConicTo(r.ControlPoint, r.EndPoint, r.Weight); + break; + case ArcSegment.Resource r: + context.ArcTo(r.Radius, r.RotationAngle, r.IsLargeArc, r.SweepClockwise, r.Point); + break; + default: + throw new NotSupportedException($"{resource.GetType()} has no transcribed pre-change path."); + } + } + + private static void ApplyRoundedRect(IGeometryContext context, RoundedRectGeometry.Resource r) + { + float width = r.Width; + float height = r.Height; + if (float.IsInfinity(width)) + width = 0; + + if (float.IsInfinity(height)) + height = 0; + + (float radiusX, float radiusY) = (width / 2, height / 2); + float maxRadius = Math.Max(radiusX, radiusY); + CornerRadius cornerRadius = r.CornerRadius; + float topLeft = Math.Clamp(cornerRadius.TopLeft, 0, maxRadius); + float topRight = Math.Clamp(cornerRadius.TopRight, 0, maxRadius); + float bottomRight = Math.Clamp(cornerRadius.BottomRight, 0, maxRadius); + float bottomLeft = Math.Clamp(cornerRadius.BottomLeft, 0, maxRadius); + float smoothing = r.Smoothing / 100; + + ApplyTopRightCorner(width, height, topRight, smoothing, context); + ApplyBottomRightCorner(width, height, bottomRight, smoothing, context); + ApplyBottomLeftCorner(width, height, bottomLeft, smoothing, context); + ApplyTopLeftCorner(width, height, topLeft, smoothing, context); + } + + // https://github.com/yjb94/react-native-squircle-skia + private static void GetPathParams( + float width, float height, float cornerRadius, float smoothing, + out float a, out float b, out float c, out float d, out float p, out float circularSectionLength) + { + float maxRadius = MathF.Min(width, height) / 2; + cornerRadius = MathF.Min(cornerRadius, maxRadius); + + p = MathF.Min((1 + smoothing) * cornerRadius, maxRadius); + + float angleAlpha; + float angleBeta; + + if (cornerRadius <= maxRadius / 2) + { + angleBeta = 90 * (1 - smoothing); + angleAlpha = 45 * smoothing; + } + else + { + float diffRatio = (cornerRadius - maxRadius / 2) / (maxRadius / 2); + + angleBeta = 90 * (1 - smoothing * (1 - diffRatio)); + angleAlpha = 45 * smoothing * (1 - diffRatio); + } + + float angleTheta = (90 - angleBeta) / 2; + float p3ToP4Distance = cornerRadius * MathF.Tan(MathUtilities.Deg2Rad(angleTheta / 2)); + + circularSectionLength = MathF.Sin(MathUtilities.Deg2Rad(angleBeta / 2)) * cornerRadius * MathF.Sqrt(2); + + c = p3ToP4Distance * MathF.Cos(MathUtilities.Deg2Rad(angleAlpha)); + d = c * MathF.Tan(MathUtilities.Deg2Rad(angleAlpha)); + b = (p - circularSectionLength - c - d) / 3; + a = 2 * b; + } + + private static void ApplyTopRightCorner(float width, float height, + float cornerRadius, float smoothing, IGeometryContext context) + { + if (cornerRadius != 0) + { + GetPathParams( + width, height, cornerRadius, smoothing, + out float a, out float b, out float c, out float d, out float p, out float circularSectionLength); + + context.MoveTo(new Point(MathF.Max(width / 2, width - p), 0)); + context.CubicTo( + new Point(width - (p - a), 0), + new Point(width - (p - a - b), 0), + new Point(width - (p - a - b - c), d)); + context.ArcTo( + new Size(cornerRadius, cornerRadius), + 0, + false, + true, + new Point(circularSectionLength, circularSectionLength) + context.LastPoint); + context.CubicTo( + new Point(width, p - a - b), + new Point(width, p - a), + new Point(width, MathF.Min(height / 2, p))); + } + else + { + context.MoveTo(new Point(width / 2, 0)); + context.LineTo(new Point(width, 0)); + context.LineTo(new Point(width, height / 2)); + } + } + + private static void ApplyBottomRightCorner(float width, float height, + float cornerRadius, float smoothing, IGeometryContext context) + { + if (cornerRadius != 0) + { + GetPathParams( + width, height, cornerRadius, smoothing, + out float a, out float b, out float c, out float d, out float p, out float circularSectionLength); + + context.LineTo(new Point(width, MathF.Max(height / 2, height - p))); + context.CubicTo( + new Point(width, height - (p - a)), + new Point(width, height - (p - a - b)), + new Point(width - d, height - (p - a - b - c))); + context.ArcTo( + new Size(cornerRadius, cornerRadius), + 0, + false, + true, + new Point(-circularSectionLength, circularSectionLength) + context.LastPoint); + context.CubicTo( + new Point(width - (p - a - b), height), + new Point(width - (p - a), height), + new Point(MathF.Max(width / 2, width - p), height)); + } + else + { + context.LineTo(new Point(width, height)); + context.LineTo(new Point(width / 2, height)); + } + } + + private static void ApplyBottomLeftCorner(float width, float height, + float cornerRadius, float smoothing, IGeometryContext context) + { + if (cornerRadius != 0) + { + GetPathParams( + width, height, cornerRadius, smoothing, + out float a, out float b, out float c, out float d, out float p, out float circularSectionLength); + + context.LineTo(new Point(MathF.Min(width / 2, p), height)); + context.CubicTo( + new Point(p - a, height), + new Point(p - a - b, height), + new Point(p - a - b - c, height - d)); + context.ArcTo( + new Size(cornerRadius, cornerRadius), + 0, + false, + true, + new Point(-circularSectionLength, -circularSectionLength) + context.LastPoint); + context.CubicTo( + new Point(0, height - (p - a - b)), + new Point(0, height - (p - a)), + new Point(0, MathF.Max(height / 2, height - p))); + } + else + { + context.LineTo(new Point(0, height)); + context.LineTo(new Point(0, height / 2)); + } + } + + private static void ApplyTopLeftCorner(float width, float height, + float cornerRadius, float smoothing, IGeometryContext context) + { + if (cornerRadius != 0) + { + GetPathParams( + width, height, cornerRadius, smoothing, + out float a, out float b, out float c, out float d, out float p, out float circularSectionLength); + + context.LineTo(new Point(0, MathF.Min(height / 2, p))); + context.CubicTo( + new Point(0, p - a), + new Point(0, p - a - b), + new Point(d, p - a - b - c)); + context.ArcTo( + new Size(cornerRadius, cornerRadius), + 0, + false, + true, + new Point(circularSectionLength, -circularSectionLength) + context.LastPoint); + context.CubicTo( + new Point(p - a - b, 0), + new Point(p - a, 0), + new Point(MathF.Min(width / 2, p), 0)); + } + else + { + context.LineTo(new Point(0, 0)); + } + + context.Close(); + } + + public static IReadOnlyList Describe(SKPath path) + { + var elements = new List + { + $"fillType={path.FillType}", + $"points={path.PointCount}", + $"verbs={path.VerbCount}", + $"tightBounds={path.TightBounds}", + $"bounds={path.Bounds}", + $"svg={path.ToSvgPathData()}", + }; + + using SKPath.RawIterator iterator = path.CreateRawIterator(); + Span points = stackalloc SKPoint[4]; + SKPathVerb verb; + int index = 0; + do + { + verb = iterator.Next(points); + elements.Add(verb switch + { + SKPathVerb.Move => $"[{index}] Move {points[0]}", + SKPathVerb.Line => $"[{index}] Line {points[0]} {points[1]}", + SKPathVerb.Quad => $"[{index}] Quad {points[0]} {points[1]} {points[2]}", + SKPathVerb.Conic => + $"[{index}] Conic {points[0]} {points[1]} {points[2]} w={iterator.ConicWeight()}", + SKPathVerb.Cubic => $"[{index}] Cubic {points[0]} {points[1]} {points[2]} {points[3]}", + SKPathVerb.Close => $"[{index}] Close", + _ => $"[{index}] Done", + }); + index++; + } while (verb != SKPathVerb.Done); + + return elements; + } +} diff --git a/tests/Beutl.UnitTests/Engine/PixelRectTests.cs b/tests/Beutl.UnitTests/Engine/PixelRectTests.cs index 9c5f8a5c16..6d0a82c223 100644 --- a/tests/Beutl.UnitTests/Engine/PixelRectTests.cs +++ b/tests/Beutl.UnitTests/Engine/PixelRectTests.cs @@ -5,6 +5,15 @@ namespace Beutl.UnitTests.Engine; public class PixelRectTests { + private static readonly Rect[] s_fractionalRects = + [ + new(0.25f, 0.25f, 4, 4), + new(-0.25f, -0.25f, 4, 4), + new(-0.02f, -0.02f, 4, 4), + new(-1.5f, -1.5f, 3, 3), + new(-3.5f, -3.5f, 0.25f, 0.25f), + ]; + [Test] public void Parse() { @@ -180,6 +189,127 @@ public void FromRect_WithScale_CeilsBottomRight() Is.EqualTo(new PixelRect(0, 0, 3, 10))); } + [Test] + public void FromRect_CoversEveryLogicalCorner([ValueSource(nameof(s_fractionalRects))] Rect rect) + { + Assert.Multiple(() => + { + AssertCovers(PixelRect.FromRect(rect), rect, new Vector(1, 1)); + AssertCovers(PixelRect.FromRect(rect, 2f), rect, new Vector(2, 2)); + AssertCovers(PixelRect.FromRect(rect, new Vector(2, 4)), rect, new Vector(2, 4)); + }); + } + + [Test] + public void FromRect_FloorsTheTopLeftAtNegativeOrigins() + { + Assert.Multiple(() => + { + Assert.That(PixelRect.FromRect(new Rect(-0.25f, -0.25f, 4, 4)), + Is.EqualTo(new PixelRect(-1, -1, 5, 5))); + Assert.That(PixelRect.FromRect(new Rect(-1.5f, -1.5f, 3, 3)), + Is.EqualTo(new PixelRect(-2, -2, 4, 4))); + Assert.That(PixelRect.FromRect(new Rect(-0.25f, -0.25f, 4, 4), 2f), + Is.EqualTo(new PixelRect(-1, -1, 9, 9))); + }); + } + + [TestCase(100_000f)] + [TestCase(1_000_000f)] + [TestCase(10_000_000f)] + public void FromRect_CoverOfLargeCoordinateSuperset_ContainsSubsetCover(float coordinate) + { + var subset = new Rect(coordinate, coordinate, 0.001f, 0.001f); + var superset = new Rect(coordinate - 5f, coordinate - 5f, 5.001f, 5.001f); + + PixelRect subsetCover = PixelRect.FromRect(subset, 1f); + PixelRect supersetCover = PixelRect.FromRect(superset, 1f); + + Assert.That( + supersetCover.Contains(subsetCover), + Is.True, + $"The cover of {superset} ({supersetCover}) did not contain the cover of {subset} ({subsetCover})."); + } + + [Test] + public void FromRect_CoverMapping_IsMonotoneForRandomContainedRects() + { + var random = new Random(0x50495845); + int verified = 0; + + for (int attempt = 0; attempt < 20_000 && verified < 5_000; attempt++) + { + float outerX = RandomCoordinate(random); + float outerY = RandomCoordinate(random); + float outerWidth = RandomExtent(random); + float outerHeight = RandomExtent(random); + var outer = new Rect(outerX, outerY, outerWidth, outerHeight); + + float innerX = (float)((double)outerX + outerWidth * random.NextDouble()); + float innerY = (float)((double)outerY + outerHeight * random.NextDouble()); + double remainingWidth = (double)outerX + outerWidth - innerX; + double remainingHeight = (double)outerY + outerHeight - innerY; + if (remainingWidth <= 0 || remainingHeight <= 0) + continue; + + float innerWidth = (float)(remainingWidth * random.NextDouble()); + float innerHeight = (float)(remainingHeight * random.NextDouble()); + if (innerWidth <= 0 || innerHeight <= 0) + continue; + + var inner = new Rect(innerX, innerY, innerWidth, innerHeight); + if (!ContainsInDouble(outer, inner)) + continue; + + float scale = 0.1f + (float)(random.NextDouble() * 3.9); + PixelRect outerCover = PixelRect.FromRect(outer, scale); + PixelRect innerCover = PixelRect.FromRect(inner, scale); + + Assert.That( + outerCover.Contains(innerCover), + Is.True, + $"Scale {scale}: cover of {outer} ({outerCover}) did not contain cover of {inner} ({innerCover})."); + verified++; + } + + Assert.That(verified, Is.EqualTo(5_000), "The seeded sweep did not generate enough valid contained rectangles."); + } + + private static float RandomCoordinate(Random random) + { + double magnitude = Math.Pow(10, 2 + random.NextDouble() * 5); + return (float)((random.Next(2) == 0 ? -1 : 1) * magnitude); + } + + private static float RandomExtent(Random random) + { + return (float)Math.Pow(10, -3 + random.NextDouble() * 5); + } + + private static bool ContainsInDouble(Rect outer, Rect inner) + { + return (double)inner.X >= outer.X + && (double)inner.Y >= outer.Y + && (double)inner.X + inner.Width <= (double)outer.X + outer.Width + && (double)inner.Y + inner.Height <= (double)outer.Y + outer.Height; + } + + private static void AssertCovers(PixelRect actual, Rect logical, Vector scale) + { + var device = new Rect( + logical.X * scale.X, + logical.Y * scale.Y, + logical.Width * scale.X, + logical.Height * scale.Y); + + Assert.That(actual.X, Is.LessThanOrEqualTo(device.X), $"{actual} misses the left of {device}"); + Assert.That(actual.Y, Is.LessThanOrEqualTo(device.Y), $"{actual} misses the top of {device}"); + Assert.That(actual.Right, Is.GreaterThanOrEqualTo(device.Right), $"{actual} misses the right of {device}"); + Assert.That(actual.Bottom, Is.GreaterThanOrEqualTo(device.Bottom), $"{actual} misses the bottom of {device}"); + Assert.That(actual.Width, Is.GreaterThan(0), $"{actual} has no width for {device}"); + Assert.That(actual.Height, Is.GreaterThan(0), $"{actual} has no height for {device}"); + } + [Test] public void TryParse_InvalidString_ReturnsFalse() { diff --git a/tests/Beutl.UnitTests/Engine/PixelTypesTests.cs b/tests/Beutl.UnitTests/Engine/PixelTypesTests.cs index cfad874684..bd6eed4ff7 100644 --- a/tests/Beutl.UnitTests/Engine/PixelTypesTests.cs +++ b/tests/Beutl.UnitTests/Engine/PixelTypesTests.cs @@ -60,6 +60,17 @@ public void FromPoint_TruncatesToInteger() Is.EqualTo(new PixelPoint(3, 10))); } + [Test] + public void FromPoint_TruncatesNegativeCoordinatesTowardZero() + { + Assert.That(PixelPoint.FromPoint(new Point(-1.7f, -2.9f)), + Is.EqualTo(new PixelPoint(-1, -2))); + Assert.That(PixelPoint.FromPoint(new Point(-1.5f, -2.5f), 2f), + Is.EqualTo(new PixelPoint(-3, -5))); + Assert.That(PixelPoint.FromPoint(new Point(-0.25f, -0.25f), new Vector(2, 4)), + Is.EqualTo(new PixelPoint(0, -1))); + } + [Test] public void Parse_ReadsTwoInts() { diff --git a/tests/Beutl.UnitTests/Engine/ScaledTextCacheTests.cs b/tests/Beutl.UnitTests/Engine/ScaledTextCacheTests.cs index d898681506..ac5a29b5d5 100644 --- a/tests/Beutl.UnitTests/Engine/ScaledTextCacheTests.cs +++ b/tests/Beutl.UnitTests/Engine/ScaledTextCacheTests.cs @@ -1,4 +1,5 @@ -using Beutl.Media.TextFormatting; +using Beutl.Graphics; +using Beutl.Media.TextFormatting; using SkiaSharp; namespace Beutl.UnitTests.Engine; @@ -26,9 +27,9 @@ public void TearDown() // A fresh, live (blob, stroke) pair per density so disposing one entry can't disturb another; // both are real Skia handles so Handle == IntPtr.Zero observes actual native disposal. - private (SKTextBlob? TextBlob, SKPath? StrokePath) CreateScaledText(float density) + private (SKTextBlob? TextBlob, SKPath? StrokePath, Rect RasterBounds) CreateScaledText(float density) { - return (SKTextBlob.Create("A", _font), new SKPath()); + return (SKTextBlob.Create("A", _font), new SKPath(), new Rect(0, 0, density, density)); } [Test] @@ -74,7 +75,7 @@ public void Get_StaysConsistent_AfterCommitFailure() // A later access at the same density still succeeds and produces a fresh, live blob. cache.CommitFaultHook = null; - (SKTextBlob? blob, _) = cache.Get(2f); + (SKTextBlob? blob, _, _) = cache.Get(2f); Assert.That(blob, Is.Not.Null); Assert.That(blob!.Handle, Is.Not.EqualTo(IntPtr.Zero)); } @@ -89,7 +90,7 @@ public void Get_EvictsWithoutCorruption_WhenExceedingMaxEntries() for (int i = 1; i <= 12; i++) { float density = 1f + i * 0.25f; - (SKTextBlob? blob, _) = cache.Get(density); + (SKTextBlob? blob, _, _) = cache.Get(density); Assert.That(blob, Is.Not.Null, $"density {density} should produce a scaled blob"); Assert.That(blob!.Handle, Is.Not.EqualTo(IntPtr.Zero)); } diff --git a/tests/Beutl.UnitTests/Engine/TextBlockSubpixelPlacementTests.cs b/tests/Beutl.UnitTests/Engine/TextBlockSubpixelPlacementTests.cs index cb58cc2d13..2bbe887536 100644 --- a/tests/Beutl.UnitTests/Engine/TextBlockSubpixelPlacementTests.cs +++ b/tests/Beutl.UnitTests/Engine/TextBlockSubpixelPlacementTests.cs @@ -75,7 +75,16 @@ private static float MeasureInkCentroidY(Typeface typeface, float translateY) using (var canvas = new ImmediateCanvas(renderTarget)) { canvas.Clear(); - new RenderNodeProcessor(node, false).Render(canvas); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + renderer.Render(canvas); } using Bitmap snapshot = renderTarget.Snapshot(); diff --git a/tests/Beutl.UnitTests/Engine/TextBlockTests.cs b/tests/Beutl.UnitTests/Engine/TextBlockTests.cs index 2ac9e05391..34b081f74d 100644 --- a/tests/Beutl.UnitTests/Engine/TextBlockTests.cs +++ b/tests/Beutl.UnitTests/Engine/TextBlockTests.cs @@ -55,8 +55,19 @@ public void ParseAndDraw(string str, int id) tb.Render(context, resource); } - var processor = new RenderNodeProcessor(node, false); - using Bitmap bmp = processor.RasterizeAndConcat(); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 1920, 1080), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Assert.That(rasterization.IsEmpty, Is.False); + Bitmap bmp = rasterization.Bitmap!; Assert.That(bmp.Save(Path.Combine(ArtifactProvider.GetArtifactDirectory(), $"{id}.png"), EncodedImageFormat.Png), Is.True); } diff --git a/tests/Beutl.UnitTests/Graphics/PreviewFrameOrderIndependenceTests.cs b/tests/Beutl.UnitTests/Graphics/PreviewFrameOrderIndependenceTests.cs index 5afc301e81..e2c0efdb68 100644 --- a/tests/Beutl.UnitTests/Graphics/PreviewFrameOrderIndependenceTests.cs +++ b/tests/Beutl.UnitTests/Graphics/PreviewFrameOrderIndependenceTests.cs @@ -2,6 +2,7 @@ using Beutl.Configuration; using Beutl.Graphics; using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; using Beutl.Graphics.Rendering.Cache; using Beutl.Graphics.Shapes; using Beutl.Media; @@ -76,7 +77,7 @@ public void RenderedFrameIsTheSameWhetherOrNotCacheHitsPrecededIt(bool preferPro using var media = new SceneMedia(preferProxy, withEffect); string[] baseline = new string[FrameCount]; - using (var renderer = new SceneRenderer(media.NewScene()) { CacheOptions = RenderCacheOptions.Default }) + using (var renderer = new SceneRenderer(media.NewScene(), RenderIntent.Preview) { CacheOptions = RenderCacheOptions.Default }) { for (int frame = 0; frame < FrameCount; frame++) { @@ -90,7 +91,7 @@ public void RenderedFrameIsTheSameWhetherOrNotCacheHitsPrecededIt(bool preferPro Assert.That(baseline.Distinct().Count(), Is.EqualTo(FrameCount), "the scene has to look different on every frame or the comparisons below prove nothing"); - using (var renderer = new SceneRenderer(media.NewScene()) { CacheOptions = RenderCacheOptions.Default }) + using (var renderer = new SceneRenderer(media.NewScene(), RenderIntent.Preview) { CacheOptions = RenderCacheOptions.Default }) { var rendered = new HashSet(); foreach (int frame in s_scrub) @@ -128,7 +129,7 @@ public void BackdropFrameIsTheSameWhetherOrNotCacheHitsPrecededIt() using var media = new SceneMedia(preferProxy: true, withEffect: false, withBackdrops: true); string[] baseline = new string[FrameCount]; - using (var renderer = new SceneRenderer(media.NewScene()) { CacheOptions = RenderCacheOptions.Default }) + using (var renderer = new SceneRenderer(media.NewScene(), RenderIntent.Preview) { CacheOptions = RenderCacheOptions.Default }) { for (int frame = 0; frame < FrameCount; frame++) { @@ -141,7 +142,7 @@ public void BackdropFrameIsTheSameWhetherOrNotCacheHitsPrecededIt() Assert.That(baseline.Distinct().Count(), Is.EqualTo(FrameCount), "the scene has to look different on every frame or the comparisons below prove nothing"); - using (var renderer = new SceneRenderer(media.NewScene()) { CacheOptions = RenderCacheOptions.Default }) + using (var renderer = new SceneRenderer(media.NewScene(), RenderIntent.Preview) { CacheOptions = RenderCacheOptions.Default }) { var rendered = new HashSet(); foreach (int frame in s_scrub) @@ -190,7 +191,7 @@ public void BackdropInsideAGroupCompositesTheSiblingDrawnBeforeIt() group.Children.Add(glass); Scene scene = media.NewSceneWith(group); - using var renderer = new SceneRenderer(scene) { CacheOptions = RenderCacheOptions.Default }; + using var renderer = new SceneRenderer(scene, RenderIntent.Preview) { CacheOptions = RenderCacheOptions.Default }; renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.Zero)); using Bitmap snapshot = renderer.Snapshot(); diff --git a/tests/Beutl.UnitTests/Graphics/ProxyVideoLogicalSizeTests.cs b/tests/Beutl.UnitTests/Graphics/ProxyVideoLogicalSizeTests.cs index 66fa0ffe8d..971b26a966 100644 --- a/tests/Beutl.UnitTests/Graphics/ProxyVideoLogicalSizeTests.cs +++ b/tests/Beutl.UnitTests/Graphics/ProxyVideoLogicalSizeTests.cs @@ -52,21 +52,26 @@ public void ProxiedVideo_UsesOriginalLogicalBoundsAndProxySupplyDensity() var source = new VideoSource(); source.ReadFrom(new Uri(scope.OriginalPath)); using var resource = source.ToResource(new CompositionContext(TimeSpan.Zero) { PreferProxy = true }); - var node = new VideoSourceRenderNode(resource, frame: 0, Brushes.Resource.White, null); - RenderNodeOperation[] operations = node.Process(new RenderNodeContext([])); + using var node = new VideoSourceRenderNode(resource, frame: 0, Brushes.Resource.White, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); Assert.Multiple(() => { Assert.That(resource.FrameSize, Is.EqualTo(new PixelSize(50, 40))); Assert.That(resource.LogicalFrameSize, Is.EqualTo(new PixelSize(100, 80))); Assert.That(node.Bounds.Size, Is.EqualTo(new Size(100, 80))); - Assert.That(operations[0].EffectiveScale.Value, Is.EqualTo(0.5f).Within(1e-6)); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(0.5f).Within(1e-6)); }); - - foreach (RenderNodeOperation operation in operations) - { - operation.Dispose(); - } } [Test] @@ -77,21 +82,26 @@ public void OriginalVideo_RemainsNativeSizeAndDensity() var source = new VideoSource(); source.ReadFrom(new Uri(path)); using var resource = source.ToResource(new CompositionContext(TimeSpan.Zero) { PreferProxy = false }); - var node = new VideoSourceRenderNode(resource, frame: 0, Brushes.Resource.White, null); - RenderNodeOperation[] operations = node.Process(new RenderNodeContext([])); + using var node = new VideoSourceRenderNode(resource, frame: 0, Brushes.Resource.White, null); + using var renderer = new RenderNodeRenderer( + node, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + RenderNodeMeasurement measurement = renderer.Measure(); Assert.Multiple(() => { Assert.That(resource.FrameSize, Is.EqualTo(new PixelSize(100, 80))); Assert.That(resource.LogicalFrameSize, Is.EqualTo(new PixelSize(100, 80))); Assert.That(node.Bounds.Size, Is.EqualTo(new Size(100, 80))); - Assert.That(operations[0].EffectiveScale.Value, Is.EqualTo(1f)); + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(1f)); }); - - foreach (RenderNodeOperation operation in operations) - { - operation.Dispose(); - } } [Test] @@ -251,7 +261,7 @@ public void SceneRenderer_DrawsProxyVideoAtOriginalLogicalFootprint() }; scene.Children.Add(element); - using var renderer = new SceneRenderer(scene); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.FromSeconds(1d / 30d))); using Bitmap snapshot = renderer.Snapshot(); @@ -299,7 +309,7 @@ public void SceneRenderer_DrawsFilteredProxyVideoAtOriginalLogicalFootprint() }; scene.Children.Add(element); - using var renderer = new SceneRenderer(scene); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.FromSeconds(1d / 30d))); using Bitmap snapshot = renderer.Snapshot(); @@ -349,7 +359,7 @@ public void SceneRenderer_DrawsSkiaFilteredProxyVideoAtOriginalLogicalFootprint( }; scene.Children.Add(element); - using var renderer = new SceneRenderer(scene); + using var renderer = new SceneRenderer(scene, RenderIntent.Preview); renderer.Render(renderer.Compositor.EvaluateGraphics(TimeSpan.FromSeconds(1d / 30d))); using Bitmap snapshot = renderer.Snapshot(); diff --git a/tests/Beutl.UnitTests/Helpers/EngineObjectHelperTests.cs b/tests/Beutl.UnitTests/Helpers/EngineObjectHelperTests.cs new file mode 100644 index 0000000000..886d77356c --- /dev/null +++ b/tests/Beutl.UnitTests/Helpers/EngineObjectHelperTests.cs @@ -0,0 +1,108 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Beutl.Editor.Components.Helpers; +using Beutl.Engine; +using Beutl.Graphics.Rendering; + +namespace Beutl.UnitTests.Helpers; + +[TestFixture] +public class EngineObjectHelperTests +{ + // The subscription creates and updates its resource inside a posted render-thread callback, and the + // render thread installs no unhandled-exception handler, so an escaping exception unwinds its loop + // and every later render on that thread is lost. + [Test] + public void A_failing_resource_factory_reports_through_the_observer_and_spares_the_render_thread() + { + var probe = new ProbeObject(); + var time = new BehaviorSubject(TimeSpan.Zero); + var failure = new InvalidOperationException("the resource factory rejected the current state"); + Exception? reported = null; + using var reportedSignal = new ManualResetEventSlim(); + + using (probe + .SubscribeEngineVersionedResource( + time, + (_, _) => throw failure) + .Subscribe( + _ => { }, + ex => + { + reported = ex; + reportedSignal.Set(); + })) + { + Assert.That(reportedSignal.Wait(TimeSpan.FromSeconds(30)), Is.True, + "the failure never reached the observer"); + } + + Assert.That(reported, Is.SameAs(failure)); + + using var stillAlive = new ManualResetEventSlim(); + RenderThread.Dispatcher.Dispatch(stillAlive.Set); + Assert.That(stillAlive.Wait(TimeSpan.FromSeconds(30)), Is.True, + "the render thread stopped taking work after the failed callback"); + } + + // Teardown releases the resource from a posted render-thread callback, where a throwing Dispose + // would unwind the loop just as a throwing factory would. + [Test] + public void A_throwing_resource_dispose_does_not_take_the_render_thread_down() + { + var probe = new ProbeObject(); + var time = new BehaviorSubject(TimeSpan.Zero); + using var published = new ManualResetEventSlim(); + + IDisposable subscription = probe + .SubscribeEngineVersionedResource( + time, + (_, _) => new ThrowingResource()) + .Subscribe(_ => published.Set()); + Assert.That(published.Wait(TimeSpan.FromSeconds(30)), Is.True, "no resource was ever published"); + + subscription.Dispose(); + + using var stillAlive = new ManualResetEventSlim(); + RenderThread.Dispatcher.Dispatch(stillAlive.Set); + Assert.That(stillAlive.Wait(TimeSpan.FromSeconds(30)), Is.True, + "the render thread stopped taking work after the failed teardown"); + } + + // Rx's default error handler rethrows on the source thread, so the trigger's failure would never + // reach the subscriber and the resource would stay held. + [Test] + public void A_failing_time_stream_reaches_the_observer() + { + var probe = new ProbeObject(); + var time = new Subject(); + var failure = new InvalidOperationException("the clock faulted"); + Exception? reported = null; + + using (probe + .SubscribeEngineVersionedResource( + time, + (o, c) => o.ToResource(c)) + .Subscribe(_ => { }, ex => reported = ex)) + { + time.OnError(failure); + } + + Assert.That(reported, Is.SameAs(failure)); + } + + [SuppressResourceClassGeneration] + private sealed class ProbeObject : EngineObject; + + private sealed class ThrowingResource : EngineObject.Resource + { + // The base finalizer calls Dispose(false); throwing from there would kill the process rather + // than exercise the explicit-release path under test. + protected override void Dispose(bool disposing) + { + if (disposing) + throw new InvalidOperationException("this resource refuses to be released"); + } + } +} diff --git a/tests/Beutl.UnitTests/NodeGraph/ConfigureNodeOwnershipTests.cs b/tests/Beutl.UnitTests/NodeGraph/ConfigureNodeOwnershipTests.cs new file mode 100644 index 0000000000..a64f7471bb --- /dev/null +++ b/tests/Beutl.UnitTests/NodeGraph/ConfigureNodeOwnershipTests.cs @@ -0,0 +1,337 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; +using Beutl.NodeGraph; +using Beutl.NodeGraph.Composition; +using Beutl.NodeGraph.Nodes; +using Beutl.UnitTests.Engine.Graphics.Rendering; + +namespace Beutl.UnitTests.NodeGraph; + +[TestFixture] +public sealed class ConfigureNodeOwnershipTests +{ + [Test] + public void BoundFilterInput_FansOutThroughTransformAndFilterEffectConfigureNodes() + { + var graph = new NodeGraphFilterEffect(); + GraphModel model = graph.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + var transform = new TransformNode(); + var filter = new FilterEffectNode(); + var transformOutput = new OutputNode(); + var filterOutput = new OutputNode(); + model.Nodes.Add(input); + model.Nodes.Add(transform); + model.Nodes.Add(filter); + model.Nodes.Add(transformOutput); + model.Nodes.Add(filterOutput); + model.Connect(GetConfigureInput(transform), input.Output); + model.Connect(GetConfigureInput(filter), input.Output); + model.Connect(transformOutput.InputPort, (IOutputPort)transform.Items[0]); + model.Connect(filterOutput.InputPort, (IOutputPort)filter.Items[0]); + + Rect bounds = new(3, 5, 24, 18); + using var resource = (NodeGraphFilterEffect.Resource)graph.ToResource(CompositionContext.Default); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + ScaleRecordingTestHelper.Source(EffectiveScale.At(1), bounds), + resource.CreateRenderNode()); + + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.Measure(pipeline); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.HasContributingValues, Is.True); + Assert.That(measurement.OutputBounds, Is.EqualTo(bounds)); + }); + } + + [Test] + public void FanOut_DisposingOneConfigureConsumerLeavesSourceAndOtherConsumerUsable() + { + var source = new OwnedRenderNodeSource(); + var firstConsumer = new FanOutConsumerNode(); + var secondConsumer = new FanOutConsumerNode(); + var model = new GraphModel(); + model.Nodes.Add(source); + model.Nodes.Add(firstConsumer); + model.Nodes.Add(secondConsumer); + model.Connect(firstConsumer.RenderInput, source.Output); + model.Connect(secondConsumer.RenderInput, source.Output); + + using (var snapshot = new GraphSnapshot()) + { + snapshot.Build(model, CompositionContext.Default); + snapshot.Evaluate(CompositionTarget.Graphics, CompositionContext.Default); + + ContainerRenderNode firstOutput = firstConsumer.OutputContainer + ?? throw new AssertionException("The first ConfigureNode consumer did not produce a container."); + ContainerRenderNode secondOutput = secondConsumer.OutputContainer + ?? throw new AssertionException("The second ConfigureNode consumer did not produce a container."); + + firstOutput.Dispose(); + + Assert.That(source.RenderNode.IsDisposed, Is.False, + "disposing one ConfigureNode output must not dispose its producer-owned input"); + Assert.That(secondOutput.IsDisposed, Is.False); + + using var renderer = new RenderNodeRenderer(secondOutput); + Assert.DoesNotThrow(() => renderer.Measure()); + Assert.That(source.RenderNode.ProcessCount, Is.EqualTo(1), + "the remaining ConfigureNode branch must still record its shared source"); + } + + Assert.That(source.RenderNode.IsDisposed, Is.True, + "the source owner releases its RenderNode when the graph snapshot is torn down"); + } + + [Test] + public void ReferencesChildRenderNode_DisposeDoesNotDisposeReferencedChild() + { + var child = new TrackingRenderNode(); + + using (var wrapper = new ReferencesChildRenderNode(child)) + { + } + + Assert.That(child.IsDisposed, Is.False); + child.Dispose(); + } + + [Test] + public void SharedNonValueFilterInputThroughConfigureReferences_ThrowsAtSecondConsumer() + { + var graph = new NodeGraphFilterEffect(); + GraphModel model = graph.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + var firstTransform = new TransformNode(); + var secondTransform = new TransformNode(); + var firstOutput = new OutputNode(); + var secondOutput = new OutputNode(); + model.Nodes.Add(input); + model.Nodes.Add(firstTransform); + model.Nodes.Add(secondTransform); + model.Nodes.Add(firstOutput); + model.Nodes.Add(secondOutput); + model.Connect(GetConfigureInput(firstTransform), input.Output); + model.Connect(GetConfigureInput(secondTransform), input.Output); + model.Connect(firstOutput.InputPort, (IOutputPort)firstTransform.Items[0]); + model.Connect(secondOutput.InputPort, (IOutputPort)secondTransform.Items[0]); + + using var resource = (NodeGraphFilterEffect.Resource)graph.ToResource(CompositionContext.Default); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + new NonValueCommandRenderNode(), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + Assert.That( + () => renderer.Measure(), + Throws.InvalidOperationException.And.Message.Contains("used by more than one consumer")); + } + + [Test] + public void UpdatingConfigureInput_ReusesReferenceAndDisposesOnlyRemovedReferences() + { + var first = new TrackingRenderNode(); + var replacement = new TrackingRenderNode(); + var source = new MutableOwnedRenderNodeSource(first, replacement); + var consumer = new FanOutConsumerNode(); + var model = new GraphModel(); + model.Nodes.Add(source); + model.Nodes.Add(consumer); + model.Connect(consumer.RenderInput, source.Output); + + using (var snapshot = new GraphSnapshot()) + { + snapshot.Build(model, CompositionContext.Default); + snapshot.Evaluate(CompositionTarget.Graphics, CompositionContext.Default); + + ContainerRenderNode output = consumer.OutputContainer + ?? throw new AssertionException("The ConfigureNode consumer did not produce a container."); + var reference = output.Children.Single() as ReferencesChildRenderNode + ?? throw new AssertionException("ConfigureNode must wrap its input in a non-owning reference."); + output.HasChanges = false; + + snapshot.Evaluate(CompositionTarget.Graphics, CompositionContext.Default); + + Assert.Multiple(() => + { + Assert.That(output.Children.Single(), Is.SameAs(reference)); + Assert.That(output.HasChanges, Is.False, + "an unchanged input must retain its existing wrapper without dirtying the output"); + }); + + source.Select(replacement); + snapshot.Evaluate(CompositionTarget.Graphics, CompositionContext.Default); + + Assert.Multiple(() => + { + Assert.That(output.Children.Single(), Is.SameAs(reference)); + Assert.That(reference.Child, Is.SameAs(replacement)); + Assert.That(reference.IsDisposed, Is.False); + Assert.That(first.IsDisposed, Is.False, + "retargeting a reference must not dispose its former producer"); + Assert.That(replacement.IsDisposed, Is.False); + Assert.That(output.HasChanges, Is.True, + "retargeting a ConfigureNode input must invalidate its output"); + }); + + output.HasChanges = false; + source.Select(null); + snapshot.Evaluate(CompositionTarget.Graphics, CompositionContext.Default); + + Assert.Multiple(() => + { + Assert.That(output.Children, Is.Empty); + Assert.That(reference.IsDisposed, Is.True, + "removing an input must dispose its no-longer-needed reference wrapper"); + Assert.That(first.IsDisposed, Is.False, + "removing an input must not dispose a producer it previously referenced"); + Assert.That(replacement.IsDisposed, Is.False); + Assert.That(output.HasChanges, Is.True, + "removing a ConfigureNode input must invalidate its output"); + }); + } + + Assert.Multiple(() => + { + Assert.That(first.IsDisposed, Is.True); + Assert.That(replacement.IsDisposed, Is.True); + }); + } + + private static IInputPort GetConfigureInput(ConfigureNode node) + => (IInputPort)node.Items[1]; +} + +internal sealed partial class FanOutConsumerNode : ConfigureNode +{ + public IInputPort RenderInput => InputPort; + + public ContainerRenderNode? OutputContainer { get; private set; } + + public partial class Resource + { + protected override void UpdateCore(GraphCompositionContext context) + { + var output = OutputPort; + if (output is null) + { + output = new ContainerRenderNode(); + OutputPort = output; + } + + GetOriginal()!.OutputContainer = output; + } + + partial void PostDispose(bool disposing) + { + OutputPort?.Dispose(); + OutputPort = null; + GetOriginal()!.OutputContainer = null; + } + } +} + +internal sealed partial class OwnedRenderNodeSource : GraphNode +{ + public OwnedRenderNodeSource() + { + Output = AddOutput("Output"); + } + + public TrackingRenderNode RenderNode { get; } = new(); + + public OutputPort Output { get; } + + public partial class Resource + { + public override void Update(GraphCompositionContext context) + { + OwnedRenderNodeSource source = GetOriginal()!; + Output = source.RenderNode; + } + + partial void PostDispose(bool disposing) + { + if (disposing) + GetOriginal()!.RenderNode.Dispose(); + } + } +} + +internal sealed partial class MutableOwnedRenderNodeSource : GraphNode +{ + private readonly TrackingRenderNode[] _ownedNodes; + + public MutableOwnedRenderNodeSource(params TrackingRenderNode[] ownedNodes) + { + ArgumentOutOfRangeException.ThrowIfZero(ownedNodes.Length); + _ownedNodes = ownedNodes; + Current = ownedNodes[0]; + Output = AddOutput("Output"); + } + + public RenderNode? Current { get; private set; } + + public OutputPort Output { get; } + + public void Select(RenderNode? node) + { + if (node is not null && !_ownedNodes.Contains(node)) + throw new ArgumentException("The source can only select a node it owns.", nameof(node)); + + Current = node; + } + + public partial class Resource + { + public override void Update(GraphCompositionContext context) + { + Output = GetOriginal()!.Current; + } + + partial void PostDispose(bool disposing) + { + if (!disposing) return; + + foreach (TrackingRenderNode node in GetOriginal()!._ownedNodes) + { + node.Dispose(); + } + } + } +} + +internal sealed class TrackingRenderNode : RenderNode +{ + public int ProcessCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + ProcessCount++; + context.PassThrough(); + } +} + +internal sealed class NonValueCommandRenderNode : RenderNode +{ + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand([], TargetCommandDescription.CreateRequestLocal( + static _ => { }, + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None))); + } +} diff --git a/tests/Beutl.UnitTests/NodeGraph/GraphSnapshotTests.cs b/tests/Beutl.UnitTests/NodeGraph/GraphSnapshotTests.cs index 9157e85300..512f10c857 100644 --- a/tests/Beutl.UnitTests/NodeGraph/GraphSnapshotTests.cs +++ b/tests/Beutl.UnitTests/NodeGraph/GraphSnapshotTests.cs @@ -1,4 +1,5 @@ using Beutl.Composition; +using Beutl.Graphics; using Beutl.Media.Proxy; using Beutl.NodeGraph; using Beutl.NodeGraph.Composition; @@ -20,12 +21,14 @@ public void Evaluate_RefreshesRoutingFlagsWithoutRebuild() DisableResourceShare = false, PreferProxy = true, PreferredProxyPreset = ProxyPreset.Half, + TargetDomain = new Rect(0, 0, 1920, 1080), }; var secondContext = new CompositionContext(TimeSpan.FromSeconds(1)) { DisableResourceShare = true, PreferProxy = false, PreferredProxyPreset = ProxyPreset.Eighth, + TargetDomain = new Rect(0, 0, 1280, 720), }; snapshot.Build(model, firstContext); @@ -40,6 +43,8 @@ public void Evaluate_RefreshesRoutingFlagsWithoutRebuild() Assert.That(node.CapturedContexts[1].DisableResourceShare, Is.True); Assert.That(node.CapturedContexts[1].PreferProxy, Is.False); Assert.That(node.CapturedContexts[1].PreferredProxyPreset, Is.EqualTo(ProxyPreset.Eighth)); + Assert.That(node.CapturedContexts[0].TargetDomain, Is.EqualTo(firstContext.TargetDomain)); + Assert.That(node.CapturedContexts[1].TargetDomain, Is.EqualTo(secondContext.TargetDomain)); }); } } @@ -52,11 +57,12 @@ public partial class Resource { public override void Update(GraphCompositionContext context) { - var node = (ContextCaptureNode)GetOriginal(); + ContextCaptureNode node = GetOriginal()!; node.CapturedContexts.Add(new CapturedGraphContext( context.DisableResourceShare, context.PreferProxy, - context.PreferredProxyPreset)); + context.PreferredProxyPreset, + context.TargetDomain)); } } } @@ -64,4 +70,5 @@ public override void Update(GraphCompositionContext context) internal readonly record struct CapturedGraphContext( bool DisableResourceShare, bool PreferProxy, - ProxyPreset PreferredProxyPreset); + ProxyPreset PreferredProxyPreset, + Rect? TargetDomain); diff --git a/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs b/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs index 51b4656b31..4a7a2a2501 100644 --- a/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs +++ b/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs @@ -1,17 +1,25 @@ using System.Linq; +using System.Reflection; using Beutl.Composition; using Beutl.Engine; using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Media; using Beutl.Media.Proxy; +using Beutl.Media.Source; using Beutl.NodeGraph; +using Beutl.NodeGraph.Composition; using Beutl.NodeGraph.Nodes; +using Beutl.NodeGraph.Nodes.Utilities; +using Beutl.UnitTests.Engine.Graphics.Rendering; +using SkiaSharp; namespace Beutl.UnitTests.NodeGraph; -// NodeGraphFilterEffectRenderNode.Process forwards OutputScale / MaxWorkingScale into the inner -// processor that walks the graph. These tests assert those scales reach the effect inside the graph. +// NodeGraphFilterEffectRenderNode.Process forwards OutputScale / MaxWorkingScale through request-local +// graph recording. These tests assert those scales reach the effect inside the graph. [TestFixture] public class NodeGraphFilterEffectRenderNodeTests { @@ -38,13 +46,6 @@ private static NodeGraphFilterEffect.Resource BuildGraphResource() return effect.ToResource(CompositionContext.Default); } - private static RenderNodeOperation SourceOp(float density) - => RenderNodeOperation.CreateLambda( - new Rect(0, 0, 120, 90), - _ => { }, - hitTest: _ => false, - effectiveScale: EffectiveScale.At(density)); - // An At(1) source lets OutputScale drive the working scale: w = max(s_out, 1). [TestCase(1.0f, 1.0f)] [TestCase(2.0f, 2.0f)] @@ -52,15 +53,14 @@ private static RenderNodeOperation SourceOp(float density) public void Process_ForwardsOutputScale_IntoGraphOutputSubtree(float outputScale, float expectedW) { using NodeGraphFilterEffect.Resource resource = BuildGraphResource(); - using FilterEffectRenderNode node = resource.CreateRenderNode(); - var context = new RenderNodeContext([SourceOp(1.0f)], outputScale: outputScale); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + resource.CreateRenderNode(), + EffectiveScale.At(1), + outputScale); - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty, "the graph dropped the input op"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + Assert.That(measurement.HasFragments, Is.True, "the graph dropped the input fragment"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), "the forwarded OutputScale did not drive the working scale inside the graph"); - DisposeAll(ops); } // An At(4) source pushes supply above s_out, so only the forwarded MaxWorkingScale can cap it. @@ -69,15 +69,15 @@ public void Process_ForwardsOutputScale_IntoGraphOutputSubtree(float outputScale public void Process_ForwardsMaxWorkingScale_IntoGraphOutputSubtree(float maxWorkingScale, float expectedW) { using NodeGraphFilterEffect.Resource resource = BuildGraphResource(); - using FilterEffectRenderNode node = resource.CreateRenderNode(); - var context = new RenderNodeContext([SourceOp(4.0f)], outputScale: 1.0f, maxWorkingScale: maxWorkingScale); + RenderNodeMeasurement measurement = ScaleRecordingTestHelper.MeasureThrough( + resource.CreateRenderNode(), + EffectiveScale.At(4), + outputScale: 1, + maxWorkingScale); - RenderNodeOperation[] ops = node.Process(context); - - Assert.That(ops, Is.Not.Empty, "the graph dropped the input op"); - Assert.That(ops[0].EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), + Assert.That(measurement.HasFragments, Is.True, "the graph dropped the input fragment"); + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(expectedW).Within(1e-4), "the forwarded MaxWorkingScale did not cap the working scale inside the graph"); - DisposeAll(ops); } [Test] @@ -104,6 +104,27 @@ public void ToResource_CapturesProxyPreferencesFromCompositionContext() }); } + // The render node leaves ChildNodes empty, so it must remain dirty after every graph rebuild and never warm + // its own persistent cache. + [Test] + public void RepeatedBuilds_NeverAdmitTheGraphRenderNodeToTheCache() + { + var effect = new NodeGraphFilterEffect(); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + using FilterEffectRenderNode node = resource.CreateRenderNode(); + + for (int i = 0; i < RenderNodeCache.StableRequestCount + 1; i++) + { + bool updateOnly = false; + resource.Update(effect, CompositionContext.Default, ref updateOnly); + node.Update(resource); + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + + Assert.That(node.Cache.CanCapture, Is.False, + "a build left the graph render node cacheable although its subtree is invisible to the cache pass"); + } + } + [Test] public void ToResource_DefaultContext_LeavesProxyPreferencesOff() { @@ -114,52 +135,1556 @@ public void ToResource_DefaultContext_LeavesProxyPreferencesOff() Assert.That(resource.PreferProxy, Is.False); } - private static void DisposeAll(RenderNodeOperation[] ops) + [Test] + public void Process_WhenGraphResourceIsDisabled_PassesThroughWithoutEvaluatingGraph() + { + var bounds = new Rect(2, 3, 18, 12); + var graph = BuildUtilityGraph(connectPreview: true); + using NodeGraphFilterEffect.Resource resource = graph.Resource; + resource.IsEnabled = false; + NodeMonitor?> monitor = GetPreviewMonitor(graph.Preview); + monitor.IsEnabled = true; + using Ref previous = Ref.Create(new Bitmap(1, 1)); + monitor.Value = previous; + var source = new CountingOpaqueSourceRenderNode(bounds); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bounds, Is.EqualTo(bounds)); + Assert.That(source.ExecutionCount, Is.EqualTo(1)); + Assert.That(graph.Shared.EvaluationCount, Is.Zero, + "A disabled NodeGraphFilterEffect resource must not evaluate its snapshot."); + Assert.That(graph.Shared.ProcessCount, Is.Zero); + Assert.That(monitor.Value, Is.SameAs(previous)); + Assert.That(previous.Value.IsDisposed, Is.False); + }); + } + + [Test] + public void MeasureAndPreview_UseBoundInputAndShareOneRecording() + { + var bounds = new Rect(7, 11, 48, 32); + var graph = BuildUtilityGraph(connectPreview: true); + using NodeGraphFilterEffect.Resource resource = graph.Resource; + NodeMonitor?> monitor = GetPreviewMonitor(graph.Preview); + monitor.IsEnabled = true; + Ref previous = Ref.Create(new Bitmap(1, 1)); + monitor.Value = previous; + + var source = new CountingOpaqueSourceRenderNode(bounds); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + RenderNodeMeasurement measurement = renderer.Measure(); + + Assert.Multiple(() => + { + Assert.That(measurement.QueryBounds, Is.EqualTo(bounds)); + Assert.That(graph.MeasureCapture.Value, Is.EqualTo(bounds), + "Measure must read the request-bound FilterEffect input metadata."); + Assert.That(graph.Shared.ProcessCount, Is.EqualTo(1), + "Measure, Preview, and Output must share one identity-cached subtree recording."); + Assert.That(source.ExecutionCount, Is.Zero, + "Preview readback must not execute while the request is only being recorded/measured."); + Assert.That(monitor.Value, Is.SameAs(previous)); + Assert.That(previous.Value, Is.Not.Null); + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Ref? replacement = monitor.Value; + Assert.Multiple(() => + { + Assert.That(graph.Shared.ProcessCount, Is.EqualTo(2), + "The shared subtree should be recorded once in each request."); + Assert.That(source.ExecutionCount, Is.EqualTo(1), + "Fan-out to Output and Preview must not duplicate the deferred source side effect."); + Assert.That(replacement, Is.Not.Null.And.Not.SameAs(previous)); + Assert.That(replacement!.Value.Width, Is.EqualTo(48)); + Assert.That(replacement.Value.Height, Is.EqualTo(32)); + Assert.That(previous.Value, Is.Null, + "Replacing the monitor value must release the previous Ref ownership."); + }); + + replacement?.Dispose(); + } + + [Test] + public void Preview_WithNullInput_DefersClearUntilExecution() + { + var graph = BuildUtilityGraph(connectPreview: false); + using NodeGraphFilterEffect.Resource resource = graph.Resource; + NodeMonitor?> monitor = GetPreviewMonitor(graph.Preview); + monitor.IsEnabled = true; + Ref previous = Ref.Create(new Bitmap(1, 1)); + monitor.Value = previous; + + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 16, 12)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + renderer.Measure(); + Assert.That(monitor.Value, Is.SameAs(previous), + "An empty preview must not mutate its monitor during recording."); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Assert.Multiple(() => + { + Assert.That(monitor.Value, Is.Null); + Assert.That(previous.Value, Is.Null, + "The deferred empty-preview command must release the previous monitor value."); + }); + } + + [Test] + public void Preview_ZeroOrOneInputThatProducesNoValue_ClearsDuringExecution() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + using var emptyRenderNode = new EmptyZeroOrOneRenderNode(new Rect(0, 0, 16, 12)); + var emptyNode = new FixedRenderNodeGraphNode(emptyRenderNode); + var previewNode = new PreviewNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(emptyNode); + model.Nodes.Add(previewNode); + model.Nodes.Add(outputNode); + model.Connect(previewNode.Input, emptyNode.Output); + model.Connect(outputNode.InputPort, emptyNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + NodeMonitor?> monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + Ref previous = Ref.Create(new Bitmap(1, 1)); + monitor.Value = previous; + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 16, 12)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(emptyRenderNode.ExecutionCount, Is.EqualTo(1)); + Assert.That(source.ExecutionCount, Is.Zero, + "The optional graph source does not consume the FilterEffect input."); + Assert.That(monitor.Value, Is.Null, + "A readback command whose optional input produced no runtime value must clear the preview."); + Assert.That(previous.Value, Is.Null); + }); + } + + [Test] + public void Preview_WhenDisabled_LeavesExistingValueUntouched() + { + var graph = BuildUtilityGraph(connectPreview: true); + using NodeGraphFilterEffect.Resource resource = graph.Resource; + NodeMonitor?> monitor = GetPreviewMonitor(graph.Preview); + monitor.IsEnabled = false; + Ref previous = Ref.Create(new Bitmap(1, 1)); + monitor.Value = previous; + + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 16, 12)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(monitor.Value, Is.SameAs(previous)); + Assert.That(previous.Value, Is.Not.Null); + }); + + previous.Dispose(); + } + + [Test] + public void Preview_WhenContentChangedThrows_RestoresPreviousOwnership() { - foreach (RenderNodeOperation op in ops) + var graph = BuildUtilityGraph(connectPreview: true); + using NodeGraphFilterEffect.Resource resource = graph.Resource; + NodeMonitor?> monitor = GetPreviewMonitor(graph.Preview); + monitor.IsEnabled = true; + using Ref previous = Ref.Create(new Bitmap(1, 1)); + Bitmap previousBitmap = previous.Value; + monitor.Value = previous; + Ref? attempted = null; + Bitmap? attemptedBitmap = null; + int notifications = 0; + EventHandler handler = (_, _) => { - op.Dispose(); + notifications++; + if (notifications == 1) + { + attempted = monitor.Value; + attemptedBitmap = attempted?.Value; + throw new InvalidOperationException("preview monitor notification failed"); + } + }; + monitor.ContentChanged += handler; + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 16, 12)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + InvalidOperationException? failure; + try + { + failure = Assert.Throws(() => + { + using RenderNodeRasterization rasterization = renderer.Rasterize(); + }); + } + finally + { + monitor.ContentChanged -= handler; } + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Does.Contain("preview monitor notification failed")); + Assert.That(notifications, Is.EqualTo(2), + "The failed assignment must issue one restoration notification."); + Assert.That(monitor.Value, Is.SameAs(previous)); + Assert.That(previous.Value, Is.SameAs(previousBitmap)); + Assert.That(previousBitmap.IsDisposed, Is.False); + Assert.That(attempted, Is.Not.Null.And.Not.SameAs(previous)); + Assert.That(attempted!.Value, Is.Null, + "The failed replacement Ref must be released exactly once by its caller."); + Assert.That(attemptedBitmap, Is.Not.Null); + Assert.That(attemptedBitmap!.IsDisposed, Is.True); + }); } -} -// A GPU-free FilterEffect whose render node stamps the resolved working scale onto passthrough ops, -// exposing the scale that NodeGraphFilterEffectRenderNode forwarded. -[SuppressResourceClassGeneration] -internal sealed partial class ScaleProbeEffect : FilterEffect -{ - public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + [Test] + public void Preview_MixedMultipleOutputs_AreCompositedBeforeDeferredReadback() { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var mixedNode = new MixedPreviewGraphNode(); + var previewNode = new PreviewNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(mixedNode); + model.Nodes.Add(previewNode); + model.Nodes.Add(outputNode); + model.Connect(mixedNode.Input, inputNode.Output); + model.Connect(previewNode.Input, mixedNode.Output); + model.Connect(outputNode.InputPort, mixedNode.Output); + + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + NodeMonitor?> monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 20, 10)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(monitor.Value, Is.Not.Null); + Assert.That(monitor.Value!.Value.Width, Is.EqualTo(20)); + Assert.That(monitor.Value.Value.Height, Is.EqualTo(10)); + Assert.That(source.ExecutionCount, Is.EqualTo(1), + "The normalized preview layer must be reused by the later graph output."); + Assert.That(mixedNode.CommandExecutionCount, Is.EqualTo(1), + "A mixed non-value output must stay ordered inside the normalized preview layer."); + }); + + monitor.Value?.Dispose(); } - public override Resource ToResource(CompositionContext context) + [Test] + public void DuplicateOutputRoots_NormalizeSharedNonValueSubtreeBeforePublication() { - var resource = new Resource(); - bool updateOnly = false; - resource.Update(this, context, ref updateOnly); - return resource; + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var mixedNode = new MixedPreviewGraphNode(); + var firstOutput = new OutputNode(); + var secondOutput = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(mixedNode); + model.Nodes.Add(firstOutput); + model.Nodes.Add(secondOutput); + model.Connect(mixedNode.Input, inputNode.Output); + model.Connect(firstOutput.InputPort, mixedNode.Output); + model.Connect(secondOutput.InputPort, mixedNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 20, 10)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(source.ExecutionCount, Is.EqualTo(1)); + Assert.That(mixedNode.CommandExecutionCount, Is.EqualTo(1)); + }); } - public new sealed class Resource : FilterEffect.Resource + [Test] + public void InputFacadeFanOut_RendersByteIdenticallyToSingleOutputControl() { - public override FilterEffectRenderNode CreateRenderNode() => new ScaleProbeRenderNode(this); + using NodeGraphFilterEffect.Resource controlResource = BuildFanOutGraph( + transform: null, + outputCount: 1); + using NodeGraphFilterEffect.Resource fanOutResource = BuildFanOutGraph( + transform: null, + outputCount: 2); + using Bitmap control = RasterizeGraph(controlResource, transformInputBeforeGraph: true); + using Bitmap fanOut = RasterizeGraph(fanOutResource, transformInputBeforeGraph: true); + + Assert.Multiple(() => + { + Assert.That((fanOut.Width, fanOut.Height), Is.EqualTo((control.Width, control.Height))); + Assert.That( + fanOut.GetPixelSpan().SequenceEqual(control.GetPixelSpan()), + Is.True, + "Publishing the input facade twice must match the equivalent single-output graph."); + }); } -} -internal sealed class ScaleProbeRenderNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) -{ - public override RenderNodeOperation[] Process(RenderNodeContext context) - { - // Resolve w as FilterEffectRenderNode.Process would, but skip its GPU path (buffer-budget clamp + - // SkiaSharp build/rasterize). So w is the forwarded supply-driven scale, not a real final scale. - EffectiveScale[] scales = context.Input.Select(i => i.EffectiveScale).ToArray(); - float w = RenderNodeContext.ResolveWorkingScale(scales, context.OutputScale, context.MaxWorkingScale); - return context.Input.Select(input => RenderNodeOperation.CreateLambda( - input.Bounds, - input.Render, - hitTest: input.HitTest, - onDispose: input.Dispose, - effectiveScale: EffectiveScale.At(w))) - .ToArray(); + [Test] + public void TransformNodeFanOut_RendersByteIdenticallyToSingleOutputControl() + { + Matrix rotation = Matrix.CreateRotation(MathF.PI * 25f / 180f); + using NodeGraphFilterEffect.Resource controlResource = BuildFanOutGraph(rotation, outputCount: 1); + using NodeGraphFilterEffect.Resource fanOutResource = BuildFanOutGraph(rotation, outputCount: 2); + using Bitmap control = RasterizeGraph(controlResource); + using Bitmap fanOut = RasterizeGraph(fanOutResource); + + Assert.Multiple(() => + { + Assert.That((fanOut.Width, fanOut.Height), Is.EqualTo((control.Width, control.Height))); + Assert.That( + fanOut.GetPixelSpan().SequenceEqual(control.GetPixelSpan()), + Is.True, + "Publishing a shared TransformNode result twice must match the single-output control."); + }); + } + + [Test] + public void DivergentFilterBranches_RenderByteIdenticallyToSeparateGraphControl() + { + using NodeGraphFilterEffect.Resource fanOutResource = BuildDivergentFilterGraph( + includeBlur: true, + includeBrightness: true); + using Bitmap fanOut = RasterizeGraph(fanOutResource, transformInputBeforeGraph: true); + using NodeGraphFilterEffect.Resource blurResource = BuildDivergentFilterGraph( + includeBlur: true, + includeBrightness: false); + using NodeGraphFilterEffect.Resource brightnessResource = BuildDivergentFilterGraph( + includeBlur: false, + includeBrightness: true); + using Bitmap control = RasterizeSeparateGraphs( + blurResource, + brightnessResource, + transformInputBeforeGraph: true); + + Assert.Multiple(() => + { + Assert.That((fanOut.Width, fanOut.Height), Is.EqualTo((control.Width, control.Height))); + Assert.That( + fanOut.GetPixelSpan().SequenceEqual(control.GetPixelSpan()), + Is.True, + "Divergent filter branches must match two equivalent non-fan-out graphs."); + }); + } + + [Test] + public void PreviewOnlyInputFacade_RendersFrameAndPreview() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + var preview = new PreviewNode(); + model.Nodes.Add(input); + model.Nodes.Add(preview); + model.Connect(preview.Input, input.Output); + NodeMonitor?> monitor = GetPreviewMonitor(preview); + monitor.IsEnabled = true; + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new TransformRenderNode(Matrix.CreateTranslation(7, 4), TransformOperator.Prepend), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(monitor.Value, Is.Not.Null); + }); + monitor.Value?.Dispose(); + } + + [Test] + public void PreviewOnlySourceNode_RendersFrameAndPreview() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + using var previewSource = new CountingOpaqueSourceRenderNode(new Rect(8, 6, 18, 14)); + var sourceNode = new FixedRenderNodeGraphNode(previewSource); + var preview = new PreviewNode(); + model.Nodes.Add(input); + model.Nodes.Add(sourceNode); + model.Nodes.Add(preview); + model.Connect(preview.Input, sourceNode.Output); + NodeMonitor?> monitor = GetPreviewMonitor(preview); + monitor.IsEnabled = true; + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(monitor.Value, Is.Not.Null); + }); + monitor.Value?.Dispose(); + } + + // A NodeGraphFilterEffect on a DrawableGroup nests PushFilterEffect around PushLayer, so the graph's + // bound inputs are a TargetLayerScope(Full) whose recording metadata stays symbolic. + [Test] + public void Preview_WithSymbolicInputBounds_RendersTheOwningTargetDomain() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var previewNode = new PreviewNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(previewNode); + model.Nodes.Add(outputNode); + model.Connect(previewNode.Input, inputNode.Output); + model.Connect(outputNode.InputPort, inputNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + NodeMonitor?> monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new LayerRenderNode(default), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 64, 48), + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(monitor.Value, Is.Not.Null, + "A symbolic-bounds subtree must not be previewed as an empty subtree."); + Assert.That(monitor.Value!.Value.Width, Is.EqualTo(64)); + Assert.That(monitor.Value.Value.Height, Is.EqualTo(48)); + }); + + monitor.Value?.Dispose(); + } + + [Test] + public void Preview_FullTargetClearUsesTheResolvedTargetExtent() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + using var clearRenderNode = new ClearRenderNode(Colors.CornflowerBlue); + var clearNode = new FixedRenderNodeGraphNode(clearRenderNode); + var previewNode = new PreviewNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(clearNode); + model.Nodes.Add(previewNode); + model.Nodes.Add(outputNode); + model.Connect(previewNode.Input, clearNode.Output); + model.Connect(outputNode.InputPort, clearNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + NodeMonitor?> monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 16, 12)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 64, 48), + CacheOptions = RenderCacheOptions.Disabled, + }, + TargetFactory = new CpuTargetFactory(), + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(monitor.Value, Is.Not.Null, + "A Full target write must not be classified as empty from its zero-area query bounds."); + Assert.That(monitor.Value!.Value.Width, Is.EqualTo(64)); + Assert.That(monitor.Value.Value.Height, Is.EqualTo(48)); + }); + + monitor.Value?.Dispose(); + } + + // The recording node observes its own local space, which every enclosing target scope separates from + // root space, so a normalizing layer sized from the root request rect clips local content away. + // The child paints at local (-40,-40,80,80) and the enclosing translate maps it to root (60,60,80,80). + [TestCase(true)] + [TestCase(false)] + public void SymbolicNormalization_UnderATranslatedEnclosingScope_KeepsContentOutsideTheRootDomainRect( + bool preview) + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(outputNode); + model.Connect(outputNode.InputPort, inputNode.Output); + NodeMonitor?>? monitor = null; + if (preview) + { + var previewNode = new PreviewNode(); + model.Nodes.Add(previewNode); + model.Connect(previewNode.Input, inputNode.Output); + monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + } + else + { + var secondOutput = new OutputNode(); + model.Nodes.Add(secondOutput); + model.Connect(secondOutput.InputPort, inputNode.Output); + } + + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var content = new Rect(60, 60, 80, 80); + var source = new CountingOpaqueSourceRenderNode(new Rect(-40, -40, 80, 80)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new LayerRenderNode(default), + resource.CreateRenderNode(), + new TransformRenderNode(Matrix.CreateTranslation(100, 100), TransformOperator.Prepend)); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 200, 200), + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + Bitmap bitmap = rasterization.Bitmap + ?? throw new AssertionException("The translated graph subtree produced no bitmap."); + Assert.That(rasterization.Bounds.Contains(content), Is.True, + "Normalizing the graph subtree must not shrink the output extent to the root domain rect " + + "reinterpreted in the recording node's local space."); + + var sample = bitmap.SKBitmap.GetPixel( + (int)(content.Center.X - rasterization.Bounds.X), + (int)(content.Center.Y - rasterization.Bounds.Y)); + + Assert.That(sample.Alpha, Is.EqualTo(byte.MaxValue), + "Content whose local coordinates fall outside the root domain rect must still be composited."); + + monitor?.Value?.Dispose(); + } + + [Test] + public void DuplicateOutputRoots_WithSymbolicBounds_NormalizeAgainstTheOwningTargetDomain() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var firstOutput = new OutputNode(); + var secondOutput = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(firstOutput); + model.Nodes.Add(secondOutput); + model.Connect(firstOutput.InputPort, inputNode.Output); + model.Connect(secondOutput.InputPort, inputNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new LayerRenderNode(default), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 64, 48), + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.Bitmap, Is.Not.Null, + "Two OutputNodes on one symbolic producer must still render."); + Assert.That(source.ExecutionCount, Is.EqualTo(1)); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void SymbolicInputBounds_WithoutARequestTargetDomain_FailWithAnActionableMessage(bool preview) + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(outputNode); + model.Connect(outputNode.InputPort, inputNode.Output); + if (preview) + { + var previewNode = new PreviewNode(); + model.Nodes.Add(previewNode); + model.Connect(previewNode.Input, inputNode.Output); + GetPreviewMonitor(previewNode).IsEnabled = true; + } + else + { + var secondOutput = new OutputNode(); + model.Nodes.Add(secondOutput); + model.Connect(secondOutput.InputPort, inputNode.Output); + } + + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new LayerRenderNode(default), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + Assert.That( + () => renderer.Measure(), + Throws.TypeOf() + .And.Message.Contains("requires a finite TargetDomain")); + } + + [Test] + public void Measure_WithSymbolicInputBounds_ReportsTheRecordedQueryBounds() + { + var bounds = new Rect(3, 5, 24, 18); + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var measureNode = new MeasureNode(); + var captureNode = new MeasureCaptureNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(measureNode); + model.Nodes.Add(captureNode); + model.Nodes.Add(outputNode); + model.Connect(measureNode.Input, inputNode.Output); + model.Connect(captureNode.X, measureNode.X); + model.Connect(captureNode.Y, measureNode.Y); + model.Connect(captureNode.Width, measureNode.Width); + model.Connect(captureNode.Height, measureNode.Height); + model.Connect(outputNode.InputPort, inputNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + + var source = new CountingOpaqueSourceRenderNode(bounds); + using var pipeline = ScaleRecordingTestHelper.Pipeline( + source, + new LayerRenderNode(default), + resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, 64, 48), + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + + renderer.Measure(); + + Assert.That(captureNode.Value, Is.EqualTo(bounds), + "MeasureNode must report the recorded query bounds of a symbolic-bounds subtree, not zero."); + } + + [Test] + public void SharedRenderNodeCycle_IsRejectedByBoundGraphRecorder() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var cycle = new ContainerRenderNode(); + cycle.AddChild(cycle); + var cycleNode = new FixedRenderNodeGraphNode(cycle); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(cycleNode); + model.Nodes.Add(outputNode); + model.Connect(outputNode.InputPort, cycleNode.Output); + + try + { + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 8, 8)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + Assert.That( + () => renderer.Measure(), + Throws.InvalidOperationException.And.Message.Contains("node-graph render cycle")); + } + finally + { + cycle.RemoveChild(cycle); + cycle.Dispose(); + } + } + + [Test] + public void PreviewCommands_ReuseSharedDefinitionWithoutPersistentRuntimeIdentities() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var firstPreview = new PreviewNode(); + var secondPreview = new PreviewNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(firstPreview); + model.Nodes.Add(secondPreview); + model.Nodes.Add(outputNode); + model.Connect(firstPreview.Input, inputNode.Output); + model.Connect(secondPreview.Input, inputNode.Output); + model.Connect(outputNode.InputPort, inputNode.Output); + GetPreviewMonitor(firstPreview).IsEnabled = true; + GetPreviewMonitor(secondPreview).IsEnabled = true; + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var bounds = new Rect(0, 0, 18, 12); + var source = new CountingOpaqueSourceRenderNode(bounds); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + + TargetCommandDescription[] firstCommands = RecordTargetCommands(pipeline, bounds); + TargetCommandDescription[] secondCommands = RecordTargetCommands(pipeline, bounds); + Assert.Multiple(() => + { + Assert.That(firstCommands, Has.Length.EqualTo(2)); + Assert.That(secondCommands, Has.Length.EqualTo(2)); + Assert.That(firstCommands[1].DefinitionFingerprint, Is.SameAs(firstCommands[0].DefinitionFingerprint)); + Assert.That(secondCommands[0].DefinitionFingerprint, Is.SameAs(firstCommands[0].DefinitionFingerprint)); + Assert.That(secondCommands[1].DefinitionFingerprint, Is.SameAs(firstCommands[0].DefinitionFingerprint)); + foreach (TargetCommandDescription command in firstCommands) + { + Assert.That(command.Resources, Has.Count.EqualTo(1), + "The deferred callback reaches only the replacement sink, and reaches it as a declared " + + "resource rather than a capture, so it can retain no transaction handle."); + } + }); + + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using (RenderNodeRasterization first = renderer.Rasterize()) + { + } + using (RenderNodeRasterization second = renderer.Rasterize()) + { + } + + StructuralPlanCacheStatistics statistics = renderer.StructuralPlanCacheStatistics; + Assert.Multiple(() => + { + Assert.That(statistics.Compilations, Is.EqualTo(1)); + Assert.That(statistics.Misses, Is.EqualTo(1)); + Assert.That(statistics.Hits, Is.EqualTo(1)); + }); + + GetPreviewMonitor(firstPreview).Value?.Dispose(); + GetPreviewMonitor(secondPreview).Value?.Dispose(); + } + + [Test] + public void Measure_WithoutFilterBinding_PreservesStandaloneGraphBehavior() + { + var bounds = new Rect(3, 5, 24, 18); + var source = new CountingOpaqueSourceRenderNode(bounds); + using var renderNode = new LayerRenderNode(default); + renderNode.AddChild(source); + var model = new GraphModel(); + var sourceNode = new FixedRenderNodeGraphNode(renderNode); + var measureNode = new MeasureNode(); + var captureNode = new MeasureCaptureNode(); + model.Nodes.Add(sourceNode); + model.Nodes.Add(measureNode); + model.Nodes.Add(captureNode); + model.Connect(measureNode.Input, sourceNode.Output); + model.Connect(captureNode.X, measureNode.X); + model.Connect(captureNode.Y, measureNode.Y); + model.Connect(captureNode.Width, measureNode.Width); + model.Connect(captureNode.Height, measureNode.Height); + using var snapshot = new GraphSnapshot(); + + var context = new CompositionContext(TimeSpan.Zero) + { + TargetDomain = new Rect(0, 0, 64, 48), + }; + snapshot.Build(model, context); + snapshot.Evaluate(CompositionTarget.Graphics, context); + + Assert.Multiple(() => + { + Assert.That(captureNode.Value, Is.EqualTo(bounds)); + Assert.That(source.ExecutionCount, Is.Zero, + "Standalone Measure should resolve a Full target layer without executing deferred work."); + }); + } + + [Test] + public void Preview_WithoutFilterBinding_RendersTheCompositionDomainForFullScopes() + { + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 14, 9)); + using var renderNode = new LayerRenderNode(default); + renderNode.AddChild(source); + var model = new GraphModel(); + var sourceNode = new FixedRenderNodeGraphNode(renderNode); + var previewNode = new PreviewNode(); + model.Nodes.Add(sourceNode); + model.Nodes.Add(previewNode); + model.Connect(previewNode.Input, sourceNode.Output); + NodeMonitor?> monitor = GetPreviewMonitor(previewNode); + monitor.IsEnabled = true; + using var snapshot = new GraphSnapshot(); + + var context = new CompositionContext(TimeSpan.Zero) + { + TargetDomain = new Rect(0, 0, 64, 48), + }; + snapshot.Build(model, context); + snapshot.Evaluate(CompositionTarget.Graphics, context); + + Assert.Multiple(() => + { + Assert.That(source.ExecutionCount, Is.EqualTo(1)); + Assert.That(monitor.Value, Is.Not.Null); + // A Full isolation scope makes the composition domain the root output extent + // (RootOutputExtent covers conservative final writes; only Measure/HitTest + // report the tight query bounds). + Assert.That(monitor.Value!.Value.Width, Is.EqualTo(64)); + Assert.That(monitor.Value.Value.Height, Is.EqualTo(48)); + }); + + monitor.Value?.Dispose(); + } + + [TestCase(false)] + [TestCase(true)] + public void StandaloneUtility_DoesNotRetryAnUnrelatedInvalidOperationException(bool preview) + { + using var renderNode = new ThrowingRenderNode(); + var model = new GraphModel(); + var sourceNode = new FixedRenderNodeGraphNode(renderNode); + model.Nodes.Add(sourceNode); + if (preview) + { + var previewNode = new PreviewNode(); + model.Nodes.Add(previewNode); + model.Connect(previewNode.Input, sourceNode.Output); + GetPreviewMonitor(previewNode).IsEnabled = true; + } + else + { + var measureNode = new MeasureNode(); + model.Nodes.Add(measureNode); + model.Connect(measureNode.Input, sourceNode.Output); + } + + using var snapshot = new GraphSnapshot(); + var context = new CompositionContext(TimeSpan.Zero) + { + TargetDomain = new Rect(0, 0, 64, 48), + }; + snapshot.Build(model, context); + + Assert.That( + () => snapshot.Evaluate(CompositionTarget.Graphics, context), + Throws.TypeOf().With.Message.EqualTo(ThrowingRenderNode.Message)); + Assert.That(renderNode.ProcessCount, Is.EqualTo(1), + "An unrelated InvalidOperationException must propagate without a target-domain retry."); + } + + [Test] + public void SharedNonValueSubtree_ThrowsAtSecondNodeGraphConsumer() + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var sharedNode = new SharedNonValueSubtreeGraphNode(); + var outputNode = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(sharedNode); + model.Nodes.Add(outputNode); + model.Connect(outputNode.InputPort, sharedNode.Output); + using var resource = (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + var source = new CountingOpaqueSourceRenderNode(new Rect(0, 0, 8, 8)); + using var pipeline = ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode()); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + + Assert.That( + () => renderer.Measure(), + Throws.InvalidOperationException.And.Message.Contains("used by more than one consumer")); + } + + private static UtilityGraph BuildUtilityGraph(bool connectPreview) + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var inputNode = new FilterEffectInputNode(); + var shared = new CountingPassThroughGraphNode(); + var measure = new MeasureNode(); + var measureCapture = new MeasureCaptureNode(); + var preview = new PreviewNode(); + var output = new OutputNode(); + model.Nodes.Add(inputNode); + model.Nodes.Add(shared); + model.Nodes.Add(measure); + model.Nodes.Add(measureCapture); + model.Nodes.Add(preview); + model.Nodes.Add(output); + + model.Connect(shared.Input, inputNode.Output); + model.Connect(measure.Input, shared.Output); + model.Connect(measureCapture.X, measure.X); + model.Connect(measureCapture.Y, measure.Y); + model.Connect(measureCapture.Width, measure.Width); + model.Connect(measureCapture.Height, measure.Height); + if (connectPreview) + model.Connect(preview.Input, shared.Output); + model.Connect(output.InputPort, shared.Output); + + return new UtilityGraph( + (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default), + shared, + measureCapture, + preview); + } + + private static NodeGraphFilterEffect.Resource BuildFanOutGraph(Matrix? transform, int outputCount) + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + model.Nodes.Add(input); + IOutputPort branchOutput = input.Output; + + if (transform is { } matrix) + { + var transformNode = new TransformNode(); + transformNode.Matrix.Property!.SetValue(matrix); + model.Nodes.Add(transformNode); + model.Connect((IInputPort)transformNode.Items[1], input.Output); + branchOutput = (IOutputPort)transformNode.Items[0]; + } + + for (int index = 0; index < outputCount; index++) + { + var output = new OutputNode(); + model.Nodes.Add(output); + model.Connect(output.InputPort, branchOutput); + } + + return (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + } + + private static NodeGraphFilterEffect.Resource BuildDivergentFilterGraph( + bool includeBlur, + bool includeBrightness) + { + var effect = new NodeGraphFilterEffect(); + GraphModel model = effect.Model.CurrentValue!; + var input = new FilterEffectInputNode(); + model.Nodes.Add(input); + + if (includeBlur) + { + var blur = new FilterEffectNode(); + blur.Object.Sigma.CurrentValue = new Size(4, 4); + var output = new OutputNode(); + model.Nodes.Add(blur); + model.Nodes.Add(output); + model.Connect((IInputPort)blur.Items[1], input.Output); + model.Connect(output.InputPort, (IOutputPort)blur.Items[0]); + } + + if (includeBrightness) + { + var brightness = new FilterEffectNode(); + brightness.Object.Amount.CurrentValue = 140f; + var output = new OutputNode(); + model.Nodes.Add(brightness); + model.Nodes.Add(output); + model.Connect((IInputPort)brightness.Items[1], input.Output); + model.Connect(output.InputPort, (IOutputPort)brightness.Items[0]); + } + + return (NodeGraphFilterEffect.Resource)effect.ToResource(CompositionContext.Default); + } + + private static Bitmap RasterizeGraph( + NodeGraphFilterEffect.Resource resource, + bool transformInputBeforeGraph = false) + { + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + RenderNode graphInput = transformInputBeforeGraph + ? new TransformRenderNode(Matrix.CreateTranslation(7, 4), TransformOperator.Prepend) + : resource.CreateRenderNode(); + using var pipeline = transformInputBeforeGraph + ? ScaleRecordingTestHelper.Pipeline(source, graphInput, resource.CreateRenderNode()) + : ScaleRecordingTestHelper.Pipeline(source, graphInput); + using var renderer = new RenderNodeRenderer(pipeline, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + return rasterization.Bitmap?.Clone() + ?? throw new AssertionException("The node graph did not produce a bitmap."); + } + + private static Bitmap RasterizeSeparateGraphs( + NodeGraphFilterEffect.Resource first, + NodeGraphFilterEffect.Resource second, + bool transformInputBeforeGraph) + { + using var root = new ContainerRenderNode(); + foreach (NodeGraphFilterEffect.Resource resource in new[] { first, second }) + { + var source = new CountingOpaqueSourceRenderNode(new Rect(3, 5, 24, 18)); + root.AddChild(transformInputBeforeGraph + ? ScaleRecordingTestHelper.Pipeline( + source, + new TransformRenderNode(Matrix.CreateTranslation(7, 4), TransformOperator.Prepend), + resource.CreateRenderNode()) + : ScaleRecordingTestHelper.Pipeline(source, resource.CreateRenderNode())); + } + using var renderer = new RenderNodeRenderer(root, new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + return rasterization.Bitmap?.Clone() + ?? throw new AssertionException("The separate graph control did not produce a bitmap."); + } + + private static NodeMonitor?> GetPreviewMonitor(PreviewNode node) + => node.Items.OfType?>>().Single(); + + private static TargetCommandDescription[] RecordTargetCommands(RenderNode root, Rect targetDomain) + { + using var request = new RenderRequest(new RenderRequestOptions( + RenderIntent.Preview, + RenderRequestPurpose.Frame, + targetDomain, + targetDomain, + cachePolicy: RenderCacheOptions.Disabled)); + RecordedRenderGraph graph = new RenderRequestRecorder(request).Record(root); + return graph.Fragments + .Select(static fragment => (RenderFragmentReference)fragment.Payload!) + .Select(static reference => reference.Payload) + .OfType() + .Select(static payload => payload.Description) + .ToArray(); + } + + private sealed record UtilityGraph( + NodeGraphFilterEffect.Resource Resource, + CountingPassThroughGraphNode Shared, + MeasureCaptureNode MeasureCapture, + PreviewNode Preview); + + private sealed class CpuTargetFactory : IRenderTargetFactory + { + public RenderTarget Create(RenderTargetAllocationDescriptor allocation) + => new CpuRenderTarget(allocation.DeviceSize); + } + + private sealed class CpuRenderTarget(PixelSize size) + : RenderTarget( + SKSurface.Create(new SKImageInfo( + size.Width, + size.Height, + SKColorType.RgbaF16, + SKAlphaType.Premul, + SKColorSpace.CreateSrgbLinear())) + ?? throw new InvalidOperationException("Could not create a CPU NodeGraph preview test surface."), + size.Width, + size.Height); + +} + +// A GPU-free FilterEffect whose render node stamps the resolved working scale onto pass-through fragments, +// exposing the scale that NodeGraphFilterEffectRenderNode forwarded. +[SuppressResourceClassGeneration] +internal sealed partial class ScaleProbeEffect : FilterEffect +{ + public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource) + { + } + + public override Resource ToResource(CompositionContext context) + { + var resource = new Resource(); + bool updateOnly = false; + resource.Update(this, context, ref updateOnly); + return resource; + } + + public new sealed class Resource : FilterEffect.Resource + { + public Resource() + { + } + + public override FilterEffectRenderNode CreateRenderNode() => new ScaleProbeRenderNode(this); + } +} + +internal sealed class ScaleProbeRenderNode(FilterEffect.Resource fe) : FilterEffectRenderNode(fe) +{ + public override void Process(RenderNodeContext context) + { + // Resolve w as FilterEffectRenderNode.Process would, but retain an identity opaque map so the + // forwarded supply-driven scale can be observed without invoking a GPU filter during recording. + foreach (RenderFragmentHandle input in context.Inputs) + { + OpaqueRenderDescription description = OpaqueRenderDescription.CreateRequestLocal( + execute: session => + { + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(session.Inputs[0].Draw); + session.Publish(output); + }, + bounds: OpaqueRenderBoundsContract.Map(RenderBoundsContract.Identity), + hitTest: RenderHitTestContract.AnyInput, + valueCardinality: RenderValueCardinality.Single, + scale: RenderScaleContract.Custom( + static metadata => RenderScaleUtilities.ResolveWorkingScale( + metadata.InputSupplies.ToArray(), + metadata.OutputScale, + metadata.MaxWorkingScale))); + context.Publish(context.OpaqueMap(input, description)); + } + } +} + +internal sealed partial class MeasureCaptureNode : GraphNode +{ + public MeasureCaptureNode() + { + X = AddInput("X"); + Y = AddInput("Y"); + Width = AddInput("Width"); + Height = AddInput("Height"); + } + + public InputPort X { get; } + + public InputPort Y { get; } + + public InputPort Width { get; } + + public InputPort Height { get; } + + public Rect Value { get; private set; } + + public partial class Resource + { + public override void Update(GraphCompositionContext context) + { + MeasureCaptureNode node = GetOriginal()!; + node.Value = new Rect(X, Y, Width, Height); + } + } +} + +internal sealed partial class FixedRenderNodeGraphNode : GraphNode +{ + public FixedRenderNodeGraphNode(RenderNode value) + { + Value = value; + Output = AddOutput("Output"); + } + + public RenderNode Value { get; } + + public OutputPort Output { get; } + + public partial class Resource + { + public override void Update(GraphCompositionContext context) + { + Output = GetOriginal()!.Value; + } + } +} + +internal sealed partial class SharedNonValueSubtreeGraphNode : GraphNode +{ + public SharedNonValueSubtreeGraphNode() + { + Output = AddOutput("Output"); + } + + public OutputPort Output { get; } + + public partial class Resource + { + private ContainerRenderNode? _root; + + public override void Update(GraphCompositionContext context) + { + if (_root is null) + { + var shared = new OrderOnlyCommandRenderNode(new MixedPreviewGraphNode()); + var left = new ContainerRenderNode(); + var right = new ContainerRenderNode(); + left.AddChild(shared); + right.AddChild(shared); + _root = new ContainerRenderNode(); + _root.AddChild(left); + _root.AddChild(right); + } + + Output = _root; + } + + partial void PostDispose(bool disposing) + { + if (disposing) + _root?.Dispose(); + _root = null; + } + } +} + +internal sealed partial class CountingPassThroughGraphNode : GraphNode +{ + public CountingPassThroughGraphNode() + { + Input = AddInput("Input"); + Output = AddOutput("Output"); + } + + public InputPort Input { get; } + + public OutputPort Output { get; } + + public int ProcessCount { get; internal set; } + + public int EvaluationCount { get; internal set; } + + public partial class Resource + { + private NonOwningCountingContainerRenderNode? _renderNode; + + public override void Update(GraphCompositionContext context) + { + CountingPassThroughGraphNode node = GetOriginal()!; + node.EvaluationCount++; + if (Input is null) + { + Output = null; + return; + } + + _renderNode ??= new NonOwningCountingContainerRenderNode(node); + _renderNode.SetInput(Input); + Output = _renderNode; + } + + partial void PostDispose(bool disposing) + { + if (disposing) + _renderNode?.Dispose(); + _renderNode = null; + } + } +} + +internal sealed class NonOwningCountingContainerRenderNode(CountingPassThroughGraphNode owner) + : ContainerRenderNode +{ + private RenderNode? _input; + + public void SetInput(RenderNode input) + { + if (ReferenceEquals(_input, input)) + return; + if (_input is not null) + RemoveChild(_input); + + _input = input; + AddChild(input); + } + + public override void Process(RenderNodeContext context) + { + owner.ProcessCount++; + base.Process(context); + } + + protected override void OnDispose(bool disposing) + { + if (_input is not null) + RemoveChild(_input); + _input = null; + } +} + +internal sealed partial class MixedPreviewGraphNode : GraphNode +{ + public MixedPreviewGraphNode() + { + Input = AddInput("Input"); + Output = AddOutput("Output"); + } + + public InputPort Input { get; } + + public OutputPort Output { get; } + + public int CommandExecutionCount { get; internal set; } + + public partial class Resource + { + private NonOwningMixedContainerRenderNode? _renderNode; + + public override void Update(GraphCompositionContext context) + { + if (Input is null) + { + Output = null; + return; + } + + MixedPreviewGraphNode node = GetOriginal()!; + _renderNode ??= new NonOwningMixedContainerRenderNode(node); + _renderNode.SetInput(Input); + Output = _renderNode; + } + + partial void PostDispose(bool disposing) + { + if (disposing) + _renderNode?.Dispose(); + _renderNode = null; + } + } +} + +internal sealed class NonOwningMixedContainerRenderNode : ContainerRenderNode +{ + private readonly RenderNode _command; + private RenderNode? _input; + + public NonOwningMixedContainerRenderNode(MixedPreviewGraphNode owner) + { + _command = new OrderOnlyCommandRenderNode(owner); + AddChild(_command); + } + + public void SetInput(RenderNode input) + { + if (ReferenceEquals(_input, input)) + return; + if (_input is not null) + RemoveChild(_input); + + _input = input; + RemoveChild(_command); + AddChild(input); + AddChild(_command); + } + + protected override void OnDispose(bool disposing) + { + if (_input is not null) + RemoveChild(_input); + RemoveChild(_command); + _command.Dispose(); + _input = null; + } +} + +internal sealed class OrderOnlyCommandRenderNode(MixedPreviewGraphNode owner) : RenderNode +{ + public override void Process(RenderNodeContext context) + { + context.Publish(context.TargetCommand([], TargetCommandDescription.CreateRequestLocal( + _ => owner.CommandExecutionCount++, + TargetRegion.Empty, + Rect.Empty, + RenderHitTestContract.None))); + } +} + +internal sealed class CountingOpaqueSourceRenderNode(Rect bounds) : RenderNode +{ + public int ExecutionCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + session => + { + ExecutionCount++; + using OpaqueRenderOutput output = session.CreateOutput(session.OutputBounds); + output.Canvas.Use(canvas => canvas.Clear(Colors.CornflowerBlue)); + session.Publish(output); + }, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.Single, + RenderScaleContract.MaterializeAtWorkingScale))); + } +} + +internal sealed class ThrowingRenderNode : RenderNode +{ + public const string Message = "unrelated render-node failure"; + + public int ProcessCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + ProcessCount++; + throw new InvalidOperationException(Message); + } +} + +internal sealed class EmptyZeroOrOneRenderNode(Rect bounds) : RenderNode +{ + public int ExecutionCount { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.Publish(context.OpaqueSource(OpaqueRenderDescription.CreateRequestLocal( + _ => ExecutionCount++, + OpaqueRenderBoundsContract.Source(bounds), + RenderHitTestContract.OutputBounds, + RenderValueCardinality.ZeroOrOne, + RenderScaleContract.MaterializeAtWorkingScale))); } } diff --git a/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableBoundsTests.cs b/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableBoundsTests.cs new file mode 100644 index 0000000000..738df91092 --- /dev/null +++ b/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableBoundsTests.cs @@ -0,0 +1,117 @@ +using Beutl.Composition; +using Beutl.Graphics; +using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.ProjectSystem; + +namespace Beutl.UnitTests.ProjectSystem; + +// A nested scene is a fixed-size viewport, so the bounds a caller queries are the referenced frame rather +// than whatever the frame happens to contain. The preview's selection outline and transform handles are +// placed from that value, and an empty or off-centre nested scene must not move or lose them. +[TestFixture] +public class SceneDrawableBoundsTests +{ + [Test] + public void NestedScene_WithoutVisualContent_KeepsFrameQueryBounds() + { + string basePath = GetTempPath(); + try + { + RenderNodeMeasurement measurement = MeasureNestedScene(CreateInnerScene(basePath, 120, 90)); + + Assert.Multiple(() => + { + Assert.That(measurement.HasFragments, Is.True); + Assert.That(measurement.QueryBounds, Is.EqualTo(new Rect(0, 0, 120, 90))); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void NestedScene_WithOffCenterContent_KeepsFrameQueryBounds() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerScene(basePath, 120, 90); + AddTopLeftRect(inner, basePath, 20); + + RenderNodeMeasurement measurement = MeasureNestedScene(inner); + + Assert.Multiple(() => + { + Assert.That(measurement.QueryBounds, Is.EqualTo(new Rect(0, 0, 120, 90))); + Assert.That(measurement.OutputBounds, Is.EqualTo(new Rect(0, 0, 20, 20)), + "widening the frame's query footprint must not widen what it actually draws"); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + private static string GetTempPath() + => Path.Combine(Path.GetTempPath(), $"beutl_scenebounds_{Guid.NewGuid():N}"); + + private static Scene CreateInnerScene(string basePath, int width, int height) + { + Directory.CreateDirectory(basePath); + return new Scene(width, height, string.Empty) + { + Uri = new Uri(Path.Combine(basePath, "inner.scene")) + }; + } + + private static void AddTopLeftRect(Scene scene, string basePath, int size) + { + var rect = new RectShape + { + Width = { CurrentValue = size }, + Height = { CurrentValue = size }, + AlignmentX = { CurrentValue = AlignmentX.Left }, + AlignmentY = { CurrentValue = AlignmentY.Top }, + }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + Uri = new Uri(Path.Combine(basePath, $"{Guid.NewGuid():N}.layer")) + }; + element.AddObject(rect); + scene.Children.Add(element); + } + + private static RenderNodeMeasurement MeasureNestedScene(Scene inner) + { + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using Drawable.Resource resource = drawable.ToResource(new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, inner.FrameSize.ToSize(1))) + { + drawable.Render(context, resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, inner.FrameSize.Width, inner.FrameSize.Height), + CacheOptions = RenderCacheOptions.Disabled, + }, + }); + return renderer.Measure(); + } +} diff --git a/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs b/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs index ed98456991..f594ed19f2 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/SceneDrawableScaleTests.cs @@ -1,14 +1,16 @@ using Beutl.Composition; using Beutl.Graphics; using Beutl.Graphics.Rendering; +using Beutl.Graphics.Rendering.Cache; using Beutl.Graphics.Shapes; using Beutl.Media; using Beutl.ProjectSystem; using Beutl.UnitTests.Engine.Graphics.Backend; +using Beutl.UnitTests.Engine.Graphics.Rendering; namespace Beutl.UnitTests.ProjectSystem; -// SceneDrawable must emit ops tagged At(w), never Unbounded. Vulkan-gated. +// SceneDrawable must preserve output scale and nested render-tree lifetime. Execution cases are Vulkan-gated. [NonParallelizable] [TestFixture] public class SceneDrawableScaleTests @@ -42,35 +44,83 @@ private static Scene CreateInnerScene(string basePath, int width, int height) return scene; } - // Pulls the single concrete op at the given output scale. - private static RenderNodeOperation PullConcreteOp(SceneDrawable drawable, Scene inner, float outputScale) + private static Scene CreateInnerSceneWithBackdrop(string basePath, int width, int height) { - Drawable.Resource resource = drawable.ToResource(new CompositionContext(TimeSpan.Zero)); - var root = new DrawableRenderNode(resource); - using (var ctx = new GraphicsContext2D(root, inner.FrameSize.ToSize(1), outputScale)) + Scene scene = CreateInnerScene(basePath, width, height); + var backdrop = new SourceBackdrop(); + var element = new Element { - drawable.Render(ctx, resource); - } + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + ZIndex = 1, + Uri = new Uri(Path.Combine(basePath, $"{Guid.NewGuid():N}.layer")) + }; + element.AddObject(backdrop); + scene.Children.Add(element); + return scene; + } - var processor = new RenderNodeProcessor(root, useRenderCache: false, outputScale: outputScale); - RenderNodeOperation[] ops = processor.PullToRoot(); + private static Scene CreateInnerSceneWithTwoDrawables(string basePath, int width, int height) + { + Scene scene = CreateInnerScene(basePath, width, height); + var rect = new RectShape + { + Width = { CurrentValue = width / 2f }, + Height = { CurrentValue = height / 2f }, + }; + var element = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + ZIndex = 1, + Uri = new Uri(Path.Combine(basePath, $"{Guid.NewGuid():N}.layer")) + }; + element.AddObject(rect); + scene.Children.Add(element); + return scene; + } - RenderNodeOperation? concrete = null; - foreach (RenderNodeOperation op in ops) + private static Scene CreateInnerSceneWithRetryingDrawable( + string basePath, + int width, + int height, + out RetryingSceneDrawable drawable) + { + Scene scene = CreateInnerScene(basePath, width, height); + drawable = new RetryingSceneDrawable(); + var element = new Element { - if (concrete == null && !op.EffectiveScale.IsUnbounded) - { - concrete = op; - } - else - { - op.Dispose(); - } + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + ZIndex = 1, + Uri = new Uri(Path.Combine(basePath, $"{Guid.NewGuid():N}.layer")) + }; + element.AddObject(drawable); + scene.Children.Add(element); + return scene; + } + + // Materializes the recorded nested-scene subtree and reports its concrete output metadata. + private static RenderNodeMeasurement MeasureConcreteOutput( + SceneDrawable drawable, + Scene inner, + float outputScale) + { + using Drawable.Resource resource = drawable.ToResource(new CompositionContext(TimeSpan.Zero)); + var root = new DrawableRenderNode(resource); + using (var ctx = new GraphicsContext2D(root, inner.FrameSize.ToSize(1), outputScale)) + { + drawable.Render(ctx, resource); } - Assert.That(concrete, Is.Not.Null, - "SceneDrawable emitted no concrete (bitmap) op — the nested-scene surface was lost or tagged Unbounded."); - return concrete!; + using var pipeline = ScaleRecordingTestHelper.SubtreePipeline( + root, + ScaleRecordingTestHelper.Layer(new Rect(0, 0, inner.FrameSize.Width, inner.FrameSize.Height)), + ScaleRecordingTestHelper.Materialize()); + return ScaleRecordingTestHelper.Measure(pipeline, outputScale); } [TestCase(1.0f)] // even at s_out == 1 the nested buffer is concrete At(1), not Unbounded vector. @@ -88,16 +138,203 @@ public void NestedScene_InheritsConcreteEffectiveScale_AtOutputScale(float outpu var drawable = new SceneDrawable(); drawable.ReferencedScene.CurrentValue = inner; - RenderNodeOperation op = PullConcreteOp(drawable, inner, outputScale); + RenderNodeMeasurement measurement = MeasureConcreteOutput(drawable, inner, outputScale); // A nested-scene buffer is concrete bitmap supply, never Unbounded. - Assert.That(op.EffectiveScale.IsUnbounded, Is.False, + Assert.That(measurement.HasFragments, Is.True, + "SceneDrawable emitted no recorded fragment for the nested scene."); + Assert.That(measurement.EffectiveScale.IsUnbounded, Is.False, "the nested-scene surface was reported as re-rasterizable Unbounded instead of a concrete bitmap"); // Inherits the outer output scale as its supply density. - Assert.That(op.EffectiveScale.Value, Is.EqualTo(outputScale).Within(1e-4), + Assert.That(measurement.EffectiveScale.Value, Is.EqualTo(outputScale).Within(1e-4), $"the nested scene did not inherit the outer output scale {outputScale} as its supply density"); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void NestedScene_WithSourceBackdrop_RasterizesAfterRecording() + { + string basePath = GetTempPath(); + try + { + VulkanTestEnvironment.EnsureAvailable(); + VulkanTestEnvironment.InvokeOnRenderThread(() => + { + Scene inner = CreateInnerSceneWithBackdrop(basePath, 120, 90); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using Drawable.Resource resource = drawable.ToResource(new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + using (var context = new GraphicsContext2D(root, inner.FrameSize.ToSize(1))) + { + drawable.Render(context, resource); + } + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(0, 0, inner.FrameSize.Width, inner.FrameSize.Height), + CacheOptions = Beutl.Graphics.Rendering.Cache.RenderCacheOptions.Disabled, + }, + }); + using RenderNodeRasterization rasterization = renderer.Rasterize(); + + Assert.Multiple(() => + { + Assert.That(rasterization.IsEmpty, Is.False); + Assert.That(rasterization.Bitmap, Is.Not.Null); + Assert.That(rasterization.Bitmap!.GetPixelSpan().ToArray(), Has.Some.Not.Zero); + }); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void NestedScene_ReusesChildrenAndWarmsStableChildCachesDespiteChangingAncestors() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerSceneWithTwoDrawables(basePath, 120, 90); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using var resource = (SceneDrawable.Resource)drawable.ToResource( + new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + RenderDrawableTree(drawable, resource, root, inner.FrameSize.ToSize(1)); + + ContainerRenderNode sceneNode = FindNestedSceneNode(root); + DrawableRenderNode[] nested = [.. sceneNode.Children.Cast()]; + Assert.That(nested, Has.Length.EqualTo(2)); + DrawableRenderNode stable = nested[0]; + DrawableRenderNode changing = nested[1]; + Drawable.Resource changingResource = resource.Frame!.Value.Objects + .OfType() + .ElementAt(1); + + CompleteSuccessfulFrame(root); + for (int frame = 0; frame < RenderNodeCache.StableRequestCount; frame++) + { + changingResource.Version++; + resource.Version++; + Assert.That(root.Update(resource), Is.True); + RenderDrawableTree(drawable, resource, root, inner.FrameSize.ToSize(1)); + + ContainerRenderNode currentSceneNode = FindNestedSceneNode(root); + Assert.Multiple(() => + { + Assert.That(currentSceneNode, Is.SameAs(sceneNode)); + Assert.That(currentSceneNode.Children[0], Is.SameAs(stable)); + Assert.That(currentSceneNode.Children[1], Is.SameAs(changing)); + }); + CompleteSuccessfulFrame(root); + } + + Assert.Multiple(() => + { + Assert.That(root.Cache.CanCapture, Is.False, "the changing parent restarts warm-up each frame"); + Assert.That(sceneNode.Cache.CanCapture, Is.False, "the changing child invalidates its ancestors"); + Assert.That(changing.Cache.CanCapture, Is.False, "the changing child restarts warm-up each frame"); + Assert.That(stable.Cache.CanCapture, Is.True, "the unchanged child retains its warm-up"); + Assert.That(stable.Cache.SuccessfulStableRequestCount, + Is.GreaterThanOrEqualTo(RenderNodeCache.StableRequestCount)); + }); + + root.Dispose(); + Assert.Multiple(() => + { + Assert.That(sceneNode.IsDisposed, Is.True); + Assert.That(stable.IsDisposed, Is.True); + Assert.That(changing.IsDisposed, Is.True); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void NestedScene_RetriesFailedChildUpdateTransactionally() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerSceneWithRetryingDrawable( + basePath, + 120, + 90, + out RetryingSceneDrawable retrying); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using var resource = (SceneDrawable.Resource)drawable.ToResource( + new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + RenderDrawableTree(drawable, resource, root, inner.FrameSize.ToSize(1)); + + ContainerRenderNode sceneNode = FindNestedSceneNode(root); + DrawableRenderNode child = sceneNode.Children + .Cast() + .Single(node => ReferenceEquals(node.Drawable!.Value.Resource.GetOriginal(), retrying)); + Drawable.Resource childResource = child.Drawable!.Value.Resource; + int originalVersion = child.Drawable.Value.Version; + RenderNode[] originalOutputs = [.. child.Children]; + Assert.That(originalOutputs, Has.Length.EqualTo(2)); - op.Dispose(); + retrying.OutputCount = 1; + retrying.ThrowAfterFirstOutput = true; + childResource.Version++; + resource.Version++; + Assert.That(root.Update(resource), Is.True); + + InvalidOperationException? failure = Assert.Throws( + () => RenderDrawableTree( + drawable, + resource, + root, + inner.FrameSize.ToSize(1))); + TrackingSceneRenderNode failedCandidate = retrying.CreatedNodes[^1]; + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("retrying nested scene")); + Assert.That(retrying.RenderCalls, Is.EqualTo(2)); + Assert.That(EnumerateSubtree(root), Does.Contain(sceneNode)); + Assert.That(sceneNode.Children, Does.Contain(child)); + Assert.That(child.Drawable!.Value.Version, Is.EqualTo(originalVersion)); + Assert.That(child.Children, Is.EqualTo(originalOutputs)); + Assert.That(originalOutputs, Has.All.Matches(node => !node.IsDisposed)); + Assert.That(failedCandidate.IsDisposed, Is.True); + }); + + retrying.ThrowAfterFirstOutput = false; + Assert.That(root.Update(resource), Is.False); + RenderDrawableTree(drawable, resource, root, inner.FrameSize.ToSize(1)); + + Assert.Multiple(() => + { + Assert.That(retrying.RenderCalls, Is.EqualTo(3)); + Assert.That(sceneNode.Children, Does.Contain(child)); + Assert.That(child.Drawable!.Value.Version, Is.EqualTo(childResource.Version)); + Assert.That(child.Children, Has.Count.EqualTo(1)); + Assert.That(child.Children[0], Is.SameAs(retrying.CreatedNodes[^1])); + Assert.That(child.Children[0].IsDisposed, Is.False); + Assert.That(originalOutputs, Has.All.Matches(node => node.IsDisposed)); }); } finally @@ -105,4 +342,302 @@ public void NestedScene_InheritsConcreteEffectiveScale_AtOutputScale(float outpu if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); } } + + [Test] + public void NestedScene_RemovingTrailingChildrenDisposesAllAfterOneFails() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerSceneWithRetryingDrawable( + basePath, + 120, + 90, + out RetryingSceneDrawable first); + first.OutputCount = 1; + var second = new RetryingSceneDrawable { OutputCount = 1 }; + var secondElement = new Element + { + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + IsEnabled = true, + ZIndex = 2, + Uri = new Uri(Path.Combine(basePath, $"{Guid.NewGuid():N}.layer")) + }; + secondElement.AddObject(second); + inner.Children.Add(secondElement); + + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + using var resource = (SceneDrawable.Resource)drawable.ToResource( + new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + RenderDrawableTree(drawable, resource, root, inner.FrameSize.ToSize(1)); + + ContainerRenderNode sceneNode = EnumerateSubtree(root) + .OfType() + .Single(node => node.Children.Count == 3 + && node.Children.All(static child => child is DrawableRenderNode)); + DrawableRenderNode firstWrapper = sceneNode.Children + .Cast() + .Single(node => ReferenceEquals(node.Drawable!.Value.Resource.GetOriginal(), first)); + DrawableRenderNode secondWrapper = sceneNode.Children + .Cast() + .Single(node => ReferenceEquals(node.Drawable!.Value.Resource.GetOriginal(), second)); + TrackingSceneRenderNode firstOutput = first.CreatedNodes.Single(); + TrackingSceneRenderNode secondOutput = second.CreatedNodes.Single(); + firstOutput.ThrowOnDispose = true; + + CompositionFrame frame = resource.Frame!.Value; + resource.Frame = new CompositionFrame([frame.Objects[0]], frame.Time, frame.Size, null); + resource.Version++; + Assert.That(root.Update(resource), Is.True); + + try + { + InvalidOperationException? failure = Assert.Throws( + () => RenderDrawableTree( + drawable, + resource, + root, + inner.FrameSize.ToSize(1))); + + Assert.Multiple(() => + { + Assert.That(failure!.Message, Is.EqualTo("tracking scene-node disposal")); + Assert.That(sceneNode.Children, Has.Count.EqualTo(1)); + Assert.That(firstOutput.DisposeCalls, Is.EqualTo(1)); + Assert.That(firstWrapper.IsDisposed, Is.False); + Assert.That(secondOutput.DisposeCalls, Is.EqualTo(1)); + Assert.That(secondOutput.IsDisposed, Is.True); + Assert.That(secondWrapper.IsDisposed, Is.True); + }); + } + finally + { + firstOutput.ThrowOnDispose = false; + firstWrapper.Dispose(); + secondWrapper.Dispose(); + } + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + /// + /// The graph is recorded once, by the GraphicsContext2D, at whatever density that context carried. A + /// request rasterizing at another one moves everything else, so a nested scene left at the recording + /// density is the one thing in the frame drawn at the wrong scale. + /// + [Test] + public void NestedScene_RebuildsWhenARequestAsksForADifferentOutputScale() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerSceneWithRetryingDrawable( + basePath, + 120, + 90, + out RetryingSceneDrawable retrying); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using var resource = (SceneDrawable.Resource)drawable.ToResource( + new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + RenderDrawableTree( + drawable, + resource, + root, + inner.FrameSize.ToSize(1), + outputScale: 1); + + using var renderer = new RenderNodeRenderer( + root, + new RenderNodeRendererOptions + { + DefaultRequest = new RenderNodeRenderRequest + { + TargetDomain = new Rect(default, inner.FrameSize.ToSize(1)), + OutputScale = 1, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Bounds, + }, + }); + renderer.Measure(); + int afterRecordingScale = retrying.RenderCalls; + + renderer.Measure(new RenderNodeRenderRequest + { + TargetDomain = new Rect(default, inner.FrameSize.ToSize(1)), + OutputScale = 2, + CacheOptions = RenderCacheOptions.Disabled, + Purpose = RenderRequestPurpose.Bounds, + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That( + afterRecordingScale, + Is.EqualTo(1), + "The control: a request at the recording density must not rebuild anything."); + Assert.That( + retrying.ObservedOutputScales, + Is.EqualTo(new[] { 1f, 2f }), + "A request at a new density must rebuild the nested graph at that density."); + } + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + [Test] + public void NestedScene_RebuildsAtANewOutputScaleWithoutReplacingChildren() + { + string basePath = GetTempPath(); + try + { + Scene inner = CreateInnerSceneWithRetryingDrawable( + basePath, + 120, + 90, + out RetryingSceneDrawable retrying); + var drawable = new SceneDrawable(); + drawable.ReferencedScene.CurrentValue = inner; + + using var resource = (SceneDrawable.Resource)drawable.ToResource( + new CompositionContext(TimeSpan.Zero)); + using var root = new DrawableRenderNode(resource); + RenderDrawableTree( + drawable, + resource, + root, + inner.FrameSize.ToSize(1), + outputScale: 1); + + ContainerRenderNode sceneNode = FindNestedSceneNode(root); + DrawableRenderNode child = sceneNode.Children + .Cast() + .Single(node => ReferenceEquals(node.Drawable!.Value.Resource.GetOriginal(), retrying)); + RenderNode[] firstOutputs = [.. child.Children]; + + RenderDrawableTree( + drawable, + resource, + root, + inner.FrameSize.ToSize(1), + outputScale: 2); + + Assert.Multiple(() => + { + Assert.That(FindNestedSceneNode(root), Is.SameAs(sceneNode)); + Assert.That(sceneNode.Children, Does.Contain(child)); + Assert.That(retrying.RenderCalls, Is.EqualTo(2)); + Assert.That(retrying.ObservedOutputScales, Is.EqualTo(new[] { 1f, 2f })); + Assert.That(child.Children, Has.Count.EqualTo(2)); + Assert.That(child.Children, Is.Not.EqualTo(firstOutputs)); + Assert.That(child.Children, Has.All.Matches(node => !node.IsDisposed)); + Assert.That(firstOutputs, Has.All.Matches(node => node.IsDisposed)); + }); + } + finally + { + if (Directory.Exists(basePath)) Directory.Delete(basePath, recursive: true); + } + } + + private static void RenderDrawableTree( + SceneDrawable drawable, + Drawable.Resource resource, + DrawableRenderNode root, + Size canvasSize, + float outputScale = 1) + { + using var context = new GraphicsContext2D(root, canvasSize, outputScale); + drawable.Render(context, resource); + } + + private static ContainerRenderNode FindNestedSceneNode(RenderNode root) + { + return EnumerateSubtree(root) + .OfType() + .Single(node => node.Children.Count == 2 + && node.Children.All(static child => child is DrawableRenderNode)); + } + + private static IEnumerable EnumerateSubtree(RenderNode node) + { + yield return node; + if (node is not ContainerRenderNode container) + yield break; + + foreach (RenderNode child in container.Children) + { + foreach (RenderNode descendant in EnumerateSubtree(child)) + yield return descendant; + } + } + + private static void CompleteSuccessfulFrame(RenderNode node) + { + RenderNodeCacheHelper.BeginLifecycle(node).CompleteSuccessfully(advanceWarmup: true); + } +} + +internal sealed partial class RetryingSceneDrawable : Drawable +{ + public bool ThrowAfterFirstOutput { get; set; } + + public int OutputCount { get; set; } = 2; + + public int RenderCalls { get; private set; } + + public List ObservedOutputScales { get; } = []; + + public List CreatedNodes { get; } = []; + + public override void Render(GraphicsContext2D context, Drawable.Resource resource) + { + RenderCalls++; + ObservedOutputScales.Add(context.OutputScale); + for (int index = 0; index < OutputCount; index++) + { + var node = new TrackingSceneRenderNode(); + CreatedNodes.Add(node); + context.DrawNode(node); + if (index == 0 && ThrowAfterFirstOutput) + throw new InvalidOperationException("retrying nested scene"); + } + } + + protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) + => new(16, 16); + + protected override void OnDraw(GraphicsContext2D context, Drawable.Resource resource) + { + } +} + +internal sealed class TrackingSceneRenderNode : RenderNode +{ + public bool ThrowOnDispose { get; set; } + + public int DisposeCalls { get; private set; } + + public override void Process(RenderNodeContext context) + { + context.PassThrough(); + } + + protected override void OnDispose(bool disposing) + { + DisposeCalls++; + if (ThrowOnDispose) + throw new InvalidOperationException("tracking scene-node disposal"); + } } diff --git a/tests/Beutl.UnitTests/TestCategories.cs b/tests/Beutl.UnitTests/TestCategories.cs new file mode 100644 index 0000000000..9ecbef0d20 --- /dev/null +++ b/tests/Beutl.UnitTests/TestCategories.cs @@ -0,0 +1,28 @@ +namespace Beutl.UnitTests; + +/// Category names shared across the suite. +internal static class TestCategories +{ + /// + /// Exercises a render target whose Vulkan image is driven by both Skia and the backend, which the two + /// track independently. + /// + /// + /// + /// RenderTarget builds its SKSurface once from a GRVkImageInfo and keeps it for the + /// target's life. Skia tracks that image's layout from there on, while VulkanTexture2D tracks the + /// same image separately as the backend transitions it for its own passes, sampling and readbacks. + /// Neither side is told what the other did, so the two records drift apart and a barrier ends up naming + /// an oldLayout the image is no longer in. Vulkan leaves that undefined; the validation gate + /// reports it as UNASSIGNED-CoreValidation-DrawState-InvalidImageLayout. + /// + /// + /// This is a pre-existing defect in the Skia interop, not in what these tests assert, and closing it + /// needs a way to read back or command the layout Skia holds — which SkiaSharp 3.119 does not expose. + /// Until then the validation job skips this category so the gate still covers everything else; the tests + /// themselves run normally in the ordinary suite. Tracked as b-editor/beutl#2263, which is also where + /// the condition for deleting this category is recorded. + /// + /// + public const string KnownVulkanSkiaLayoutInterop = "KnownVulkanSkiaLayoutInterop"; +} diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 76c42155d7..e564caeb2e 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -1,4 +1,4 @@ -# tests/ — local context +# tests/ — local context Most projects under `tests/` are NUnit (+ Moq where needed); the exceptions are the two BenchmarkDotNet projects (`Beutl.Benchmarks`, `Beutl.FFmpegBenchmarks`). Use this index when picking the right project for a new test. @@ -25,6 +25,37 @@ Most projects under `tests/` are NUnit (+ Moq where needed); the exceptions are The interactive Avalonia previewers / sample apps no longer live here. The sample extension package `PackageSample` was moved out of `tests/` (and out of `Beutl.slnx`, so CI does not build it) and now lives under `samples/`. Running it launches a window; it is not a test harness. +## Vulkan validation gate + +Vulkan validation is off by default and enabled with `BEUTL_VULKAN_VALIDATION=1`, which requires +`VK_LAYER_KHRONOS_validation` to be installed. When it is on, `VulkanTestEnvironment.InvokeOnRenderThread` +and its `GpuTestEnvironment` twin read `VulkanValidationErrorLog.Shared` before and after every +render-thread invocation and fail the test that reported an error, so API misuse the driver is not required +to diagnose — a nested render pass instance, a handle from another device — cannot pass as green. + +CI runs the GPU-backed tests a second time with the layer installed (`GPU tests under Vulkan validation` in +`.github/workflows/dotnet.yml`). Locally: + +```bash +BEUTL_REQUIRE_GPU=1 BEUTL_VULKAN_VALIDATION=1 \ + dotnet test tests/Beutl.Graphics3DTests/Beutl.Graphics3DTests.csproj -f net10.0 +``` + +`VulkanValidationGateTests.WhenTheJobAsksForValidation_TheInstanceEnabledIt` fails when the variable is set +but the layer did not load, because a gate that observes nothing must not report success. Without the +Vulkan SDK it will fail for that reason — install the layer before enabling the variable. + +One category is held back: `TestCategories.KnownVulkanSkiaLayoutInterop`, declared in both +`Beutl.UnitTests` and `Beutl.Graphics3DTests` under the same name because the validation job filters both +assemblies on it. `RenderTarget` builds its +`SKSurface` once and Skia tracks that image's layout from there, while `VulkanTexture2D` tracks the same +image separately as the backend transitions it — so the two records drift and a barrier eventually names an +`oldLayout` the image has left. Closing that needs a way to read back or command the layout Skia holds, +which SkiaSharp 3.119 does not expose. Those tests run normally in the ordinary suite; only the +validation job skips them, so the gate still covers everything else. Tracked as +[#2263](https://github.com/b-editor/beutl/issues/2263); drop the exclusion from +`.github/workflows/dotnet.yml` once the interop keeps one record. + ## Headless E2E tests The end-to-end suites are built on `Avalonia.Headless.NUnit` (they run on headless CI without xvfb or a GPU). Shared helpers live in the non-test library `tests/Beutl.Testing.Headless/` (`BeutlHomeIsolation`, `HeadlessTestHelpers`). diff --git a/tests/SourceGeneratorTest/Class1.cs b/tests/SourceGeneratorTest/Class1.cs index c7c2ddf7b2..984e04cb6d 100644 --- a/tests/SourceGeneratorTest/Class1.cs +++ b/tests/SourceGeneratorTest/Class1.cs @@ -7,7 +7,7 @@ public partial class Derived : EngineObject { public IProperty X { get; } = Property.Create(0f); - public IProperty Y { get; } = Property.Create(0f); + public IProperty Y { get; } = Property.Create(17f); } public partial class Derived2 : Derived @@ -21,7 +21,9 @@ public partial class Derived3 : Derived public List Children { get; } = []; // EngineObjectの派生型に対して - public IProperty Child { get; } = Property.Create(null!); + public IProperty Child { get; } = Property.Create(new Derived()); + + public IProperty OptionalChild { get; } = Property.Create(null); // IListProperty over an EngineObject element exercises the generated list path // (CompareAndUpdateList + per-item disposal). diff --git a/tests/SourceGeneratorTest/EngineObject.cs b/tests/SourceGeneratorTest/EngineObject.cs index 0a66d03718..cfda43cb8c 100644 --- a/tests/SourceGeneratorTest/EngineObject.cs +++ b/tests/SourceGeneratorTest/EngineObject.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Beutl.Composition; @@ -26,11 +26,11 @@ public virtual Resource ToResource(CompositionContext context) public class Resource : IDisposable { - private EngineObject _original = null!; + private EngineObject? _original; public int Version { get; protected set; } - public EngineObject GetOriginal() => _original; + public EngineObject? GetOriginal() => _original; public virtual void Update(EngineObject obj, CompositionContext context, ref bool updateOnly) { @@ -104,13 +104,14 @@ protected void CompareAndUpdateList(CompositionContext context field.RemoveAt(field.Count - 1); } } - protected void CompareAndUpdateObject(CompositionContext context, IProperty prop, ref TResource field, ref bool updateOnly) where TObject : EngineObject where TResource : Resource + protected void CompareAndUpdateObject(CompositionContext context, IProperty prop, ref TResource? field, ref bool updateOnly) where TObject : EngineObject? where TResource : Resource { var value = context.Get(prop); if (value is null) { if (field is not null) { + field.Dispose(); field = null; if (!updateOnly) { @@ -134,14 +135,17 @@ protected void CompareAndUpdateObject(CompositionContext con { if (field.GetOriginal() != value) { + var oldField = field; field = (TResource)value.ToResource(context); Version++; updateOnly = true; + oldField.Dispose(); } else { - var oldVersion = value.Version; - field.Update(value, context, ref updateOnly); + var oldVersion = field.Version; + var _ = false; + field.Update(value, context, ref _); if (!updateOnly && oldVersion != field.Version) { Version++; diff --git a/tests/SourceGeneratorTest/EngineObjectResourceGeneratorTests.cs b/tests/SourceGeneratorTest/EngineObjectResourceGeneratorTests.cs index 1697f985d8..7e0781b2ec 100644 --- a/tests/SourceGeneratorTest/EngineObjectResourceGeneratorTests.cs +++ b/tests/SourceGeneratorTest/EngineObjectResourceGeneratorTests.cs @@ -107,6 +107,15 @@ public void Derived3_GeneratesObjectPropertyForEngineObjectTypedProperty() // surfaced as a Derived.Resource and compared via CompareAndUpdateObject. Assert.That(source, Does.Contain("Child")); Assert.That(source, Does.Contain("CompareAndUpdateObject(context")); + Assert.That(source, Does.Contain("set => _child = value;")); + Assert.That(source, Does.Contain("set => _optionalChild = value;")); + Assert.That(source, Does.Not.Contain("SetOwnedResource")); + Assert.That(source, Does.Not.Contain("ReplaceChild(")); + Assert.That(source, Does.Not.Contain("DetachChild()")); + Assert.That(source, Does.Contain( + "get => _child ?? throw new global::System.InvalidOperationException")); + Assert.That(source, Does.Not.Contain("DetachOptionalChild()")); + Assert.That(source, Does.Not.Contain("ReplaceOptionalChild(")); // The disposable object property is released by its backing field in Dispose. Assert.That(source, Does.Contain("_child?.Dispose();")); });