Paper is intentionally decoupled from any specific graphics API. All rendering goes throughDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Paper/llms.txt
Use this file to discover all available pages before exploring further.
ICanvasRenderer, a small interface that receives batched draw-call data from Prowl.Quill and submits it to whatever GPU backend you choose. This page explains the interface, shows how to wire it up, and points you to the three ready-to-run reference implementations in the repository.
When Do You Need a Custom Renderer?
If you are using the provided OpenTK or Raylib samples you do not need to implement anything — those renderers are complete and production-quality. You only need to write your ownICanvasRenderer when:
- You are embedding Paper in an engine that manages its own render pipeline (Unity, Godot, a custom engine).
- You want to target a graphics API not yet covered (DirectX, Vulkan, Metal, WebGPU).
- You need to integrate Paper’s draw calls into an existing render graph or command buffer model.
Reference Implementations
Study these three samples before writing your own. They all implement the same interface against very different backends and between them cover the major patterns you will encounter.OpenTK (OpenGL 4)
Samples/OpenTK/PaperRenderer.csFull-featured OpenGL 4.3 renderer with a VAO/VBO pipeline, projection matrix, scissor, gradient brushes, and a dual-Kawase backdrop blur implementation.
Raylib
Samples/RaylibSample/RaylibCanvasRenderer.csUnder 500 lines. Uses Raylib’s
Rlgl low-level API to submit triangles directly. Demonstrates that the interface is simple enough for a minimal integration.WebGL / WASM
Samples/WasmExample/WebGLCanvasRenderer.csBridges the .NET/WASM runtime to a JavaScript WebGL2 context through JS interop. Shows how to handle texture IDs as opaque tokens and batch vertex data across the boundary.
The ICanvasRenderer Interface
Paper only calls three methods on your renderer, plus a texture lifecycle pair:RenderCalls is called once per paper.EndFrame(). All vertex and index data for the entire frame lives in canvas.Vertices and canvas.Indices. Each DrawCall in the list has an ElementCount telling you how many indices it consumes, starting from the offset left by the previous draw call.Step-by-Step: Implementing a Renderer
Paper produces anti-aliased geometry using a single fragment shader that handles flat fills, gradients, texture sampling, scissoring, and optional backdrop blur. The vertex format is 20 bytes per vertex:
offset 0: float x (position)
offset 4: float y
offset 8: float u (UV / AA params)
offset 12: float v
offset 16: ubyte r, g, b, a (pre-multiplied colour)
Copy the GLSL source from
Samples/Common/CanvasShaderSource.cs for OpenGL-based backends or from the inline string constants in RaylibCanvasRenderer.cs. The fragment shader expects these uniforms:uniform mat4 projection; // orthographic: (0,w) x (0,h)
uniform sampler2D texture0; // font atlas / image texture
uniform mat4 scissorMat; // scissor transform
uniform vec2 scissorExt; // scissor half-extents
uniform mat4 brushMat; // gradient transform
uniform int brushType; // 0=none, 1=linear, 2=radial, 3=box
uniform vec4 brushColor1;
uniform vec4 brushColor2;
uniform vec4 brushParams; // gradient geometry
uniform vec2 brushParams2; // corner radius + feather
uniform mat4 brushTextureMat; // texture image transform
uniform float dpiScale; // logical-to-pixel ratio
public class MyRenderer : ICanvasRenderer
{
public bool SupportsBackdropBlur => false; // enable once blur passes are ready
public object CreateTexture(uint width, uint height)
{
// Allocate a GPU texture and return an opaque handle.
// Example for a hypothetical API:
var tex = MyGfxApi.CreateTexture2D(width, height, Format.RGBA8);
return tex;
}
public Int2 GetTextureSize(object texture)
{
var tex = (MyTexture)texture;
return new Int2((int)tex.Width, (int)tex.Height);
}
public void SetTextureData(object texture, IntRect bounds, byte[] data)
{
var tex = (MyTexture)texture;
tex.UpdateRegion(bounds.Min.X, bounds.Min.Y,
bounds.Size.X, bounds.Size.Y, data);
}
public void RenderCalls(Canvas canvas, IReadOnlyList<DrawCall> drawCalls)
{
if (drawCalls.Count == 0) return;
// 1. Upload vertex + index buffers to the GPU.
UploadGeometry(canvas.Vertices, canvas.Indices);
// 2. Configure shared render state (blend, depth-test, etc.).
SetBlendState(premultipliedAlpha: true);
// 3. Iterate draw calls.
int indexOffset = 0;
foreach (var dc in drawCalls)
{
BindTexture(dc.Texture ?? _whiteTexture);
if (dc.Shader != null)
{
// Custom shader path
UseShader((MyShader)dc.Shader);
ApplyCustomUniforms(dc.ShaderUniforms);
}
else
{
// Default Paper shader
UseShader(_paperShader);
// Scissor
dc.GetScissor(out var scissorMat, out var scissorExt);
SetUniform("scissorMat", scissorMat);
SetUniform("scissorExt", scissorExt);
// Gradient brush
SetUniform("brushType", (int)dc.Brush.Type);
SetUniform("brushMat", dc.Brush.BrushMatrix);
SetUniform("brushColor1", dc.Brush.Color1);
SetUniform("brushColor2", dc.Brush.Color2);
SetUniform("brushParams", new Float4(
(float)dc.Brush.Point1.X, (float)dc.Brush.Point1.Y,
(float)dc.Brush.Point2.X, (float)dc.Brush.Point2.Y));
SetUniform("brushParams2", new Float2(
(float)dc.Brush.CornerRadii, (float)dc.Brush.Feather));
SetUniform("brushTextureMat", dc.Brush.TextureMatrix);
SetUniform("dpiScale", (float)canvas.FramebufferScale);
}
DrawIndexed(indexOffset, dc.ElementCount);
indexOffset += dc.ElementCount;
}
}
}
Construct a
Paper instance, passing your renderer, the initial viewport size, and a FontAtlasSettings describing how fonts should be rasterized.var renderer = new MyRenderer();
renderer.Initialize(windowWidth, windowHeight);
var fontAtlas = new Prowl.Quill.FontAtlasSettings(); // default settings
var paper = new Paper(renderer, windowWidth, windowHeight, fontAtlas);
When the window dimensions change, notify both the renderer (so it rebuilds its projection matrix) and Paper (so it re-flows the layout).
void OnWindowResize(int newWidth, int newHeight)
{
renderer.UpdateProjection(newWidth, newHeight); // your renderer method
paper.SetResolution(newWidth, newHeight);
}
On high-DPI displays there is a gap between the logical window size (what Paper uses for layout) and the physical framebuffer size (what the GPU draws into). Set
DisplayFramebufferScale so Paper rasterizes fonts at the right density.// On Retina macOS or with a 200% Windows DPI setting:
paper.DisplayFramebufferScale = new Float2(2f, 2f);
// Or pass dpiScale to BeginFrame each frame (sets X and Y simultaneously):
float dpi = (float)framebufferWidth / logicalWidth;
paper.BeginFrame(deltaTime, dpiScale: dpi);
Scale default style values (padding, border widths, etc.) proportionally with
ScaleAllSizes. Call this once at startup, not every frame.FontAtlasSettings
FontAtlasSettings controls how Prowl.Quill allocates and updates the GPU font texture atlas. The defaults work for most applications, but you may want to adjust them when:
- You are loading a large number of distinct fonts or sizes (increase atlas dimensions).
- You need subpixel rendering or specific SDF parameters.
- You are targeting memory-constrained hardware (reduce atlas size, limit glyph range).
Loading Fonts
Paper usesFontFile objects from Prowl.Scribe. Load font bytes from disk or an embedded resource and construct a FontFile: