Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Quill/llms.txt

Use this file to discover all available pages before exploring further.

Prowl.Quill is designed to be fast out of the box — the entire rendering core is under 1 000 lines of executable code, and the API eliminates unnecessary allocations wherever possible. Even so, understanding how the library batches draw calls, how tessellation tolerances trade quality for speed, and when to reuse pre-built text layouts will help you sustain high frame rates even when drawing tens of thousands of shapes per frame.

Draw call batching

The canvas automatically merges consecutive shapes into a single GPU draw call whenever they share the same scissor region, brush (colour, gradient, texture), and shader + uniforms. No manual grouping is required — draw similar shapes back-to-back and the renderer handles the rest.
// All three circles share the same brush → one draw call
canvas.SetFillColor(Color32.FromArgb(200, 80, 160, 255));
canvas.CircleFilled(100f, 100f, 30f, Color32.FromArgb(200, 80, 160, 255));
canvas.CircleFilled(200f, 100f, 30f, Color32.FromArgb(200, 80, 160, 255));
canvas.CircleFilled(300f, 100f, 30f, Color32.FromArgb(200, 80, 160, 255));

// Changing the colour starts a new draw call
canvas.CircleFilled(400f, 100f, 30f, Color32.FromArgb(200, 255, 100, 80));

What breaks a batch

State changes

Setting a different fill colour, stroke colour, brush (gradient/texture), scissor rectangle, or custom shader always opens a new draw call on the next shape.

RequestNewDrawCall()

Call RequestNewDrawCall() when you need to guarantee a rendering-order boundary — for example, when a transparent shape must composite over an opaque one drawn in the same frame.
// Force a new draw call to ensure correct transparency layering
canvas.RectFilled(50f, 50f, 200f, 100f, Color32.FromArgb(255, 40, 40, 80));
canvas.RequestNewDrawCall();
canvas.RectFilled(80f, 70f, 140f, 60f, Color32.FromArgb(120, 255, 255, 255));
Sort your draw commands by shader → texture → brush → scissor to maximise batching opportunities. The benchmark scene in Prowl.Quill draws 79 000 rectangles and 1 000 circles per frame using this principle and sustains very high frame rates.

Prefer hardware-accelerated primitives

The *Filled family of methods (RectFilled, RoundedRectFilled, CircleFilled, PieFilled) uses shader-based antialiasing and bypasses the CPU path-tessellation pipeline entirely. They are significantly faster than the equivalent path API for simple shapes:
// Single method call, no tessellation, AA handled in GPU shader
canvas.RectFilled(x, y, width, height, color);
canvas.CircleFilled(cx, cy, radius, color);
canvas.RoundedRectFilled(x, y, width, height, radius, color);
canvas.PieFilled(cx, cy, radius, startAngle, endAngle, color);
Reserve BeginPath / Fill / FillComplex for genuinely irregular shapes — concave polygons, shapes with holes, and SVG-style paths — where no built-in primitive applies.

Tessellation tolerance

public void SetTessellationTolerance(float tolerance = 0.5f)
The tessellation tolerance controls how closely Bézier curve and arc approximations must follow the true curve before subdivision stops. The default is 0.5 (half a logical pixel), which is barely visible. Raising it reduces the triangle count for curved paths at the cost of slight faceting:
// Coarser tessellation — faster, slightly faceted curves
canvas.SetTessellationTolerance(2.0f);

// Finer tessellation — smoother curves, more triangles (default)
canvas.SetTessellationTolerance(0.5f);
Only affects BezierCurveTo, QuadraticCurveTo, and path-based Arc. Shader-based primitives (CircleFilled, etc.) are unaffected.

Arc segment density

public void SetRoundingMinDistance(float distance = 3f)
Controls the minimum arc segment length (in logical units) for Arc, Circle, RoundedRect, and their filled equivalents. A higher value means fewer segments per arc — fewer CPU triangles but potentially more visible faceting on very large circles:
// Fewer arc segments for a large number of small circles
canvas.SetRoundingMinDistance(6f);

// More segments for a large, prominent circle (default)
canvas.SetRoundingMinDistance(3f);
For shader-based filled primitives (CircleFilled, RoundedRectFilled), segment count also affects CPU cost, but the GPU AA fringe still looks smooth regardless. Raising RoundingMinDistance to 5–8 is a safe optimisation for scenes with many small circles.

Reuse text layouts across frames

Pre-building a text layout once is one of the most impactful optimisations available for text-heavy UIs.

Static text: CreateLayout + DrawLayout

public TextLayout CreateLayout(string text, TextLayoutSettings settings)
public void DrawLayout(TextLayout layout, float x, float y, Color32 color, Float2? origin = null)
CreateLayout runs the font engine’s glyph shaping and line-breaking pipeline and returns an object whose geometry is frozen. DrawLayout simply submits that geometry — no shaping, no line-breaking, no per-character measurement:
// Create once (e.g., at startup or when text changes)
TextLayout labelLayout = canvas.CreateLayout("Score: 9999", new TextLayoutSettings
{
    Font      = uiFont,
    PixelSize = 20f,
});

// Draw many times per frame at negligible cost
canvas.DrawLayout(labelLayout, 10f, 10f, Color.White);

Rich text: reuse QuillRichText

// Create once
QuillRichText richText = canvas.CreateRichText(source, settings);

// Draw every frame — parsing and layout are not repeated
canvas.DrawRichText(richText, position, currentTime);
Call QuillRichText.Reset() to replay animations without recreating the object. Only call CreateRichText again when the source text or layout settings change.

Markdown: reuse QuillMarkdown

// Create once per document
QuillMarkdown doc = canvas.CreateMarkdown(markdownSource, settings);

// Draw at zero extra allocation cost each frame
canvas.DrawMarkdown(doc, new Float2(x, y));
Recreate the QuillMarkdown object only when the source or the available width changes.

HiDPI and framebuffer scale

public void BeginFrame(float width, float height, float framebufferScale = 1.0f)
The framebufferScale parameter tells the canvas how many physical pixels correspond to one logical unit. On a Retina or HiDPI display, this is typically 2.0. Passing the correct value is critical for both visual quality and performance:

Correct scale

At framebufferScale = 2.0, glyph atlases are rasterised at 2× density, AA fringe widths shrink to sub-pixel size, and everything looks crisp without any changes to your drawing code.

Wrong scale

At framebufferScale = 1.0 on a HiDPI display, glyphs are rasterised at 1× and upscaled by the OS, producing blurry text and thick AA fringes. Conversely, using 2.0 on a 1× display over-rasterises and wastes GPU memory.
// Query the OS for the true scale factor (example — actual API is backend-specific)
float dpiScale = window.DevicePixelRatio; // 1.0, 1.5, 2.0, etc.

canvas.BeginFrame(
    width:             window.ClientWidth  / dpiScale,
    height:            window.ClientHeight / dpiScale,
    framebufferScale:  dpiScale);
Use canvas.PixelToLogical(rawMousePos) to convert physical-pixel mouse coordinates back to logical units for hit-testing.

Benchmark insights

The BenchmarkScene sample draws 79 000 rectangles and 1 000 circles every frame, each with an independent transform (translate + rotate + scale) and an animated colour. Key techniques it uses that you can apply in production:
Creating a System.Random inside the hot loop is expensive. The benchmark uses a simple LCG (Linear Congruential Generator) to produce pseudo-random floats at near-zero cost:
private uint _randomState = 42;

private float NextFloat()
{
    _randomState = _randomState * 1103515245u + 12345u;
    return (_randomState >> 8) * (1.0f / 16777216.0f);
}
Time-dependent colour calculations use Maths.Sin which is not free. Computing the time argument multipliers once before the loop avoids redundant floating-point work:
float colorTimeR = _time * 2f;
float colorTimeG = _time * 1.5f;
float colorTimeB = _time * 1.8f;

for (int i = 0; i < RECT_COUNT; i++)
{
    byte r = (byte)(128 + 127 * Maths.Sin(colorTimeR + i * 0.01f));
    byte g = (byte)(128 + 127 * Maths.Sin(colorTimeG + i * 0.015f));
    byte b = (byte)(128 + 127 * Maths.Sin(colorTimeB + i * 0.008f));
    // ...
}
Rather than calling GetTransform inside the loop, the benchmark caches the root transform once and restores it with CurrentTransform for each shape — avoiding multiple matrix multiplications:
var curTransform = canvas.GetTransform();

for (int i = 0; i < RECT_COUNT; i++)
{
    canvas.CurrentTransform(curTransform);          // cheap: direct assignment
    canvas.TransformBy(Transform2D.CreateTranslation(x, y));
    canvas.TransformBy(Transform2D.CreateRotation(rotation));
    canvas.TransformBy(Transform2D.CreateScale(scale, scale));
    canvas.RectFilled(-w / 2, -h / 2, w, h, color);
}

Quick reference

PracticeImpact
Use RectFilled, CircleFilled, RoundedRectFilled for simple shapesHigh — skips CPU tessellation
Draw shapes with the same shader/brush consecutivelyHigh — maximises batching
Use CreateLayout + DrawLayout for static textHigh — eliminates per-frame font shaping
Reuse QuillRichText / QuillMarkdown across framesHigh — layout computed once
Set framebufferScale correctly for HiDPIHigh — crispness and atlas efficiency
Increase SetRoundingMinDistance for many small arcsMedium — fewer CPU triangles
Increase SetTessellationTolerance for curved pathsMedium — fewer Bézier subdivisions
Call RequestNewDrawCall() only when necessaryMedium — unnecessary calls fragment batches

Build docs developers (and LLMs) love