Skip to content

Sprite geometry

Generated from engine/render/sprite_geometry.h at 862e08b. The text under each declaration is the header's own comment, word for word. About the reference says how these pages are made.

Browse the render module

#include "engine/render/sprite_geometry.h" · namespace labrador

Where a sprite's four corners go, and what they sample.

THIS FILE IS THE PIXEL CONTRACT. Every term RenderPixelTests pins is decided here and nowhere else: that the destination is in pixels with y running down, that texel (0,0) lands at the destination's top left, that a flip mirrors the texture and not the rectangle, that the source rectangle is in texels, that the tint multiplies, that a fractional destination truncates with an exclusive right edge, and that the origin is measured in unscaled source texels.

A BACKEND DOES NOT DO THIS ARITHMETIC, WHICH IS THE WHOLE POINT. What a backend receives is four SpriteVertex in view pixels; what it owes is a buffer, a shader that multiplies each vertex by one four-float constant and the sampled texel by the vertex's own colour, and the state that makes the blend premultiplied. No two backends can disagree about where a sprite went, because none of them decides - the null backend included, which runs this file too, and that is what makes its recording worth asserting on.

THE ONE TERM A BACKEND STILL OWNS is where the pane itself sits in the thing being drawn into, because that is the one question whose answer is not the same shape on every API behind this seam. Direct3D measures a viewport down from the render target's top left and needs no height at all. GL measures up from the bottom and so has to subtract from one, which it reads from the window rather than from a cached copy, because a cached copy is wrong for the whole of every drag-resize (engine/render/gl/backend.h, Impl::drawable_size). Vulkan hands the rasteriser a viewport whose origin is the pane's BOTTOM edge and whose height is negative, which inverts y for the whole pipeline and makes the flip pane-local rather than buffer-relative - so the buffer's height never enters it (engine/render/vulkan/renderer.cpp). Each keeps the sentence above true rather than nearly true.

THE CORNER ORDER IS PART OF THE CONTRACT: 0 is the destination's top left, 1 its top right, 2 its bottom left, 3 its bottom right. A backend's index buffer is built from that and from nothing else, so the two triangles are (0,1,2) and (1,3,2).

void build_sprite_quad(const mattmath::RectangleF& destination,
const mattmath::RectangleI& source,
const mattmath::Vector2F& texture_size,
const Colour& tint,
float rotation,
const mattmath::Vector2F& origin,
SpriteFlip flip,
SpriteVertex* corners);

Fills corners[4] for a sprite drawn into destination.

destination is in view pixels and source in texels of a texture texture_size texels across. origin is in UNSCALED SOURCE TEXELS - so an 8x8 destination over a 2x2 source scales an origin of two texels into a shift of eight pixels, which is the term the seam states and the one most often assumed to be destination pixels instead.

THE DESTINATION TRUNCATES. Each of its four edges is taken to a whole pixel by dropping the fraction, and the size is the difference of the truncated edges rather than the truncation of the size - which is not the same number, and is why this takes a rectangle rather than a position and a size. A right edge of 18.9 becomes 18 and is exclusive.

float scale,
const mattmath::RectangleI& source,
const mattmath::Vector2F& texture_size,
const Colour& tint,
float rotation,
const mattmath::Vector2F& origin,
SpriteVertex* corners);

The same, for a sprite placed by a position and a uniform scale over its source rather than by a rectangle.

NOTHING TRUNCATES HERE, and that is the difference that matters. Text is laid out by this: a pen advance is fractional in most fonts, and rounding each glyph to a whole pixel would make a line of text drift against its own measurement. There is no flip because no caller flips text, and flipped text is named in RenderPixelTests as something nothing pins.

void build_glyph_quad(const mattmath::Vector2F& position,
float scale,
const Glyph& glyph,
const mattmath::Vector2F& pen,
const mattmath::Vector2F& texture_size,
const Colour& tint,
float rotation,
const mattmath::Vector2F& origin,
SpriteVertex* corners);

One glyph of a line of text, at the pen the walk (font.h) reported.

position, scale, tint and rotation are the whole string's, and origin is the caller's origin for the string - all four the same for every glyph in it. What varies per glyph is glyph and pen.

THIS IS THE TERM THE WALK DELIBERATELY DOES NOT CARRY. for_each_glyph reports pen.y as the TOP OF THE LINE and leaves the glyph's own y_offset out, because measurement wants the line and drawing wants the bearing; this is where drawing adds it back.

The pen subtracts because it moves the glyph and an origin moves the string: shifting the origin left by the pen puts the glyph right of the string's position by exactly the pen. Both are in unscaled source texels, which is what build_scaled_quad already means by an origin, so the two compose without a conversion.

const mattmath::RectangleF& destination,
const mattmath::RectangleI& source,
float rotation,
const mattmath::Vector2F& origin);

THE AXIS-ALIGNED BOX AROUND THE QUAD build_sprite_quad WOULD BUILD from the same four arguments: the same truncated edges, the same origin shift, the same turn about the destination's top left. Tight, not merely conservative - it is the bounding box of the four corners.

This is a view-space measurement, not a world-space cull bound. sprite_world_bounds accounts for the camera-dependent quantization. It lives here because it has to agree with build_sprite_quad to the term. The destination rectangle is where a sprite lands only when its origin is zero and its rotation is zero. An authored frame origin shifts the sprite by that many texels' worth of destination, and a rotation turns it about its top left, so a sprite at x=100 with an origin of its own width draws across x=80..100 - and a cull against x=100..120 would drop it from any view that ends at x=90 while it is plainly inside, on every backend at once, because none of them decides this.

origin is the WHOLE origin - the frame's authored one plus the caller's, summed the way SpriteSheet::draw sums them - in unscaled source texels, exactly as build_sprite_quad takes it.

const mattmath::RectangleF& destination,
const mattmath::RectangleI& source, float rotation,
const mattmath::Vector2F& origin, float units_per_pixel = 0.0f);

World geometry before view-space quantization, expanded by a bound on its error at nonnegative world units per pixel (zero for layout). Each truncated edge moves by less than one pixel; a size moves by less than two. Rotation mixes those errors and the source-relative pivot scales them, including origins outside the source rectangle.

float scale,
const mattmath::Vector2F& measured_size,
float rotation,
const mattmath::Vector2F& origin);

The same, for a string laid out by build_glyph_quad: measured_size is what the font's walk reported for the whole string, unscaled, and the box is that size, scaled, sitting origin scaled texels up and left of position, turned about position. Every glyph's quad falls inside it because every glyph's pen falls inside the measurement, which is font.h's contract rather than this file's.

Named in the public declarations above: Colour, Glyph, RectangleF, RectangleI, SpriteFlip, SpriteVertex, Vector2F.

The files that include this header directly. A file can also reach it through another header.

Development documentation (unreleased). Built from Labrador 862e08b of 2026-10-08.