Skip to content

Commit 787de2f

Browse files
Fix text culling and glyph subpixel batching
Adds translation-only matrix detection and uses it to derive visible text bounds from canvas state, enabling safe line culling for text while correctly disabling the fast path for non-translation transforms. Text composition commands now carry a dedicated sub-pixel offset (instead of embedding it into per-glyph drawing options transforms), and backend prep paths apply that offset consistently. The batcher also avoids rebuilding scene commands when brush transforms are effectively no-ops. New text tests cover offscreen culling, translated and rotated transforms, and text-block rendering parity against unculled output.
1 parent 4a88695 commit 787de2f

7 files changed

Lines changed: 467 additions & 65 deletions

File tree

src/ImageSharp.Drawing/Helpers/MatrixUtilities.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ public static float GetAverageScale(in Matrix4x4 matrix)
4343
public static bool IsAffine(in Matrix4x4 matrix)
4444
=> matrix.M14 == 0F && matrix.M24 == 0F && matrix.M34 == 0F && matrix.M44 == 1F;
4545

46+
/// <summary>
47+
/// Returns a value indicating whether the matrix is a pure 2D translation: an identity
48+
/// linear part with no perspective terms, so geometry moves by the M41 and M42 offsets
49+
/// without any scale, rotation, or skew.
50+
/// </summary>
51+
/// <param name="matrix">The transformation matrix.</param>
52+
/// <returns><see langword="true"/> when the matrix only translates; otherwise, <see langword="false"/>.</returns>
53+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
54+
public static bool IsTranslationOnly(in Matrix4x4 matrix)
55+
=> matrix.M11 == 1F && matrix.M12 == 0F && matrix.M13 == 0F && matrix.M14 == 0F &&
56+
matrix.M21 == 0F && matrix.M22 == 1F && matrix.M23 == 0F && matrix.M24 == 0F &&
57+
matrix.M31 == 0F && matrix.M32 == 0F && matrix.M33 == 1F && matrix.M34 == 0F &&
58+
matrix.M44 == 1F;
59+
4660
/// <summary>
4761
/// Returns a value indicating whether the matrix maps axis-aligned rectangles to axis-aligned rectangles.
4862
/// </summary>

src/ImageSharp.Drawing/Processing/Backends/CompositionCommand.cs

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public readonly struct CompositionCommand
7171
/// <param name="clipDescriptor">The clip descriptor, or <see langword="null"/> for non begin-clip commands.</param>
7272
/// <param name="isInsideLayer">True if the command was recorded inside a layer.</param>
7373
/// <param name="applyBarrier">The apply barrier, or <see langword="null"/> for non-apply commands.</param>
74+
/// <param name="subPixelOffset">The fractional translation composed into <see cref="Transform"/>.</param>
7475
private CompositionCommand(
7576
CompositionCommandKind kind,
7677
IPath? sourcePath,
@@ -83,7 +84,8 @@ private CompositionCommand(
8384
Point destinationOffset,
8485
DrawingClipDescriptor? clipDescriptor,
8586
bool isInsideLayer,
86-
ApplyBarrier? applyBarrier)
87+
ApplyBarrier? applyBarrier,
88+
Vector2 subPixelOffset)
8789
{
8890
this.Kind = kind;
8991
this.sourcePath = sourcePath;
@@ -97,6 +99,7 @@ private CompositionCommand(
9799
this.clipDescriptor = clipDescriptor;
98100
this.IsInsideLayer = isInsideLayer;
99101
this.applyBarrier = applyBarrier;
102+
this.SubPixelOffset = subPixelOffset;
100103
}
101104

102105
/// <summary>
@@ -152,9 +155,31 @@ private CompositionCommand(
152155
public IPath SourcePath => this.sourcePath ?? throw new InvalidOperationException("Layer commands do not carry path geometry.");
153156

154157
/// <summary>
155-
/// Gets the command transform.
158+
/// Gets the fractional translation applied after the drawing options transform. Glyph
159+
/// geometry rides an integer destination offset; the sub-pixel remainder travels here so
160+
/// the queued command does not need per-operation drawing options to carry it.
156161
/// </summary>
157-
public Matrix4x4 Transform => this.drawingOptions?.Transform ?? Matrix4x4.Identity;
162+
public Vector2 SubPixelOffset { get; }
163+
164+
/// <summary>
165+
/// Gets the command transform: the drawing options transform followed by the sub-pixel
166+
/// translation.
167+
/// </summary>
168+
public Matrix4x4 Transform
169+
{
170+
get
171+
{
172+
Matrix4x4 transform = this.drawingOptions?.Transform ?? Matrix4x4.Identity;
173+
if (this.SubPixelOffset == Vector2.Zero)
174+
{
175+
return transform;
176+
}
177+
178+
return transform.IsIdentity
179+
? Matrix4x4.CreateTranslation(this.SubPixelOffset.X, this.SubPixelOffset.Y, 0F)
180+
: transform * Matrix4x4.CreateTranslation(this.SubPixelOffset.X, this.SubPixelOffset.Y, 0F);
181+
}
182+
}
158183

159184
/// <summary>
160185
/// Gets the clip descriptor opened by a <see cref="CompositionCommandKind.BeginClip"/> command.
@@ -248,7 +273,8 @@ internal static CompositionCommand Create(
248273
destinationOffset,
249274
null,
250275
isInsideLayer,
251-
null);
276+
null,
277+
Vector2.Zero);
252278

253279
/// <summary>
254280
/// Creates a fill-path composition command with the owning layer state recorded by the canvas.
@@ -269,6 +295,38 @@ internal static CompositionCommand Create(
269295
Rectangle targetBounds,
270296
Point destinationOffset,
271297
DrawingCanvasLayer? layer)
298+
=> Create(
299+
path,
300+
brush,
301+
drawingOptions,
302+
in rasterizerOptions,
303+
targetBounds,
304+
destinationOffset,
305+
layer,
306+
Vector2.Zero);
307+
308+
/// <summary>
309+
/// Creates a fill-path composition command carrying a sub-pixel translation alongside the
310+
/// owning layer state recorded by the canvas.
311+
/// </summary>
312+
/// <param name="path">Path in target-local coordinates.</param>
313+
/// <param name="brush">Brush used during composition.</param>
314+
/// <param name="drawingOptions">Drawing options (graphics, shape, transform) used during composition.</param>
315+
/// <param name="rasterizerOptions">Rasterizer options used to generate coverage.</param>
316+
/// <param name="targetBounds">The absolute bounds of the logical target for this command.</param>
317+
/// <param name="destinationOffset">Absolute destination offset where coverage is composited.</param>
318+
/// <param name="layer">The layer that owned this command when it was recorded.</param>
319+
/// <param name="subPixelOffset">The fractional translation composed into <see cref="Transform"/>.</param>
320+
/// <returns>The composition command.</returns>
321+
internal static CompositionCommand Create(
322+
IPath path,
323+
Brush brush,
324+
DrawingOptions drawingOptions,
325+
in RasterizerOptions rasterizerOptions,
326+
Rectangle targetBounds,
327+
Point destinationOffset,
328+
DrawingCanvasLayer? layer,
329+
Vector2 subPixelOffset)
272330
=> new(
273331
CompositionCommandKind.FillLayer,
274332
path,
@@ -281,7 +339,8 @@ internal static CompositionCommand Create(
281339
destinationOffset,
282340
null,
283341
layer is not null,
284-
null);
342+
null,
343+
subPixelOffset);
285344

286345
/// <summary>
287346
/// Creates a begin-layer composition command with shared layer state.
@@ -304,7 +363,8 @@ internal static CompositionCommand CreateBeginLayer(
304363
default,
305364
null,
306365
false,
307-
null);
366+
null,
367+
Vector2.Zero);
308368

309369
/// <summary>
310370
/// Creates an end-layer composition command with shared layer state.
@@ -325,7 +385,8 @@ internal static CompositionCommand CreateEndLayer(Rectangle layerBounds, Drawing
325385
default,
326386
null,
327387
false,
328-
null);
388+
null,
389+
Vector2.Zero);
329390

330391
/// <summary>
331392
/// Creates a begin-clip composition command.
@@ -346,7 +407,8 @@ internal static CompositionCommand CreateBeginClip(DrawingClipDescriptor descrip
346407
destinationOffset,
347408
descriptor,
348409
false,
349-
null);
410+
null,
411+
Vector2.Zero);
350412

351413
/// <summary>
352414
/// Creates an end-clip composition command.
@@ -365,7 +427,8 @@ internal static CompositionCommand CreateEndClip()
365427
default,
366428
null,
367429
false,
368-
null);
430+
null,
431+
Vector2.Zero);
369432

370433
/// <summary>
371434
/// Creates an apply composition command.
@@ -422,7 +485,8 @@ private static CompositionCommand CreateApply(
422485
barrier.DestinationOffset,
423486
null,
424487
barrier.IsInsideLayer,
425-
barrier);
488+
barrier,
489+
Vector2.Zero);
426490
}
427491

428492
/// <summary>

src/ImageSharp.Drawing/Processing/Backends/FlushScene.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,7 @@ private static void ProcessPathCommand(
10061006
command.RasterizerOptions,
10071007
command.TargetBounds,
10081008
command.DestinationOffset,
1009+
command.SubPixelOffset,
10091010
allocator,
10101011
out PreparedFillItem preparedApply))
10111012
{
@@ -1364,6 +1365,7 @@ private static bool TryPrepareFillPath(
13641365
command.RasterizerOptions,
13651366
command.TargetBounds,
13661367
command.DestinationOffset,
1368+
command.SubPixelOffset,
13671369
allocator,
13681370
out prepared);
13691371

@@ -1378,6 +1380,7 @@ private static bool TryPrepareFillPath(
13781380
/// <param name="sourceRasterizerOptions">The rasterizer options recorded with the command.</param>
13791381
/// <param name="targetBounds">The destination bounds of the flush.</param>
13801382
/// <param name="destinationOffset">The offset from path-local to destination coordinates.</param>
1383+
/// <param name="subPixelOffset">The fractional translation applied after the drawing options transform.</param>
13811384
/// <param name="allocator">The allocator used for retained raster storage.</param>
13821385
/// <param name="prepared">The prepared fill payload when successful.</param>
13831386
/// <returns><see langword="true"/> when the fill intersects the target and rasterizable geometry was created; otherwise <see langword="false"/>.</returns>
@@ -1388,10 +1391,21 @@ internal static bool TryPrepareFillPath(
13881391
in RasterizerOptions sourceRasterizerOptions,
13891392
Rectangle targetBounds,
13901393
Point destinationOffset,
1394+
Vector2 subPixelOffset,
13911395
MemoryAllocator allocator,
13921396
out PreparedFillItem prepared)
13931397
{
13941398
Matrix4x4 transform = drawingOptions.Transform;
1399+
if (subPixelOffset != Vector2.Zero)
1400+
{
1401+
// The sub-pixel remainder is a device-space nudge applied after the recorded
1402+
// transform, mirroring CompositionCommand.Transform for callers that receive the
1403+
// options and offset separately.
1404+
transform = transform.IsIdentity
1405+
? Matrix4x4.CreateTranslation(subPixelOffset.X, subPixelOffset.Y, 0F)
1406+
: transform * Matrix4x4.CreateTranslation(subPixelOffset.X, subPixelOffset.Y, 0F);
1407+
}
1408+
13951409
Vector2 scale = MatrixUtilities.GetScale(transform);
13961410
Matrix4x4 residual = MatrixUtilities.GetResidual(scale, transform);
13971411
LinearGeometry geometry = path.ToLinearGeometry(scale);

src/ImageSharp.Drawing/Processing/Backends/StrokePathCommand.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,45 @@ internal StrokePathCommand(
7373
Pen pen,
7474
bool isInsideLayer,
7575
DrawingCanvasLayer? ownerLayer)
76+
: this(
77+
sourcePath,
78+
brush,
79+
drawingOptions,
80+
in rasterizerOptions,
81+
targetBounds,
82+
destinationOffset,
83+
pen,
84+
isInsideLayer,
85+
ownerLayer,
86+
Vector2.Zero)
87+
{
88+
}
89+
90+
/// <summary>
91+
/// Initializes a new instance of the <see cref="StrokePathCommand"/> struct carrying a
92+
/// sub-pixel translation alongside the owning layer state recorded by the canvas.
93+
/// </summary>
94+
/// <param name="sourcePath">The source stroke path.</param>
95+
/// <param name="brush">The brush used to shade the stroke.</param>
96+
/// <param name="drawingOptions">The drawing options (graphics, shape, transform) used during composition.</param>
97+
/// <param name="rasterizerOptions">The rasterizer options used to generate coverage.</param>
98+
/// <param name="targetBounds">The absolute bounds of the logical target.</param>
99+
/// <param name="destinationOffset">The absolute destination offset of the command.</param>
100+
/// <param name="pen">The stroke metadata.</param>
101+
/// <param name="isInsideLayer">True if the command was recorded inside a layer.</param>
102+
/// <param name="ownerLayer">The layer that owned this command when it was recorded.</param>
103+
/// <param name="subPixelOffset">The fractional translation composed into <see cref="Transform"/>.</param>
104+
internal StrokePathCommand(
105+
IPath sourcePath,
106+
Brush brush,
107+
DrawingOptions drawingOptions,
108+
in RasterizerOptions rasterizerOptions,
109+
Rectangle targetBounds,
110+
Point destinationOffset,
111+
Pen pen,
112+
bool isInsideLayer,
113+
DrawingCanvasLayer? ownerLayer,
114+
Vector2 subPixelOffset)
76115
{
77116
this.sourcePath = sourcePath;
78117
this.drawingOptions = drawingOptions;
@@ -83,6 +122,7 @@ internal StrokePathCommand(
83122
this.DestinationOffset = destinationOffset;
84123
this.Pen = pen;
85124
this.IsInsideLayer = isInsideLayer;
125+
this.SubPixelOffset = subPixelOffset;
86126
}
87127

88128
/// <summary>
@@ -126,9 +166,31 @@ internal StrokePathCommand(
126166
public IPath SourcePath => this.sourcePath;
127167

128168
/// <summary>
129-
/// Gets the drawing transform.
169+
/// Gets the fractional translation applied after the drawing options transform. Glyph
170+
/// geometry rides an integer destination offset; the sub-pixel remainder travels here so
171+
/// the queued command does not need per-operation drawing options to carry it.
130172
/// </summary>
131-
public Matrix4x4 Transform => this.drawingOptions.Transform;
173+
public Vector2 SubPixelOffset { get; }
174+
175+
/// <summary>
176+
/// Gets the drawing transform: the drawing options transform followed by the sub-pixel
177+
/// translation.
178+
/// </summary>
179+
public Matrix4x4 Transform
180+
{
181+
get
182+
{
183+
Matrix4x4 transform = this.drawingOptions.Transform;
184+
if (this.SubPixelOffset == Vector2.Zero)
185+
{
186+
return transform;
187+
}
188+
189+
return transform.IsIdentity
190+
? Matrix4x4.CreateTranslation(this.SubPixelOffset.X, this.SubPixelOffset.Y, 0F)
191+
: transform * Matrix4x4.CreateTranslation(this.SubPixelOffset.X, this.SubPixelOffset.Y, 0F);
192+
}
193+
}
132194

133195
/// <summary>
134196
/// Gets a value indicating whether the command was recorded inside a layer.

0 commit comments

Comments
 (0)