refactor(engine)!: GPU pass fusion — record-then-plan pipeline with fused shader passes - #2221
refactor(engine)!: GPU pass fusion — record-then-plan pipeline with fused shader passes#2221yuto-trd wants to merge 125 commits into
Conversation
Add the 004 specification, plan, research, data model, task list, and the public-API, render-request, and breaking-change contracts. The paired baseline/feature evidence tree the plan originally called for is not carried in the repository: it was 61.5 MB across 267 files, and its acceptance role is served by the in-repo golden, fusion, and failure suites that compare fusion-disabled against fusion-enabled rendering in the same process and device.
…passes Render nodes now record declarative fragments in one pass and a planner compiles them into a fused execution plan, instead of each node pulling and rasterizing its own children. Built-in current-pixel and whole-source shader effects are authored as shader definitions and calls, so adjacent stages merge into a single GPU program. Resource authoring moves to definition/call objects with typed RenderResourceSlot bindings: structural identity comes from the callback's own identity, and per-recording values travel through call state. Drawable brushes are materialized by the executor rather than lowered ahead of execution. The editor's engine-resource subscription now creates, updates, and disposes resources on the render dispatcher, and disposing a subscription cancels work that has not started, so a resource is never created for a subscription that is already gone. The drawable-brush thumbnail handler gained an explicit lifetime: it rasterizes at the drawable's own bounds, rejects superseded or rolled-back publications, and has a single resource owner. BREAKING CHANGE: Beutl.Engine replaces legacy render descriptions, runtime identities, manual cache controls, and named resource bindings with definition/call objects and RenderResourceSlot bindings. RenderNodeOperation, OperationWrapperRenderNode, and RenderNodeProcessor are removed, along with the interim FilterEffectBrush, FilterEffectPen, LoweredBrush, LoweredPen, LoweredPaint, PaintedRenderCanvas, and PaintedRenderSession surfaces; custom filter effects use Brush.Resource and Pen.Resource values. Render diagnostics and the allocation preflight are removed. Beutl.NodeGraph ConfigureNode consumers now hold non-owning input references.
Add golden, fusion, planning, recording, cache, and failure-matrix suites for the recorded-then-planned pipeline, plus public-API contract tests for the definition/call surface and benchmarks for complete-request workloads. Coverage of mechanisms this change removes is dropped with them: runtime and cache identity, cache-output verification, the deep state-validation machinery, name-based resource addressing, and lowered brush and paint authoring.
|
Too many files changed for review (570 files, 300 file limit). |
|
Important Review skippedToo many files! This PR contains 570 files, which is 420 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (570)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
A grouped drawable records a full-target layer scope, which the fragment handle cannot resolve without a domain, so the thumbnail render threw RenderTargetDomainRequiredException and no bitmap was ever published.
The materializer reported the brush's destination box as the source size, so TileBrushCalculator computed a stretch of 1 for any drawable whose intrinsic bounds differed from the destination: a 40px drawable filling a 160px shape covered 42px instead of 160px. The materializer now crops its snapshot to the compiled output bounds and reports them, matching the pre-fusion behaviour. Also restores the volatile _disposed flags and the cross-thread disposal tests in the IPC providers, which the branch had reverted against main.
Cropping through a subset surface snapshot allocated a backend image on every drawable-brush fill. Taking the whole snapshot and cropping the raster copy keeps the device interaction the same as before the content-bounds fix.
Once bounds turn symbolic, AddItem and AppendDescription route operations to _renderTimeItems, which the public CountItems ignored. An out-of-tree effect that branched on the count saw an undercount for work that still executes.
…ender thread Resource creation and update run in a posted render-thread callback, and the render thread installs no unhandled-exception handler, so a throwing factory unwound its loop and stopped every later render. Failures now release any partially created resource and reach the subscriber through OnError.
…t-out BrushConstructor's public constructor could not supply a materializer, so a DrawableBrush painted through the public path degraded to transparent where it used to rasterize itself. The materializer delegate and its result are public now, and the internal constructor overload folds into the primary one. RenderNodeContext.DisableRenderCache becomes public: a node that records a child it does not list in ChildNodes has to keep itself out of the cache, and the cache cannot see a change reported only by that unlisted child. IRenderTargetFactory.GetMaximumDimension is removed. Nothing queried it, so a factory advertising a smaller limit was ignored either way; the buffer bound stays RenderScaleUtilities.MaxBufferDimension until a consumer exists. BREAKING CHANGE: Beutl.Engine — IRenderTargetFactory.GetMaximumDimension is removed; drop the implementation from custom factories. BrushConstructor gains a trailing optional DrawableBrushMaterializer parameter, so positional callers after `intent` must be updated.
The Legacy/Compatibility vocabulary read as "deprecated authoring API", which it never described. FilterEffect.ApplyTo is the sole abstract effect entry point and the spec keeps it (research.md R10, spec.md FR-018); what these types actually distinguish is the imperative fallback that a segment falls into once an unknown custom operation makes bounds symbolic, versus the recorded path that ordinary Shader/Geometry items still take. LegacyFilterEffectCompatibilityExecutor -> FilterEffectStageFallbackExecutor LegacyFilterEffectRenderFragmentPayload -> FilterEffectSegmentRenderFragmentPayload RenderFragmentKind.LegacyFilterEffect -> FilterEffectSegment CompatibilityRenderValue -> MaterializedRenderValue CompatibilityExecutionState -> RenderRequestExecutionState CompatibilityClassification -> ExecutionIslandClassification The two broad ones were the worst fit: RenderRequestExecutionState is built for every planned request and MaterializedRenderValue carries fused shader runs. LegacyCustomEffect, LegacyRawCanvas and LegacyFilterSamplingSupport keep their names — they do describe retained imperative boundaries. A segment also collects Skia items and typed suffixes, so the planner no longer reports LegacyCustomEffect as the boundary reason for segments that hold no custom item; those get the new FilterEffectSegment reason.
Four defects introduced by this PR's own review round: Teardown released the resource from a posted render-thread callback without catching, so a throwing Dispose unwound the loop the earlier fix had just protected. The release now logs and swallows. The trigger subscription installed only an onNext handler, so a faulting time stream hit Rx's rethrowing default handler while this subscription stayed uninformed and kept its resource. It now cancels, releases, and reports through OnError. Thumbnail shutdown counted queued and running updates together, so a shutdown disposed the resource under an update that was still recording from it. The two are counted separately now: shutdown writes off work that never starts and waits for work already in flight. A Stretch listener that disposed the handler left rollback restoring the previous thumbnail that Dispose had just cleared, leaking it with no owner. Rollback now keeps the source cleared and releases the previous bitmap when disposal won.
The scale table still named RenderNodeOperation.EffectiveScale, which this feature removed; effective scale now lives on the recorded fragment. The filter-effect skill taught CustomEffect for per-pixel SKSL without saying that the form is opaque to the planner, so authors following it lost fusion without knowing. Both now name the current surface.
…d on The logical device never asked for imageCubeArray, while the shadow path creates VK_IMAGE_VIEW_TYPE_CUBE_ARRAY views and its lighting shader declares the SampledCubeArray capability. Using a feature without requesting it is undefined behaviour the driver need not report, which is why it went unnoticed until the Vulkan validation gate reported 26 VUID-VkImageViewCreateInfo-viewType-01004 and 13 VUID-VkShaderModuleCreateInfo-pCode-08740 errors across 13 of the Graphics3D tests. The device now enables it where the hardware advertises it, alongside the two 64-bit shader features, and a cube texture array on a device without it is rejected by name instead of failing inside vkCreateImageView, whose message mentions neither the texture nor the missing feature.
…g fills it The lighting pass binds a shadow atlas whole, but only the slots the lights present actually rendered into were transitioned; the rest sat in VK_IMAGE_LAYOUT_UNDEFINED and were handed to the sampler anyway. Reading an image in a layout that does not permit it is undefined behaviour the driver need not report, which is why it went unnoticed until the Vulkan validation gate reported 375 InvalidImageLayout errors across the Graphics3D suite - every one of them an unfilled slot. An array, cube-array or cube texture now leaves its constructor with every slot in ShaderReadOnlyOptimal, so an allocated slot is readable from the moment it exists whether or not anything ever writes to it. Rendering into one still transitions it and back. ShadowManager carried a note saying a sweep over the whole array would be harmful because it would transition from Undefined and discard data. It would not: each transition reads the slot's tracked layout and skips a slot already in the target. The note now says what is actually true, which is that the sweep is unnecessary rather than dangerous.
The array, cube-array and cube texture constructors defaulted usage to a depth attachment, so building one directly with a colour format produced an image whose view the format cannot back. Nothing in production hit it - the context factories pick usage from the format - but the validation gate caught a test of mine that constructed a texture directly and asked for a depth attachment over RGBA8, reported as VUID-VkImageViewCreateInfo-usage-02277 and VUID-VkImageCreateInfo-imageCreateMaxMipLevels-02251. usage is now required, which is the same treatment the render hosts got: a default that is silently wrong for a whole class of inputs is worse than no default. The three factories are the only callers. The tests now allocate through the context, which is the supported path. Also holds Scene3D_ReusesItsRendererAcrossRequests out of the validation job under the existing KnownVulkanSkiaLayoutInterop category - it is the same Skia/backend layout divergence, reaching the 3D suite through Renderer3D's Skia surface.
…fails The initial layout transition added to the array, cube and cube-array constructors sits after every native handle has been created, and recording it can fail - allocating or beginning a command buffer. The constructors clean up after a failed image view but not after this, and none of these types has a finalizer, so a caller that retries after a recoverable recording failure leaked the image, its memory, the aggregate view and every per-layer view each time. The transition now releases the same handles the view-creation failure path does before rethrowing. No test: the failure needs a command-buffer allocation to fail inside the constructor, which the suite has no way to induce. The cleanup mirrors, line for line, the path a few lines above it that the same constructor already runs for a failed image view.
RenderTarget.ClearToTransparent hands the clear to Skia and records it on the wrapper, but HasTransparentContents prefers the backend's record when the texture keeps one - and BeginDraw had just set that to false, because Skia was about to draw. A pooled target the pool had already cleared therefore reported unknown contents, and the next caller that wanted a blank one cleared and flushed the same image again. Every reused intermediate on a materialization-heavy frame paid for that. The clear now tells the backend what it did, so the two records agree.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 45244fe199
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| or TargetCommandSession | ||
| or RawTargetScopeSession | ||
| or RawTargetCommandSession; | ||
| bool mutablePayload = key is Array || IsKnownMutableCollection(key.GetType()); |
There was a problem hiding this comment.
Reject mutable custom metadata captures
When an external author captures a custom mutable reference such as a BoundsBox in a bounds, scale, or hit-test callback, ValidatePureMetadataCallback reaches this check but accepts it because only arrays and a fixed list of collection types count as mutable. The callback can then change between the repeated metadata evaluations documented by these contracts while its structural identity remains the same MethodInfo, producing inconsistent ROI, bounds, or target-allocation decisions instead of the promised synchronous rejection; reject unknown reference captures or recursively validate their instance fields.
AGENTS.md reference: AGENTS.md:L60-L60
Useful? React with 👍 / 👎.
"Save selected element as image" rendered against the scene frame as the request's target domain, and a target domain is a hard output clip: an element hanging over the edge exported cropped, and one entirely outside the frame measured empty and threw "the selected drawable produced no raster output". The pull model this replaced measured the drawable's own bounds. The export now measures without a domain first and takes the union of the frame and what the drawable actually occupies, so the frame still owns a fragment that resolves its region from the target while nothing is clipped away. A subtree that genuinely needs an owning domain to measure at all says so by throwing, and keeps the frame it had.
Demand planning consulted a target scope's scale contract only when the scope was the engine's internal value-replay map. A public TargetScopeDefinition that enlarges its input while replaying it therefore declared the higher input density its contract promises and was handed the unchanged output demand anyway, so an unbounded source materialized at 1x and was then stretched. Both demand paths now ask the contract. A contract that declares no backward map maps demand to itself, so this changes nothing for the scopes that were already right.
Geometry is a materialization boundary, so an operation that draws its input through an enlarging transform needs that input denser than its own consumer asked for. There was no way to say so: demand planning had no case for geometry fragments, so they fell through forwarding the output demand unchanged, and an unbounded source was rasterized at 1x and then stretched. Whole-source shaders and opaque combines already had the remedy; geometry did not. GeometryDefinition<TState>.Create accepts a RenderInputDemandContract, and planning applies it. The default leaves demand unchanged, so an operation that draws its input at its own density is unaffected.
…s needs A target command draws its inputs onto the target, and one 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. Replay-demand planning materialized every input at the unchanged target density, so an unbounded or vector source drawn for a 1x target was rasterized at 1x and then enlarged, with no way to say otherwise. TargetCommandDefinition<TState>.Create accepts a RenderInputDemandContract, resolved per input index, and planning applies it. The default leaves demand unchanged.
…ion plans Applying a target scope's backward demand map to every scope, not only the engine's value-replay map, was wrong. An ordinary scope replays its input onto the target, and one whose transform is appended to the destination matrix - TransformOperator.Append - has that scale carried by the destination already, so pre-scaling the input rasterizes it four times too large and then draws it scaled again. AppendTransform_LayerMaterializesAtPlannedDestinationDensity says exactly that, and it is right. The gate is restored, and its reason is written down where the next reader will meet it. The gap the review named - a public target scope has no way to declare an enlarging replay, because TargetScopeDefinition always records isValueReplayMap: false - is real, but closing it means exposing that distinction, not removing it. Also stops the target-command demand lookup assuming a payload: the demand resolver is exercised with synthetic payload-less fragments, and a command without one simply declares no mapping.
…s on Reclaiming deferred targets flushes the shared context and reported that as "a context-wide flush happened", which PrepareForSampling takes as permission to skip its own surface flush. A target from a caller-supplied IRenderTargetFactory can live on another context, and its work was then never submitted before the snapshot, readback or backend sample that follows - stale pixels, from a queue drain that had nothing to do with it. FlushAndDrain now takes the context the caller is about to sample and says true only when the flush covered that one. The queue is drained either way; a caller told false submits its own surface as before.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 749c944225
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case RenderFragmentKind.Opacity: | ||
| case RenderFragmentKind.Blend: | ||
| case RenderFragmentKind.TargetLayerScope: | ||
| case RenderFragmentKind.RawTargetScope: |
There was a problem hiding this comment.
Apply backward demand mapping to raw target scopes
When an out-of-tree RawTargetScopeDefinition pushes an enlarging canvas transform before ReplayInput() and supplies a bidirectional RenderScaleContract, this branch forwards the target demand unchanged instead of consulting RawTargetScopeDescription.Scale. An unbounded/vector child behind a materialization boundary is therefore rasterized at the target density and then enlarged, producing blurred output despite the public contract declaring the higher input demand; resolve the raw scope's backward scale mapping here rather than grouping it with identity-demand scopes.
AGENTS.md reference: AGENTS.md:L59-L60
Useful? React with 👍 / 👎.
Minimum allowed line rate is |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd69026a9d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Rect bounds = context.CalculateRecordedInputBoundsHint(); | ||
| context.Publish(context.TargetLayerScope(context.Inputs, TargetRegion.Region(bounds))); |
There was a problem hiding this comment.
Preserve full-target work when isolating groups
When an out-of-tree drawable records only GraphicsContext2D.Clear(Color) inside a DrawableGroup whose opacity is below 100% (or whose children require blend isolation), the clear fragment has an empty recorded bounds hint even though it writes the full target. This constructs an empty TargetLayerScope, which ReplayTargetLayerScope skips entirely, so the group no longer produces the full-frame color that the previous save-layer path composited. Use a full/owning target layer when target-write metadata is symbolic rather than deriving the isolation region solely from recorded value bounds.
AGENTS.md reference: AGENTS.md:L60-L60
Useful? React with 👍 / 👎.
A metadata callback is evaluated repeatedly and its structural identity is only its MethodInfo, so a capture that changes between evaluations makes one identity stand for different bounds, scales or hit tests. Only arrays and a fixed list of collection types counted as mutable, so an ordinary class with a settable field - the shape anyone reaches for when they want to hand a callback a value - passed. The test is structural rather than another list: a captured reference type is accepted only when every instance field is readonly and holds something that passes in turn, so an unfamiliar shell whose fields cannot be reassigned is still accepted. A struct is not asked to be readonly, since the callback reads whatever the display class holds either way, but what it points at is followed. Types whose contents cannot change - string, Guid, the immutable collections - are leaves, because following an ImmutableArray's backing array would read as a mutable array from the outside.
When a lambda is written inside another lambda over the same locals, Roslyn caches the inner delegate in the closure the two share. Capture validation read that field as a delegate the author had captured and refused the contract, so recording one from inside any other lambda - including from a test's own assertion lambda - failed for a field nobody wrote. The cache is recognised by pointing back at the closure being validated: whatever it reads is one of that closure's other fields, which validation checks in its own right. A delegate built elsewhere, over state this closure cannot show, still has a different target and is still refused.
|
No TODO comments were found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f12d4590f1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// The only addressing mode here. An unguarded external callback is never reusable, so it is recorded | ||
| /// through <c>CreateRequestLocal</c> only and may always capture the tokens it needs. | ||
| /// </remarks> | ||
| public void UseResource<T>(RenderResource<T> resource, Action<T> use) |
There was a problem hiding this comment.
Resolve raw resources through their declared slots
When an out-of-tree raw scope or command declares reusable resource slots and binds a different token on each call, these sessions cannot address that binding by slot; the callback must also carry the exact RenderResource<T> in TState, as DrawBackdropRenderNode now does. This makes the binding schema validation-only and creates two sources of truth that can drift into an execution-time undeclared-resource failure, unlike guarded sessions. Store the bindings and expose slot-backed UseResource on both raw sessions so definitions remain reusable without duplicating tokens in state.
AGENTS.md reference: AGENTS.md:L59-L60
Useful? React with 👍 / 👎.
| @@ -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); | |||
There was a problem hiding this comment.
Rebuild nested scenes for each request scale
When a SceneDrawable graph is recorded once and the same RenderNodeRenderer is later rasterized with a different per-call OutputScale, this captures only the original GraphicsContext2D scale. SceneBitmapRenderNode.Update is not called by subsequent renderer requests, so nested drawables whose Render method records scale-dependent nodes remain frozen at the first scale even though the executor rasterizes at the new one; the replaced implementation instead recreated its nested renderer from RenderNodeContext.OutputScale during each request. Make the nested graph scale-neutral or rebuild its scale-dependent children from the active request scale.
Useful? React with 👍 / 👎.
Description
Replaces the five-PR stack (#2166–#2171) with one branch.
Render nodes record declarative fragments in a single pass; a planner compiles them into a fused
execution plan. Previously each node pulled and rasterized its own children. Adjacent shader stages now
merge into one GPU program.
RenderResourceSlots; structural identity comes from the callback itself.SKSLShaderbuilders.Affected areas
Beutl.Engine(rendering / scene / track)Beutl.Editor,Beutl.Editor.Components,Beutl.Controls)Beutl.NodeGraph(node editor)Beutl.ProjectSystem(project / document persistence)Breaking changes
Sixteen commits carry
!. Migration recipes →contracts/breaking-changes.mdRenderNodeOperation,OperationWrapperRenderNode,RenderNodeProcessor, render diagnostics, the allocation preflight, the interim lowered brush/paint surfacesRect.TransformToClippedAABB→TransformToAABBRenderScaleContract.MapInputSupply(map)→MapInputSupplyPreservingDemand(map)WholeSourcecoordinates · zero-radiusDilate/Erode· six filters gainingSupportsDirectReplay· pending Skia colour filters · custom-effect device grids ·ChromaKeymatchingVerification
UnitTests6,953,AgentToolkit545,HeadlessUITests271,PublicApiContractTests128, + 7 smaller suitesEvery failure on Linux and Windows reproduces at
b816714d0or lives in files this branch does not touch.dotnet build Beutl.slnxanddotnet format --verify-no-changesare clean.Output parity — the same-process fusion A/B in
GpuPassFusionSameProcessParityHarness, plus anout-of-tree differential harness (~5,700 scenes, 94,862 shots per side) run against target-main on all
three backends. Remaining differences are intended, precision-level, or cases where target-main is wrong.
Performance — per SC-008, measurable on demand and not asserted as a met acceptance criterion.
Fixed issues / References
Supersedes #2166, #2167, #2168, #2169, #2171.
BREAKING CHANGE: Beutl.Engine replaces the executable render-node pull model with recorded
fragments and planned execution.
RenderNodeOperation,OperationWrapperRenderNode,RenderNodeProcessor,render diagnostics, the allocation preflight, and the interim
FilterEffectBrush,FilterEffectPen,LoweredBrush,LoweredPen,LoweredPaint,PaintedRenderCanvasandPaintedRenderSession. UseBrush.ResourceandPen.Resource.Rect.TransformToClippedAABB->Rect.TransformToAABB;RenderScaleContract.MapInputSupply(map)->MapInputSupplyPreservingDemand(map).OpaqueRenderBoundsContract.SourceandRenderNodeContext.PaintedSourcetake an optional
Thickness rasterOutset- logical room that widens only the buffer a source drawsinto, never what it publishes. Existing calls compile unchanged; both signatures moved.
ITexture2DgainsRequiresSkiaFlushForBackendInterop,PrepareForSkiaRendering()andPrepareForSkiaSampling(bool), none with a default implementation;IGraphicsContext.CreateRenderPass3DmakesdepthFormata requiredTextureFormat?(null is acolour-only pass);
CreateFramebuffer3DandIFramebuffer3D.DepthTexturemake the depth attachmentnullable;
PipelineOptionsgainsSpecializationConstants.RenderNode.Processreturns void and publishes transaction-scoped fragment handles;callback authoring uses immutable
*Definition<TState>objects with.Call(state, bindings);RenderNode.HasChangesis the only public content-invalidation signal;ConfigureNodeconsumers hold non-owning input references.
ShaderDescription.WholeSourcecoordinates; zero-radiusDilate/Erode;the six built-in Skia filters that gained
SupportsDirectReplay; pending Skia colour filters;custom-effect device grids;
ChromaKeycolour matching; aTextRenderNodefragment publishesFormattedText.ActualBoundsinstead of its density-dependent raster bounds, so text stops movingbetween preview scales and export; a particle covers the bounding box of its rotated source instead
of a square of the source's longer side, and is resampled with Mitchell instead of point-sampled.
Beutl.NodeGraph, Beutl.ProjectSystem, Beutl.AgentToolkit. In-tree consumers are migrated.
removed surface.