[Quill] Clamp brush texture sampling to an opt-in UV window to stop atlas bleed from the AA fringe - #13
Merged
Conversation
…tlas bleed from the AA fringe
Contributor
|
Huh, this is not the solution i expected xD I guess it works, not a huge fan, of it being shader-sided i was thinking of like just padding the Texture Brush or something. |
Acissathar
deleted the
Clamp-brush-texture-sampling-to-an-opt-in-UV-window-to-stop-atlas-bleed-from-the-AA-fringe
branch
August 26, 2026 17:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
As mentioned in Discord, there is an issue with Paper using the SetTextureBrush to render a part of a spritesheet that causes the AA to bleed over some pixels, regardless of filtering on the sprite / clamping. Tested locally and can verify it fixes the issue, but the logic itself was largely handled by Claude because this is out of my knowledge space.
Problem
Textured shape draws bleed neighbouring atlas content along their edges whenever the brush maps a
sub-rect (UV window) of a texture onto a shape:
RectFilled(and shape AA in general) emits its AA fringe half a physical pixel outside thenominal rect (
Canvas.cs, outer ring atx - hp/x + width + hp). The overshoot itself iscorrect: the coverage ramp is centred on the edge, so a pixel centred on the boundary gets ~0.5
coverage and abutting shapes join without seams.
sample (
Canvas.slang:float4 fill = color * texture0.Sample(applyTransform(..., input.fragPos))).sampler can't help: ClampToEdge acts at the texture border, not at
u1.DrawImagehas the same overshoot, but its window is[0,1], so ClampToEdge happens to hide it —and on Repeat samplers the fringe wraps and shows the opposite edge instead.
A transparent gutter in the atlas is not a substitute fix: the fringe is half a screen pixel,
which spans more than one texel whenever a sprite draws below native size.
Fix
An opt-in UV clamp window on the brush, applied in the shader before the sample:
Brush.TextureClamp—(uMin, vMin, uMax, vMax), whereuMax <= uMinmeans unclamped. Thedefault is unclamped, so existing draws and tiling brushes are untouched. Deliberately not a
(0,0,1,1)default: that would break Repeat wrapping and depend on every construction siteremembering to set it.
Canvas.SetBrushTextureClamp(uMin, vMin, uMax, vMax)stores the window inset half a texeltoward its centre. The inset is load-bearing: the window boundary is the texel edge shared with
the neighbouring cell, where bilinear blends 50/50 with the neighbour and nearest floors into it —
clamping to the raw window still bleeds. Half a texel in, both filters resolve to the window's own
edge texel. The inset needs the texture size, so call this after
SetBrushTexture. Windowsnarrower than a texel collapse to their centre rather than inverting.
Canvas.slangclamps the transformed UV to the window before sampling when a window is active —the behaviour ClampToEdge gives at a real texture border, produced at a sub-rect border.
TextureClampparticipates inBrush.Matches/ComputeHash, so adjacent sub-rect draws from oneatlas can't merge into a single batch sharing the wrong clamp.
DrawImagenow self-clamps to (an inset)[0,1]— it knows its own window — which fixes theRepeat-sampler fringe wrap with no API change.
Why opt-in rather than automatic: the brush transform is a mapping, not an extent — nothing in
brush state says the paint ends at
u1, and for tiling brushes it deliberately doesn't. Derivingthe window per-draw from the shape's bounds is only correct while the brush is axis-aligned with
the shape, and would silently bleed again under rotation. (This is the same trade-off Skia exposes
as
SrcRectConstraintondrawImageRect.)Notes: no
ICanvasRendererchange — the inset uses the existingGetTextureSize. Renderers opt inby uploading
DrawCall.TextureClampas thetextureClampuniform; one that doesn't keeps exactlythe current behaviour (zero = unclamped). Backend shaders regenerate from
Canvas.slangvia theexisting ShaderGen step.