All tutorials Mighty Professional
Build a Game Engine · Rendering: the 2D path (Vulkan)

The 2D Renderer: Sprite Batching

Drawing one textured quad is easy. Drawing ten thousand of them at sixty frames a second means you can't pay for a draw call per sprite, so you stop issuing one and start : pack many quads into one buffer and one draw. This page builds a batcher in C++ and Rust on top of the Vulkan scaffold, then fixes the problems a real 2D renderer runs into next: seams between sprites in an atlas, dark halos around cutouts, and transparent sprites layered in the wrong order.

Time~55 min LevelSenior PrereqsHelpful, not required: Your First Triangle in Vulkan (the draw loop), Textures, Samplers & Materials (descriptor sets), and 3D Math (the ortho matrix). StackC++ (Vulkan) · Rust (ash)
◂ Build a Game Engine Phase 5 · Rendering: the 2D path (Vulkan) Next · The Audio Engine ▸
What you'll be able to do by the end

01The draw-call problem

A bullet-hell scene with a few thousand sprites can drop to 30 fps while the GPU sits mostly idle. Each and each state bind (pipeline, descriptor set, vertex buffer) carries a fixed CPU-side cost: the driver validates and translates state and encodes a command. Issue one draw per sprite and the CPU's submission rate, not the GPU, sets the frame rate. Wloka's 2003 talk put it bluntly: "Ask not how many tris/batch, but rather how many batches/frame!" His measurements had a 1 GHz CPU, fully busy, submitting about 25,000 batches per second[1].

Worked example

At Wloka's rate, 25,000 batches a second is 25,000 ÷ 60 = about 416 batches per frame at 60 fps, with the CPU doing nothing else. On that 2003 hardware, drawing 2,000 sprites one call each means the frame can't come in under 2,000 ÷ 25,000 = 80 ms, about 12 fps, however fast the GPU is. Modern APIs cut the cost per call, but the total still grows with the number of calls. Put the same 2,000 sprites in one batch and the draw-call cost is one call.

Batching speeds up the CPU, not the GPU

The common misframing. Batching cuts driver and per-draw overhead on the CPU; it does not make the GPU rasterize faster (it often does less total work, with fewer state changes). The numbers from Wloka's talk come from 2003-era Direct3D drivers and CPUs; Vulkan and D3D12 lowered per-call overhead a lot, so don't quote one figure as current. But per-draw cost is still nonzero, so batching still pays[1].

The widget counts the draws a batcher would issue for a field of sprites:

The dots are sprites, colored by which of three textures they use; the panel shows the sprite count, the draw calls and a CPU frame-time bar with a 16.6 ms mark. Drag Sprites, flip between the strategies and between three textures and one atlas, and with three textures tick "sort by texture". One draw per sprite: the draw-call count equals the sprite count, and at the starting 1,200 sprites the bar reads 17.2 ms, past the 16.6 ms line. Batched with one atlas: a single draw however many sprites (up to the 16k-quad index cap from §3), and 1.7 ms. Batched with three textures in submission order: the batch breaks at every texture change, which with shuffled sprites is most of them: 790 draws for 1,200 sprites. Sorted by texture it drops to three draws, which only works when draw order doesn't matter (§8). That's why atlases exist. The bar is a proxy that charges a fixed 13 µs per draw, an illustrative older-API figure rather than a measurement; read it for the shape.

02The quad & camera

Your sprites are positioned in world units, the player walks right, and the camera has to follow without you rewriting every sprite's position. Every sprite is a quad, a rectangle drawn as two triangles: four vertices, a six-index list (0,1,2, 2,3,0) so the diagonal vertices are shared. Per-vertex you carry position, a UV (a 0..1 coordinate into the texture), and a tint color. Sprites live in world space; a 2D camera and an orthographic projection map them to the screen.

Orthographic projection maps a world box straight to clip space (the vertex shader's output coordinates) with no perspective: things don't shrink with distance. A pixel-space ortho (left 0, right width, top 0, bottom height) makes one world unit one pixel. The camera's view matrix is pan (translate) and zoom (scale); the view-projection is ortho · view, computed once per frame.

For the pixel-space ortho, the mapping from a screen pixel to Vulkan's −1..1 normalized coordinates is:

eq. 1 · pixel-space ortho xndc = 2x ÷ W − 1,   yndc = 2y ÷ H − 1

In words: divide the pixel position by the screen size to get 0..1 across the screen, then stretch that to −1..1. Pixel (0, 0) is the top-left corner at (−1, −1) and (W, H) is the bottom-right at (1, 1). Hover any term to see what it stands for.

Worked example

A 1280 × 720 screen, a camera centered on world point (640, 360) and zoomed 2×. The view maps a world point to the screen as (world − camera) × zoom + screen center, so world (700, 400) lands at ((700 − 640) × 2 + 640, (400 − 360) × 2 + 360) = (760, 440). The ortho then gives NDC (2 × 760 ÷ 1280 − 1, 2 × 440 ÷ 720 − 1) = (0.19, 0.22): a little right of and below the center. Pan the camera and only the 64-byte view-projection changes; every sprite's world position stays put.

It's the Y-flip, not "Vulkan is left-handed"

Vulkan's NDC is right-handed; what trips people is the Y-down framebuffer mapping and the 0..1 depth range, both separate from world handedness (the 3D Math tutorial untangles this). Whether you need a flip depends on your world: the pixel-space ortho above (top 0, bottom height) already matches Vulkan's Y-down, but a +Y-up game world needs one, either by swapping the ortho's top and bottom or with a negative viewport height. Two more: zoom about the screen center (or cursor), not the world origin, or the view drifts; and keep vertices in world space with the camera applied in the vertex shader. Bake the camera into vertices on the CPU and any cached geometry (a static tilemap buffer, say) has to be rebuilt every time the camera moves.

The world here is +Y up: ground, three houses and a sun. The camera pans by itself; drag Zoom, then tick "skip the Y-flip". The readout traces one point from world coordinates through Vulkan's +Y-down NDC to a framebuffer pixel as the view-projection changes. Tick "skip the Y-flip" and the ground lands at the top and the roofs point down: the Vulkan Y-down trap, here at the camera level.

03Sprite batching

The fix for the draw-call problem is to send many sprites in one draw, which means building their vertices yourself. The batcher keeps a CPU-side vertex array. Each frame: clear it, append four vertices per visible sprite (world-space corners, atlas UVs, tint), copy the array into a per-frame dynamic vertex buffer, and issue one vkCmdDrawIndexed per batch. This is the MonoGame SpriteBatch design: accumulate, then flush[11][2].

Per-frame-in-flight, and what breaks a batch

The dynamic vertex buffer must be per-frame-in-flight: you can't overwrite a buffer the GPU is still reading from the previous frame, so allocate one per frame in flight and index by the current frame (exactly like the command buffers and sync objects). Within a frame, each flush appends after the previous batch instead of starting at offset 0: the GPU reads all of the frame's batches after submit, so rewriting the start of the buffer would make every earlier draw read the last batch's vertices. And a batch breaks on a texture change, a pipeline change, or a blend-state change: you flush the current batch, bind the new state, and start a fresh one[2]. There's no universal "max sprites per batch"; it's bounded by the buffer size and the index width (16-bit indices cap one draw at 16,384 quads).

Worked example

Each SpriteVertex below is 20 bytes, so a sprite's four corners take 80 bytes. 10,000 sprites write 800,000 bytes a frame, and two frames in flight need two such buffers, 1.6 MB. A 16-bit index reaches 65,536 vertices, which is 65,536 ÷ 4 = 16,384 quads, and the shared index buffer for all of them (6 indices of 2 bytes per quad) is 196,608 bytes, built once. If the frame's first batch held 1,000 sprites, the second batch's draw passes vertexOffset = 4,000 so it reads its own vertices.

The batcher: accumulate, flush on texture change
struct SpriteVertex { float pos[2]; float uv[2]; uint32_t rgba; };  // 20 bytes, packed color

void draw(const Sprite& sprite) {
    if (sprite.texture != batchTexture) flush();   // texture change breaks the batch
    batchTexture = sprite.texture;
    appendQuad(vertices, sprite);                   // 4 world-space verts, atlas UVs, tint
}

void flush() {
    if (vertices.empty()) return;
    // One buffer PER frame in flight, and each flush this frame appends after the last:
    // the GPU reads every batch only after submit, so no batch may overwrite another.
    SpriteVertex* dst = mappedVertexBuffer[frameIndex] + frameVertexCount;
    memcpy(dst, vertices.data(), vertices.size() * sizeof(SpriteVertex));
    vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1,
                            &batchTexture.set, 0, nullptr);          // the combined image sampler
    VkDeviceSize offset = 0;
    vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer[frameIndex], &offset);
    vkCmdBindIndexBuffer(cmd, quadIndexBuffer, 0, VK_INDEX_TYPE_UINT16);   // 0,1,2, 2,3,0, then +4 per quad
    uint32_t indexCount = (uint32_t)(vertices.size() / 4 * 6);
    vkCmdDrawIndexed(cmd, indexCount, 1, 0, (int32_t)frameVertexCount, 0);   // vertexOffset: where this batch starts
    frameVertexCount += (uint32_t)vertices.size();
    vertices.clear();
}
// At the start of each frame, after its fence wait: frameVertexCount = 0.
#[repr(C)]                     // the GPU reads this layout: keep declaration order, no reordering
#[derive(Clone, Copy)]
struct SpriteVertex { pos: [f32; 2], uv: [f32; 2], rgba: u32 }   // 20 bytes

fn draw(&mut self, sprite: &Sprite) {
    if sprite.texture != self.batch_texture { self.flush(); }   // texture change breaks the batch
    self.batch_texture = sprite.texture;
    self.append_quad(sprite);                              // 4 world-space verts, atlas UVs, tint
}

fn flush(&mut self) {
    if self.vertices.is_empty() { return; }
    // One buffer PER frame in flight; each flush this frame appends after the last.
    let base = self.mapped_vertex_buffer[self.frame_index];   // *mut SpriteVertex from vkMapMemory
    unsafe {
        let dst = base.add(self.frame_vertex_count as usize);
        std::ptr::copy_nonoverlapping(self.vertices.as_ptr(), dst, self.vertices.len());
    }
    let index_count = (self.vertices.len() / 4 * 6) as u32;
    unsafe {
        self.device.cmd_bind_descriptor_sets(self.cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline_layout, 0,
            &[self.batch_texture.set], &[]);                          // the combined image sampler
        self.device.cmd_bind_vertex_buffers(self.cmd, 0, &[self.vertex_buffer[self.frame_index]], &[0]);
        self.device.cmd_bind_index_buffer(self.cmd, self.quad_index_buffer, 0, vk::IndexType::UINT16);
        self.device.cmd_draw_indexed(self.cmd, index_count, 1, 0, self.frame_vertex_count as i32, 0);   // vertex_offset: batch start
    }
    self.frame_vertex_count += self.vertices.len() as u32;
    self.vertices.clear();
}
// At the start of each frame, after its fence wait: self.frame_vertex_count = 0.
What's intentionally missing

A staging copy to device-local memory (we map host-visible, which is standard for per-frame streaming but can be slower to read on discrete GPUs); a capacity check and buffer growth when a frame's vertices outrun the buffer; the 32-bit index fallback past 16k quads; frustum culling of off-screen sprites; sort-key generation; multi-threaded batch building with secondary command buffers; and descriptor-set caching.

04Instancing

Particles and tilemaps are thousands of identical quads that differ only in position, size, frame and color, so re-sending all four corners for each one is mostly wasted bandwidth. The alternative to rebuilding all the vertices each frame: upload the quad's four vertices once, and put per-sprite data (position, scale, UV rect, color) in a second buffer bound at VK_VERTEX_INPUT_RATE_INSTANCE. Draw with vkCmdDrawIndexed(cmd, 6, instanceCount, 0, 0, 0) and the six-index quad; the vertex shader receives the static corner plus that instance's attributes, because the hardware advances an instance-rate buffer once per instance rather than once per vertex[3]. (gl_InstanceIndex is there if you'd rather index a storage buffer yourself.)

Both are valid; neither is universally best. Dynamic-vertex batching re-uploads all vertex data each frame (CPU rebuild plus bandwidth scaling with sprite count), but handles arbitrary per-sprite geometry and interleaved sorting easily. uploads far less when sprites are uniform quads (particles, tilemaps), but interleaving across textures is more awkward. Instancing still breaks the batch on a texture change: it cuts vertex bandwidth, not the texture-binding constraint.

Worked example

Per-instance data of a position (8 bytes), a scale (8), a UV rectangle (16) and a color (4) is 36 bytes a sprite, against 80 bytes for four full vertices. For 10,000 sprites that's 360 KB a frame instead of 800 KB, and the quad's four corners are uploaded once.

05Atlas & bleeding

The widget in §1 showed three textures turning one draw into hundreds. A packs many sprites into one image so they share a descriptor and don't break the batch; sprites reference sub-rects via UVs[4]. (A texture array of same-size layers, selected by index, avoids the UV math; bindless indexes a big descriptor array, but is feature-gated.)

Atlas bleeding, and why mips make it worse

Bilinear filtering blends the four nearest texels (texture pixels), so at a sprite's edge it pulls in the neighbor sprite's texels: a visible seam. The standard fixes are a half-texel UV inset, a padding gutter between sprites (2px or more), and edge extrusion[8]. Mipmaps make it worse: each level halves resolution, so the bleed crosses farther, and a gutter must be wide enough at the coarsest mip you use. The clean fix is per-sprite mips packed into the atlas, or a texture array, rather than mipping the whole atlas. (Hardware wrap modes also can't tile one sprite inside an atlas; repeating a sub-rect takes shader math.)

Worked example

A sprite occupies texels 0 to 7 of a 256-texel-wide atlas. Its UV range is 0 to 8 ÷ 256 = 0.03125, and the last texel's center is at 7.5 ÷ 256. Sampling exactly at the right edge, u = 0.03125, lands halfway between texel 7 and texel 8, which belongs to the next sprite, so bilinear returns half of each. Inset the UVs by half a texel, to 0.5 ÷ 256 and 7.5 ÷ 256, and the edge sample sits on texel 7's center and returns only the sprite's own color. Each mip level halves the atlas, so a gutter that is 2 texels wide at mip 0 is 1 texel at mip 1 and half a texel at mip 2.

A one-row atlas, sampled for real: sprite A (purple) sits beside sprite B (green).

The top row is the atlas, with sprite A's UV rectangle outlined in yellow, and the strip below is the right half of sprite A as the sampler actually returns it. Untick padding, then tick mipmaps. Packed tight with no inset, the last half-texel blends toward B: the readout says the edge picks up 49% of B's color. Add the 2-texel extruded gutter and the half-texel inset and it's clean, 0%. Now turn on whole-atlas mips: at level 2 one texel averages four of level 0, the gutter averages into B's texels, and a quarter-size sprite picks up green again, 28%. The gutter has to double in width for each mip level you use, which is why mipped sprites need wide gutters, per-sprite mips, or a texture array.

06The view-projection

The camera moves every frame, so the shader needs a fresh view-projection matrix every frame, and creating a buffer and descriptor for 64 bytes is a lot of ceremony. The view-projection is one mat4 (64 bytes), and the smallest way to hand it to the shader each frame is a push constant: the guaranteed minimum maxPushConstantsSize is 128 bytes (256 on Vulkan 1.4 devices), so one matrix fits with room to spare[9]. Push constants have no backing memory and are fast; a UBO (via a descriptor) is the alternative when you need more.

128 bytes is the floor, not the ceiling

Before Vulkan 1.4 the floor is 128 bytes, and some mobile drivers report exactly that. A full model + view + projection (3 × mat4 = 192 bytes) overflows it, so push the premultiplied view-projection (64 bytes), not all three. That leaves 64 bytes: room for, say, a 16-byte tint and a 4-byte time. Either way, query maxPushConstantsSize rather than assuming. The VkPushConstantRange stage flags must include every shader stage that reads it.

07Alpha blending

Leaves, sparks and smoke have soft, partly transparent edges, and those edges have to mix with whatever is already on screen. Transparency is a per-attachment blend, set in the pipeline, that combines the new color with the one already in the framebuffer[5]:

eq. 2 · blend result = src × Sf + dst × Df

In words: scale the new color by one factor, scale what's already there by another, and add. For the "over" operator, the factors make the new color cover the old in proportion to its alpha. There are two conventions for which factors to use, and the choice matters more than it looks. Hover any term to see what it stands for.

Premultiplied composites correctly under filtering and mips

The reason to prefer premultiplied: the GPU samples four texels and interpolates, then blends, and it can't un-multiply first. With straight alpha, a fully transparent texel still contributes its stored RGB to the interpolation, so a bilinear or mip blend pulls in garbage color: the dark or colored fringe halo around cutouts. Premultiplied blending commutes with interpolation, so filtering introduces no error[6][7]. It also unifies "over" and additive in one blend state (additive is premultiplied with alpha 0), one reason particle systems often use it. Scope: the correctness win is specifically under filtering/mips/interpolation.

Worked example

A red leaf texel (1, 0, 0) with alpha 1 sits next to a fully transparent texel whose stored color is black, drawn over a white background. A bilinear sample halfway between them gets color (0.5, 0, 0) and alpha 0.5. With straight alpha the blend computes 0.5 × (0.5, 0, 0) + 0.5 × (1, 1, 1) = (0.75, 0.5, 0.5): a dark, dull red. In premultiplied form the texels are (1, 0, 0, 1) and (0, 0, 0, 0), the sample is (0.5, 0, 0, 0.5), and the blend gives 1 × (0.5, 0, 0) + 0.5 × (1, 1, 1) = (1, 0.5, 0.5): the clean half-red-over-white you'd expect. The difference, (0.75, 0.5, 0.5) against (1, 0.5, 0.5), is the dark halo.

Premultiplied-alpha blend state
VkPipelineColorBlendAttachmentState blend{};
blend.blendEnable = VK_TRUE;
blend.srcColorBlendFactor = VK_BLEND_FACTOR_ONE;                  // premultiplied: RGB already × alpha
blend.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
blend.colorBlendOp = VK_BLEND_OP_ADD;
blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
blend.alphaBlendOp = VK_BLEND_OP_ADD;
blend.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
// straight alpha: set srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA instead
let blend = vk::PipelineColorBlendAttachmentState::default()
    .blend_enable(true)
    .src_color_blend_factor(vk::BlendFactor::ONE)                  // premultiplied
    .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
    .color_blend_op(vk::BlendOp::ADD)
    .src_alpha_blend_factor(vk::BlendFactor::ONE)
    .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
    .alpha_blend_op(vk::BlendOp::ADD)
    .color_write_mask(vk::ColorComponentFlags::RGBA);
// straight alpha: .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) instead

08Draw order

A smoke puff drawn before the glass window behind it ends up behind the glass. Opaque sprites can draw in any order with the depth test on, which keeps a fragment only if it's nearer than what's stored; the z-buffer resolves occlusion. Transparent sprites can't: the depth buffer stores one depth per pixel and can't represent "see-through," so blended sprites must be sorted back-to-front (painter's algorithm) and drawn after the opaques, usually with depth test on but depth write off[10].

Sorting fights batching, and can't always win

Back-to-front breaks for intersecting or cyclically overlapping transparents, where no single correct order exists; that's what order-independent transparency (OIT) approximations exist for, as an advanced escape hatch, not a 2D default. And sorting fights batching: sorting purely by depth can force texture switches (batch breaks), so 2D renderers sort within a layer and lean hard on atlases to keep the batch intact. MonoGame's SpriteSortMode exposes exactly this tension[11].

Worked example

Three transparent sprites: smoke at depth 8 (texture A), glass at depth 5 (texture B) and smoke at depth 3 (texture A). Back to front is 8, 5, 3, which is A, B, A: three batches. Sorting by texture would give A, A, B, two batches, but the near smoke would then be drawn before the glass and end up behind it. Put both textures in one atlas and the correct order is a single batch.

09Text rendering

Every HUD needs a score counter and a menu. Text is just the batcher again: a bitmap font is a glyph atlas plus per-glyph metrics, and you draw one quad per glyph sampling its sub-rect, so all glyphs share the font atlas and batch into one draw. Advance (how far to move right after a glyph) and kerning (per-pair adjustments, like pulling the V in "AV" closer) come from the metrics. "SCORE 1200" is 10 glyphs, so 9 quads plus a space that only advances the pen: 36 vertices, 720 bytes, all in the same draw as the rest of the UI if they share the atlas.

Bitmap glyphs blur when scaled past their baked size; the common scalable answer is a signed distance field (SDF), which stores a distance instead of coverage, so a small atlas stays crisp under heavy magnification[12]. Plain SDFs round off sharp corners; MSDF (multi-channel SDF) keeps them by encoding distances in three channels[13]. Both cost a shader and bring their own artifacts. Full text shaping (bidirectional text, ligatures, complex scripts) is a separate problem handled by a shaping library.

Wrong answers, and why: leftover draws are texture-change batch breaks (atlas fixes them, not "GPU-only" batching or instancing); and the edge halo is a straight-alpha-under-filtering artifact that premultiplied alpha fixes, not a depth or filter-quality issue.

10Pitfalls

"Batching speeds up the GPU"It cuts CPU/driver draw-call overhead. The GPU does the same (or less) work.
Draws still high after batchingTexture changes break the batch. Pack into an atlas or array.
Overwriting the in-flight VBThe dynamic vertex buffer must be per-frame-in-flight.
Atlas edge seamsBilinear bleed; add a half-texel inset and a padding gutter.
Dark halo on cutoutsStraight alpha under filtering; use premultiplied alpha.
Transparents show through wrongSort transparent back-to-front; the depth buffer can't sort them.
Pushing MVP as 3 matrices192 B overflows the pre-1.4 push-constant floor of 128 B; push the premultiplied VP.
16k-quad wall16-bit indices cap a draw at 16,384 quads; use 32-bit or split.

11What's next

The renderer can now put thousands of sprites and glyphs on screen in a handful of draws. The 2D rendering track is complete. Next come The Audio Engine and Audio DSP & the FFT, the last stops before the 2D-game capstone wires the loop, renderer, input, audio, collision, and assets into a playable game. The full path is on the series hub.

  1. Matthias Wloka. "Batch, Batch, Batch: What Does It Really Mean?" GDC 2003. nvidia.com. Draw calls are CPU-bound; what matters is batches per frame, not triangles.
  2. Matt DesLauriers. "Sprite Batching" (lwjgl-basics wiki). github.com/mattdesl. Accumulate quad vertices; flush on texture change, buffer full, or end of frame.
  3. Joey de Vries. LearnOpenGL, "Instancing." learnopengl.com. Per-instance attributes and one instanced draw replacing thousands of per-object calls.
  4. Joey de Vries. LearnOpenGL, 2D Game "Final thoughts." learnopengl.com. Batching quads and sprite sheets to cut texture-state switches.
  5. The Khronos Group. Vulkan Specification, "The Framebuffer" (blending). docs.vulkan.org. The blend equation and VkPipelineColorBlendAttachmentState factors.
  6. Inigo Quilez. "Premultiplied alpha." iquilezles.org. Premultiplied blending commutes with scaling/interpolation; straight-alpha error peaks at texel edges.
  7. Eric Haines. "GPUs prefer premultiplication" (Real-Time Rendering blog). realtimerendering.com. The GPU interpolates then blends and can't un-premultiply, so straight alpha fringes under filtering.
  8. CodeAndWeb. TexturePacker "Texture Settings." codeandweb.com. Shape padding, extrude, and alpha-bleed to kill atlas bleeding and dark halos.
  9. The Khronos Group. Vulkan Specification, "Limits" (required limits table). docs.vulkan.org. maxPushConstantsSize: 128 bytes minimum in Vulkan core, 256 in Vulkan 1.4.
  10. Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., transparency chapter. realtimerendering.com. Back-to-front "over" blending and why overlapping transparents resist sorting.
  11. MonoGame. SpriteBatch documentation and source. docs.monogame.net. The canonical deferred batcher and SpriteSortMode (the sort-vs-batch tension).
  12. Chris Green (Valve). "Improved Alpha-Tested Magnification for Vector Textures and Special Effects." SIGGRAPH 2007 course. steamcdn-a.akamaihd.net. Distance-field glyph and decal textures that stay sharp under magnification from a low-resolution texture.
  13. Viktor Chlumský. msdfgen. github.com/Chlumsky/msdfgen. Multi-channel signed distance fields that preserve sharp corners.

See also