Instructions for using GDI, GDI+ for drawing primitives, shapes and processing/rendering bitmaps and rendering Fonts. Also, tenets for adding new APIs to System.Drawing for modernization and improving drawing, imaging, and font features.
These rules apply when using GDI+ for drawing in the WinForms runtime and
when adding new drawing APIs to System.Drawing. For control-level API
additions (properties, events, etc.), see the new-control-api skill. For
writing tests for rendering APIs, see the gdi-rendering-tests skill.
Golden rule: Cache what you can, dispose what you must, and always restore graphics state after modifying it.
WinForms provides a ref-counted caching mechanism for Pen and SolidBrush
objects (RefCountedCache<TObject, TCacheEntryData, TKey>). Cached objects have
implicit conversions that let them behave like their GDI+ counterparts while
reducing allocation overhead.
SystemPens and SystemBrushes already provide cached objects. Always
prioritize their usage (unless you need a non-default pen width). Objects
obtained from these APIs must not be disposed:
// Good — already cached via SystemPens.
e.Graphics.DrawLine(SystemPens.ButtonHighlight, 0, bounds.Bottom - 1, bounds.Width, bounds.Bottom - 1);
When SystemPens/SystemBrushes do not have the color you need, prefer cached
scopes over direct instantiation:
// INCORRECT — direct instantiation.
using (var pen = new Pen(Color.Red))
{
g.DrawLine(pen, p1, p2);
}
// CORRECT — cached scope.
using var pen = Color.Red.GetCachedPenScope();
g.DrawLine(pen, p1, p2);
Available cached scopes:
// Default-width pen (width = 1)
using var pen = color.GetCachedPenScope();
// Custom-width pen (integer only)
using var thickPen = color.GetCachedPenScope(2);
// SolidBrush
using var brush = color.GetCachedSolidBrushScope();
Always use var for the scope type and always apply using.
When caching is not possible: If the pen needs additional configuration
during its lifetime (e.g., DashStyle, CustomStartCap, Inset), you must
use a non-cached pen/brush and dispose it explicitly.
When refactoring methods that return Pen or SolidBrush:
// Before
private Pen GetHighlightPen() => new Pen(SystemColors.Highlight);
// After
private PenCache.Scope GetHighlightPenScope()
=> SystemColors.Highlight.GetCachedPenScope();
In WinForms control painting, prefer e.GraphicsInternal over e.Graphics for
performance. GraphicsInternal avoids unnecessary state saves:
void Paint(PaintEventArgs e)
{
e.GraphicsInternal.DrawRectangle(pen, rect);
}
Caveat: Do not pass GraphicsInternal to other methods — callees cannot
distinguish it from a regular Graphics instance.
If you must modify the clip or transform, save and restore state:
GraphicsState? previousState = null;
try
{
previousState = graphicsInternal.Save();
graphicsInternal.TranslateTransform(x, y);
graphicsInternal.SetClip(rect);
// … draw …
}
finally
{
if (previousState is not null)
{
graphicsInternal.Restore(previousState);
}
}
When changing quality settings, always restore the original value:
SmoothingMode originalMode = g.SmoothingMode;
try
{
g.SmoothingMode = SmoothingMode.AntiAlias;
// … draw …
}
finally
{
g.SmoothingMode = originalMode;
}
Settings that must be preserved and restored:
SmoothingModeTextRenderingHintInterpolationModeCompositingQualityPixelOffsetModeAlways dispose GDI+ objects that cannot be cached:
using var customPen = new Pen(Color.Red) { DashStyle = DashStyle.Dash };
g.DrawLine(customPen, p1, p2);
Never cache GraphicsPath objects — always create, use, and dispose locally:
using GraphicsPath path = new();
path.AddEllipse(rect);
g.FillPath(brush, path);
Ensure objects remain valid throughout their usage. Do not pass a scoped object to something that may use it after the scope ends:
// INCORRECT — brush may be used after scope ends.
using (var brush = color.GetCachedSolidBrushScope())
{
someObject.SomeFutureOperation(brush);
}
// CORRECT — immediate use within scope.
using var brush = color.GetCachedSolidBrushScope();
g.FillRectangle(brush, rect);
When adding new drawing APIs (e.g., DrawXxx / FillXxx methods) to the
Graphics class or new path operations to GraphicsPath, follow the
established pattern from the RoundedRectangle API addition.
All new public APIs in System.Drawing must be guarded with a preprocessor
directive for the target .NET version. Currently, new APIs target at least
.NET 11:
#if NET11_0_OR_GREATER
/// <summary>
/// Draws the outline of the specified rounded rectangle.
/// </summary>
public void DrawRoundedRectangle(Pen pen, RectangleF rect, SizeF corner)
{
using GraphicsPath path = new();
path.AddRoundedRectangle(rect, corner);
DrawPath(pen, path);
}
#endif
Why? The
System.Drawing.Commonpackage ships as part of the shared framework. Version guards ensure new APIs are only available on the .NET version they were approved for, preventing accidental use on older runtimes, specifically, when we need to service parts of main at a later point in time back into an earlier version, or if we're including for a new version, whose branch has not snapped, yet.
Every new drawing primitive must have two public overloads:
Rectangle, Point, Size) that delegates to the
float overload.RectangleF, PointF, SizeF) with the actual
implementation.The integer overload uses <inheritdoc cref="..."/> to inherit documentation
from the float overload:
#if NET11_0_OR_GREATER
/// <inheritdoc cref="DrawRoundedRectangle(Pen, RectangleF, SizeF)"/>
public void DrawRoundedRectangle(Pen pen, Rectangle rect, Size corner) =>
DrawRoundedRectangle(pen, (RectangleF)rect, corner);
/// <summary>
/// Draws the outline of the specified rounded rectangle.
/// </summary>
/// <param name="pen">The <see cref="Pen"/> to draw the outline with.</param>
/// <param name="rect">The bounds of the rounded rectangle.</param>
/// <param name="corner">
/// The size of the ellipse used to round the corners of the rectangle.
/// </param>
public void DrawRoundedRectangle(Pen pen, RectangleF rect, SizeF corner)
{
using GraphicsPath path = new();
path.AddRoundedRectangle(rect, corner);
DrawPath(pen, path);
}
#endif
When adding a new shape primitive, provide both Draw (outline) and Fill
(interior) methods. The Fill variant takes a Brush instead of a Pen:
#if NET11_0_OR_GREATER
public void FillRoundedRectangle(Brush brush, RectangleF rect, SizeF corner)
{
using GraphicsPath path = new();
path.AddRoundedRectangle(rect, corner);
FillPath(brush, path);
}
#endif
If the new primitive is path-based, add the Add[Shape] method to
GraphicsPath as well, using the same version guard and integer/float overload