Browse Source

First experimental iteration of shelf allocation for font atlases.

Lê Duy Quang 11 months ago
parent
commit
0511149879

+ 4 - 0
Include/RmlUi/Core/FontEngineInterface.h

@@ -55,6 +55,8 @@ public:
 	/// Called when RmlUi is being shut down.
 	virtual void Shutdown();
 
+	virtual void OnBeginFrame();
+
 	/// Called by RmlUi when it wants to load a font face from file.
 	/// @param[in] file_name The file to load the face from.
 	/// @param[in] face_index The index of the font face within a font collection.
@@ -118,6 +120,8 @@ public:
 	virtual int GenerateString(RenderManager& render_manager, FontFaceHandle face_handle, FontEffectsHandle font_effects_handle, StringView string,
 		Vector2f position, ColourbPremultiplied colour, float opacity, const TextShapingContext& text_shaping_context, TexturedMeshList& mesh_list);
 
+	virtual bool EnsureGlyphs(FontFaceHandle face_handle, StringView string);
+
 	/// Called by RmlUi to determine if the text geometry is required to be re-generated. Whenever the returned version
 	/// is changed, all geometry belonging to the given face handle will be re-generated.
 	/// @param[in] face_handle The font handle.

+ 7 - 2
Source/Core/ElementText.cpp

@@ -101,13 +101,14 @@ void ElementText::OnRender()
 		return;
 
 	RenderManager& render_manager = GetContext()->GetRenderManager();
+	FontEngineInterface& font_engine_interface = *GetFontEngineInterface();
 
 	// If our font effects have potentially changed, update it and force a geometry generation if necessary.
 	if (font_effects_dirty && UpdateFontEffects())
 		geometry_dirty = true;
 
 	// Dirty geometry if font version has changed.
-	int new_version = GetFontEngineInterface()->GetVersion(font_face_handle);
+	int new_version = font_engine_interface.GetVersion(font_face_handle);
 	if (new_version != font_handle_version)
 	{
 		font_handle_version = new_version;
@@ -115,7 +116,11 @@ void ElementText::OnRender()
 	}
 
 	// Regenerate the geometry if the colour or font configuration has altered.
-	if (geometry_dirty)
+	bool should_regenerate = geometry_dirty;
+	if (!should_regenerate)
+		for (size_t i = 0; i < lines.size(); ++i)
+			should_regenerate = !font_engine_interface.EnsureGlyphs(font_face_handle, lines[i].text) || should_regenerate;
+	if (should_regenerate)
 		GenerateGeometry(render_manager, font_face_handle);
 
 	// Regenerate text decoration if necessary.

+ 3 - 0
Source/Core/FontEngineDefault/CMakeLists.txt

@@ -17,6 +17,9 @@ target_sources(rmlui_core PRIVATE
 	"${CMAKE_CURRENT_SOURCE_DIR}/FontTypes.h"
 	"${CMAKE_CURRENT_SOURCE_DIR}/FreeTypeInterface.cpp"
 	"${CMAKE_CURRENT_SOURCE_DIR}/FreeTypeInterface.h"
+	"${CMAKE_CURRENT_SOURCE_DIR}/LruList.h"
+	"${CMAKE_CURRENT_SOURCE_DIR}/SpriteSet.cpp"
+	"${CMAKE_CURRENT_SOURCE_DIR}/SpriteSet.h"
 )
 
 target_compile_definitions(rmlui_core PRIVATE "RMLUI_FONT_ENGINE_FREETYPE")

+ 11 - 0
Source/Core/FontEngineDefault/FontEngineInterfaceDefault.cpp

@@ -43,6 +43,11 @@ void FontEngineInterfaceDefault::Shutdown()
 	FontProvider::Shutdown();
 }
 
+void FontEngineInterfaceDefault::OnBeginFrame()
+{
+	FontProvider::OnBeginFrame();
+}
+
 bool FontEngineInterfaceDefault::LoadFontFace(const String& file_name, int face_index, bool fallback_face, Style::FontWeight weight)
 {
 	return FontProvider::LoadFontFace(file_name, face_index, fallback_face, weight);
@@ -88,6 +93,12 @@ int FontEngineInterfaceDefault::GenerateString(RenderManager& render_manager, Fo
 		(int)font_effects_handle);
 }
 
+bool FontEngineInterfaceDefault::EnsureGlyphs(FontFaceHandle handle, StringView string)
+{
+	auto handle_default = reinterpret_cast<FontFaceHandleDefault*>(handle);
+	return handle_default->EnsureGlyphs(string);
+}
+
 int FontEngineInterfaceDefault::GetVersion(FontFaceHandle handle)
 {
 	auto handle_default = reinterpret_cast<FontFaceHandleDefault*>(handle);

+ 4 - 0
Source/Core/FontEngineDefault/FontEngineInterfaceDefault.h

@@ -40,6 +40,8 @@ public:
 	/// Called when RmlUi is being shut down.
 	void Shutdown() override;
 
+	void OnBeginFrame() override;
+
 	/// Adds a new font face to the database. The face's family, style and weight will be determined from the face itself.
 	bool LoadFontFace(const String& file_name, int face_index, bool fallback_face, Style::FontWeight weight) override;
 
@@ -66,6 +68,8 @@ public:
 		Vector2f position, ColourbPremultiplied colour, float opacity, const TextShapingContext& text_shaping_context,
 		TexturedMeshList& mesh_list) override;
 
+	bool EnsureGlyphs(FontFaceHandle face_handle, StringView string) override;
+
 	/// Returns the current version of the font face.
 	int GetVersion(FontFaceHandle handle) override;
 

+ 6 - 0
Source/Core/FontEngineDefault/FontFace.cpp

@@ -85,6 +85,12 @@ FontFaceHandleDefault* FontFace::GetHandle(int size, bool load_default_glyphs)
 	return result;
 }
 
+void FontFace::OnBeginFrame()
+{
+	for (auto iterator = handles.begin(); iterator != handles.end(); ++iterator)
+		iterator->second->PurgeUnusedGlyphs();
+}
+
 void FontFace::ReleaseFontResources()
 {
 	HandleMap().swap(handles);

+ 2 - 0
Source/Core/FontEngineDefault/FontFace.h

@@ -54,6 +54,8 @@ public:
 	/// @return The font handle.
 	FontFaceHandleDefault* GetHandle(int size, bool load_default_glyphs);
 
+	void OnBeginFrame();
+
 	/// Releases resources owned by sized font faces, including their textures and rendered glyphs.
 	void ReleaseFontResources();
 

+ 56 - 4
Source/Core/FontEngineDefault/FontFaceHandleDefault.cpp

@@ -62,6 +62,8 @@ bool FontFaceHandleDefault::Initialize(FontFaceHandleFreetype face, int font_siz
 
 	if (!FreeType::InitialiseFaceHandle(ft_face, font_size, glyphs, metrics, load_default_glyphs))
 		return false;
+	for (auto iterator = glyphs.begin(); iterator != glyphs.end(); ++iterator)
+		new_glyphs.push_back(&*iterator);
 
 	has_kerning = FreeType::HasKerning(ft_face);
 	FillKerningPairCache();
@@ -70,6 +72,8 @@ bool FontFaceHandleDefault::Initialize(FontFaceHandleFreetype face, int font_siz
 	base_layer = GetOrCreateLayer(nullptr);
 	layer_configurations.push_back(LayerConfiguration{base_layer});
 
+	new_glyphs.clear();
+
 	return true;
 }
 
@@ -83,6 +87,21 @@ const FontGlyphMap& FontFaceHandleDefault::GetGlyphs() const
 	return glyphs;
 }
 
+void FontFaceHandleDefault::PurgeUnusedGlyphs() {
+	glyph_lru_list.tick();
+	Vector<Character> purged_characters;
+	while (glyph_lru_list.getLastEntryAge() > 600) {
+		const Character character = *glyph_lru_list.getLast();
+		glyphs.erase(character);
+		glyph_lru_list_handle_map.erase(character);
+		glyph_lru_list.evictLast();
+		purged_characters.push_back(character);
+	}
+	if (!purged_characters.empty())
+		for (auto& pair : layers)
+			pair.layer->RemoveGlyphs(purged_characters);
+}
+
 int FontFaceHandleDefault::GetStringWidth(StringView string, float letter_spacing, Character prior_character)
 {
 	RMLUI_ZoneScoped;
@@ -269,6 +288,29 @@ int FontFaceHandleDefault::GenerateString(RenderManager& render_manager, Texture
 	return Math::Max(line_width, 0);
 }
 
+bool FontFaceHandleDefault::EnsureGlyphs(StringView string)
+{
+	bool all_alive = true;
+	for (auto it_string = StringIteratorU8(string); it_string; ++it_string)
+	{
+		Character character = *it_string;
+		if ((char32_t)character < (char32_t)' ')
+			continue;
+		if (glyphs.find(character) == glyphs.end())
+		{
+			new_characters.push_back(character);
+			all_alive = false;
+		}
+		else
+		{
+			glyph_lru_list.ping(glyph_lru_list_handle_map[character]);
+		}
+	}
+	if (!all_alive)
+		is_layers_dirty = true;
+	return all_alive;
+}
+
 bool FontFaceHandleDefault::UpdateLayersOnDirty()
 {
 	bool result = false;
@@ -282,11 +324,16 @@ bool FontFaceHandleDefault::UpdateLayersOnDirty()
 		// Regenerate all the layers.
 		// Note: The layer regeneration needs to happen in the order in which the layers were created,
 		// otherwise we may end up cloning a layer which has not yet been regenerated. This means trouble!
+		for (const auto character : new_characters)
+			new_glyphs.push_back(&*glyphs.find(character));
+
 		for (auto& pair : layers)
 		{
 			GenerateLayer(pair.layer.get());
 		}
 
+		new_characters.clear();
+		new_glyphs.clear();
 		result = true;
 	}
 
@@ -300,8 +347,9 @@ int FontFaceHandleDefault::GetVersion() const
 
 bool FontFaceHandleDefault::AppendGlyph(Character character)
 {
-	bool result = FreeType::AppendGlyph(ft_face, metrics.size, character, glyphs);
-	return result;
+	if (!FreeType::AppendGlyph(ft_face, metrics.size, character, glyphs))
+		return false;
+	return true;
 }
 
 void FontFaceHandleDefault::FillKerningPairCache()
@@ -377,6 +425,8 @@ const FontGlyph* FontFaceHandleDefault::GetOrAppendGlyph(Character& character, b
 				RMLUI_ERROR;
 				return nullptr;
 			}
+			glyph_lru_list_handle_map.emplace(character, glyph_lru_list.add(character));
+			new_characters.push_back(character);
 
 			is_layers_dirty = true;
 		}
@@ -409,6 +459,8 @@ const FontGlyph* FontFaceHandleDefault::GetOrAppendGlyph(Character& character, b
 				if (it_glyph == glyphs.end())
 					return nullptr;
 			}
+			glyph_lru_list_handle_map.emplace(character, glyph_lru_list.add(character));
+			new_characters.push_back(character);
 		}
 		else
 		{
@@ -448,7 +500,7 @@ bool FontFaceHandleDefault::GenerateLayer(FontFaceLayer* layer)
 
 	if (!font_effect)
 	{
-		result = layer->Generate(this);
+		result = layer->Generate(this, new_glyphs);
 	}
 	else
 	{
@@ -471,7 +523,7 @@ bool FontFaceHandleDefault::GenerateLayer(FontFaceLayer* layer)
 		}
 
 		// Create a new layer.
-		result = layer->Generate(this, clone, clone_glyph_origins);
+		result = layer->Generate(this, new_glyphs, clone, clone_glyph_origins);
 
 		// Cache the layer in the layer cache if it generated its own textures (ie, didn't clone).
 		if (!clone)

+ 11 - 0
Source/Core/FontEngineDefault/FontFaceHandleDefault.h

@@ -35,7 +35,9 @@
 #include "../../../Include/RmlUi/Core/Geometry.h"
 #include "../../../Include/RmlUi/Core/Texture.h"
 #include "../../../Include/RmlUi/Core/Traits.h"
+#include "../../../Include/RmlUi/Core/Types.h"
 #include "FontTypes.h"
+#include "LruList.h"
 
 namespace Rml {
 
@@ -56,6 +58,8 @@ public:
 
 	const FontGlyphMap& GetGlyphs() const;
 
+	void PurgeUnusedGlyphs();
+
 	/// Returns the width a string will take up if rendered with this handle.
 	/// @param[in] string The string to measure.
 	/// @param[in] prior_character The optionally-specified character that immediately precedes the string. This may have an impact on the string
@@ -88,6 +92,8 @@ public:
 	/// @return The width, in pixels, of the string geometry.
 	int GenerateString(RenderManager& render_manager, TexturedMeshList& mesh_list, StringView string, Vector2f position, ColourbPremultiplied colour,
 		float opacity, float letter_spacing, int layer_configuration);
+	
+	bool EnsureGlyphs(StringView string);
 
 	/// Version is changed whenever the layers are dirtied, requiring regeneration of string geometry.
 	int GetVersion() const;
@@ -117,7 +123,12 @@ private:
 	// (Re-)generate a layer in this font face handle.
 	bool GenerateLayer(FontFaceLayer* layer);
 
+	using GlyphLruList = LruList<Character>;
 	FontGlyphMap glyphs;
+	GlyphLruList glyph_lru_list;
+	UnorderedMap<Character, GlyphLruList::Handle> glyph_lru_list_handle_map;
+	Vector<Character> new_characters;
+	Vector<const FontGlyphMap::value_type*> new_glyphs;
 
 	struct EffectLayerPair {
 		const FontEffect* font_effect;

+ 75 - 19
Source/Core/FontEngineDefault/FontFaceLayer.cpp

@@ -29,7 +29,7 @@
 #include "FontFaceLayer.h"
 #include "../../../Include/RmlUi/Core/RenderManager.h"
 #include "FontFaceHandleDefault.h"
-#include <string.h>
+#include <cstring>
 #include <type_traits>
 
 namespace Rml {
@@ -43,20 +43,22 @@ FontFaceLayer::FontFaceLayer(const SharedPtr<const FontEffect>& _effect) : colou
 
 FontFaceLayer::~FontFaceLayer() {}
 
-bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFaceLayer* clone, bool clone_glyph_origins)
+bool FontFaceLayer::Generate(
+	const FontFaceHandleDefault* handle, const Vector<const FontGlyphMap::value_type*> &new_glyphs,
+	const FontFaceLayer* clone, bool clone_glyph_origins
+)
 {
 	// Clear the old layout if it exists.
 	{
 		// @performance: We could be much smarter about this, e.g. such as adding new glyphs to the existing texture layout and textures.
 		// Right now we re-generate the whole thing, including textures.
-		texture_layout = TextureLayout{};
-		character_boxes.clear();
+		//texture_layout = TextureLayout{};
+		//character_boxes.clear();
 		textures_owned.clear();
 		textures_ptr = &textures_owned;
+		//sprite_set = {4, 1024, 0};
 	}
 
-	const FontGlyphMap& glyphs = handle->GetGlyphs();
-
 	// Generate the new layout.
 	if (clone)
 	{
@@ -69,10 +71,10 @@ bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFace
 		// Request the effect (if we have one) and adjust the origins as appropriate.
 		if (effect && !clone_glyph_origins)
 		{
-			for (auto& pair : glyphs)
+			for (const auto entry : new_glyphs)
 			{
-				Character character = pair.first;
-				const FontGlyph& glyph = pair.second;
+				Character character = entry->first;
+				const FontGlyph& glyph = entry->second;
 
 				auto it = character_boxes.find(character);
 				if (it == character_boxes.end())
@@ -97,11 +99,11 @@ bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFace
 	else
 	{
 		// Initialise the texture layout for the glyphs.
-		character_boxes.reserve(glyphs.size());
-		for (auto& pair : glyphs)
+		character_boxes.reserve(character_boxes.size() + new_glyphs.size());
+		for (const auto entry : new_glyphs)
 		{
-			Character character = pair.first;
-			const FontGlyph& glyph = pair.second;
+			Character character = entry->first;
+			const FontGlyph& glyph = entry->second;
 
 			Vector2i glyph_origin(0, 0);
 			Vector2i glyph_dimensions = glyph.bitmap_dimensions;
@@ -119,12 +121,49 @@ bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFace
 
 			RMLUI_ASSERT(box.dimensions.x >= 0 && box.dimensions.y >= 0);
 
-			character_boxes[character] = box;
-
+			/*
 			// Add the character's dimensions into the texture layout engine.
 			texture_layout.AddRectangle((int)character, glyph_dimensions);
+			*/
+
+			SpriteSet::Handle sprite_set_handle;
+			if (effect == nullptr)
+			{
+				if (glyph.color_format == ColorFormat::RGBA8)
+				{
+					sprite_set_handle = sprite_set.add(glyph_dimensions.x, glyph_dimensions.y, glyph.bitmap_data);
+				}
+				else
+				{
+					const int glyph_pixel_count = glyph_dimensions.x * glyph_dimensions.y;
+					Vector<unsigned char> unpacked_bitmap(glyph_pixel_count * 4);
+					for (int i = 0; i < glyph_pixel_count; ++i)
+						for (int c = 0; c < 4; ++c)
+							unpacked_bitmap[i * 4 + c] = glyph.bitmap_data[i];
+					sprite_set_handle = sprite_set.add(glyph_dimensions.x, glyph_dimensions.y, unpacked_bitmap.data());
+				}
+			}
+			else
+			{
+				Vector<unsigned char> processed_bitmap(glyph_dimensions.x * glyph_dimensions.y * 4);
+				effect->GenerateGlyphTexture(processed_bitmap.data(), glyph_dimensions, glyph_dimensions.x * 4, glyph);
+				sprite_set_handle = sprite_set.add(glyph_dimensions.x, glyph_dimensions.y, processed_bitmap.data());
+			}
+			sprite_set_handle_map.emplace(character, sprite_set_handle);
+			const auto sprite_data = sprite_set.get(sprite_set_handle);
+			// Set the character's texture index.
+			box.texture_index = sprite_data.textureId;
+			// Generate the character's texture coordinates.
+			const float texture_size = 1024.f;
+			box.texcoords[0].x = static_cast<float>(sprite_data.x) / texture_size;
+			box.texcoords[0].y = static_cast<float>(sprite_data.y) / texture_size;
+			box.texcoords[1].x = static_cast<float>(sprite_data.x + sprite_data.width) / texture_size;
+			box.texcoords[1].y = static_cast<float>(sprite_data.y + sprite_data.height) / texture_size;
+
+			character_boxes[character] = box;
 		}
 
+		/*
 		constexpr int max_texture_dimensions = 1024;
 
 		// Generate the texture layout; this will position the glyph rectangles efficiently and
@@ -151,15 +190,16 @@ bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFace
 			box.texcoords[1].x = float(rectangle.GetPosition().x + rectangle.GetDimensions().x) / float(texture.GetDimensions().x);
 			box.texcoords[1].y = float(rectangle.GetPosition().y + rectangle.GetDimensions().y) / float(texture.GetDimensions().y);
 		}
+		*/
 
+		sprite_set_textures = sprite_set.getTextures();
 		const FontEffect* effect_ptr = effect.get();
 		const int handle_version = handle->GetVersion();
 
 		// Generate the textures.
-		for (int i = 0; i < texture_layout.GetNumTextures(); ++i)
+		const int texture_count = static_cast<int>(sprite_set_textures.size());
+		for (int texture_id = 0; texture_id < texture_count; ++texture_id)
 		{
-			const int texture_id = i;
-
 			CallbackTextureFunction texture_callback = [handle, effect_ptr, texture_id, handle_version](
 														   const CallbackTextureInterface& texture_interface) -> bool {
 				Vector2i dimensions;
@@ -183,9 +223,14 @@ bool FontFaceLayer::Generate(const FontFaceHandleDefault* handle, const FontFace
 
 bool FontFaceLayer::GenerateTexture(Vector<byte>& texture_data, Vector2i& texture_dimensions, int texture_id, const FontGlyphMap& glyphs)
 {
-	if (texture_id < 0 || texture_id > texture_layout.GetNumTextures())
+	if (texture_id < 0 || texture_id > sprite_set_textures.size())
 		return false;
 
+	const unsigned char *const source = sprite_set_textures[texture_id];
+	texture_data.insert(texture_data.end(), source, source + 1024 * 1024 * 4);
+	texture_dimensions = {1024, 1024};
+
+	/*
 	// Generate the texture data.
 	texture_data = texture_layout.GetTexture(texture_id).AllocateTexture();
 	texture_dimensions = texture_layout.GetTexture(texture_id).GetDimensions();
@@ -245,10 +290,21 @@ bool FontFaceLayer::GenerateTexture(Vector<byte>& texture_data, Vector2i& textur
 			effect->GenerateGlyphTexture(rectangle.GetTextureData(), Vector2i(box.dimensions), rectangle.GetTextureStride(), glyph);
 		}
 	}
+	*/
 
 	return true;
 }
 
+void FontFaceLayer::RemoveGlyphs(const Vector<Character> &characters)
+{
+	for (const auto character : characters)
+	{
+		character_boxes.erase(character);
+		sprite_set.remove(sprite_set_handle_map[character]);
+		sprite_set_handle_map.erase(character);
+	}
+}
+
 const FontEffect* FontFaceLayer::GetFontEffect() const
 {
 	return effect.get();

+ 13 - 2
Source/Core/FontEngineDefault/FontFaceLayer.h

@@ -33,7 +33,9 @@
 #include "../../../Include/RmlUi/Core/FontGlyph.h"
 #include "../../../Include/RmlUi/Core/Geometry.h"
 #include "../../../Include/RmlUi/Core/MeshUtilities.h"
+#include "../../../Include/RmlUi/Core/Types.h"
 #include "../TextureLayout.h"
+#include "SpriteSet.h"
 
 namespace Rml {
 
@@ -57,7 +59,10 @@ public:
 	/// @param[in] clone The layer to optionally clone geometry and texture data from.
 	/// @param[in] clone_glyph_origins True to keep the character origins from the cloned layer, false to generate new ones.
 	/// @return True if the layer was generated successfully, false if not.
-	bool Generate(const FontFaceHandleDefault* handle, const FontFaceLayer* clone = nullptr, bool clone_glyph_origins = false);
+	bool Generate(
+		const FontFaceHandleDefault* handle, const Vector<const FontGlyphMap::value_type*> &newGlyphs,
+		const FontFaceLayer* clone = nullptr, bool clone_glyph_origins = false
+	);
 
 	/// Generates the texture data for a layer (for the texture database).
 	/// @param[out] texture_data The generated texture data.
@@ -88,6 +93,8 @@ public:
 		MeshUtilities::GenerateQuad(mesh, (position + box.origin).Round(), box.dimensions, colour, box.texcoords[0], box.texcoords[1]);
 	}
 
+	void RemoveGlyphs(const Vector<Character> &characters);
+
 	/// Returns the effect used to generate the layer.
 	const FontEffect* GetFontEffect() const;
 
@@ -120,9 +127,13 @@ private:
 	TextureList textures_owned;
 	TextureList* textures_ptr = &textures_owned;
 
-	TextureLayout texture_layout;
+	//TextureLayout texture_layout;
 	CharacterMap character_boxes;
 	Colourb colour;
+
+	SpriteSet sprite_set{4, 1024, 1};
+	UnorderedMap<Character, SpriteSet::Handle> sprite_set_handle_map;
+	Vector<const unsigned char*> sprite_set_textures;
 };
 
 } // namespace Rml

+ 6 - 0
Source/Core/FontEngineDefault/FontFamily.cpp

@@ -86,6 +86,12 @@ FontFace* FontFamily::AddFace(FontFaceHandleFreetype ft_face, Style::FontStyle s
 	return result;
 }
 
+void FontFamily::OnBeginFrame()
+{
+	for (auto &font_face : font_faces)
+		font_face.face->OnBeginFrame();
+}
+
 void FontFamily::ReleaseFontResources()
 {
 	for (auto& entry : font_faces)

+ 2 - 0
Source/Core/FontEngineDefault/FontFamily.h

@@ -60,6 +60,8 @@ public:
 	/// @return True if the face was loaded successfully, false otherwise.
 	FontFace* AddFace(FontFaceHandleFreetype ft_face, Style::FontStyle style, Style::FontWeight weight, UniquePtr<byte[]> face_memory);
 
+	void OnBeginFrame();
+
 	/// Releases resources owned by sized font faces, including their textures and rendered glyphs.
 	void ReleaseFontResources();
 

+ 7 - 0
Source/Core/FontEngineDefault/FontProvider.cpp

@@ -69,6 +69,13 @@ void FontProvider::Shutdown()
 	FreeType::Shutdown();
 }
 
+void FontProvider::OnBeginFrame()
+{
+	auto &font_families = Get().font_families;
+	for (auto iterator = font_families.begin(); iterator != font_families.end(); ++iterator)
+		iterator->second->OnBeginFrame();
+}
+
 FontProvider& FontProvider::Get()
 {
 	RMLUI_ASSERT(g_font_provider);

+ 1 - 0
Source/Core/FontEngineDefault/FontProvider.h

@@ -48,6 +48,7 @@ class FontProvider {
 public:
 	static bool Initialise();
 	static void Shutdown();
+	static void OnBeginFrame();
 
 	/// Returns a handle to a font face that can be used to position and render text. This will return the closest match
 	/// it can find, but in the event a font family is requested that does not exist, nullptr will be returned instead of a

+ 173 - 0
Source/Core/FontEngineDefault/LruList.h

@@ -0,0 +1,173 @@
+#ifndef GRAPHICS_LRULIST_H
+#define GRAPHICS_LRULIST_H
+
+#include <cstdlib>
+#include <vector>
+
+/*
+	Implementation details:
+	- The first entry has the previous index pointing to itself to simplify eviction of the last entry.
+	- Invalidation is simply done by incrementing the entry's epoch. This is trivially safe.
+*/
+
+template<typename T>
+class LruList final {
+	private:
+		struct Entry {
+			unsigned previousIndex, nextIndex;
+			unsigned epoch, lastUsed;
+			T data;
+		};
+
+		std::vector<Entry> pool{1 << 10};
+		std::size_t size = 0;
+		unsigned headIndex = 0, tailIndex = 0;
+		unsigned currentEpoch = 0;
+	public:
+		struct Handle {
+			unsigned index;
+			unsigned epoch;
+		};
+
+		LruList();
+		void tick();
+		Handle add(T data);
+		bool isAlive(Handle handle);
+		bool ping(Handle handle);
+		void remove(Handle handle);
+		void evictLast();
+		T* getData(Handle handle);
+		const T* getData(Handle handle) const;
+		T* getLast();
+		const T* getLast() const;
+		unsigned getLastEntryAge() const;
+};
+
+template<typename T>
+LruList<T>::LruList() {
+	const unsigned poolSize = static_cast<unsigned>(pool.size());
+	for (unsigned i = 0; i != poolSize; ++i) pool[i].nextIndex = i + 1;
+}
+
+template<typename T>
+void LruList<T>::tick() {
+	++currentEpoch;
+}
+
+template<typename T>
+typename LruList<T>::Handle LruList<T>::add(const T data) {
+	unsigned poolSize = static_cast<unsigned>(pool.size());
+	if (size == poolSize) {
+		poolSize <<= 1;
+		pool.resize(poolSize);
+		for (unsigned i = static_cast<unsigned>(size); i != poolSize; ++i) pool[i].nextIndex = i + 1;
+		pool[tailIndex].nextIndex = static_cast<unsigned>(size);
+	}
+
+	if (size == 0) {
+		Entry &newEntry = pool[headIndex];
+		newEntry.previousIndex = headIndex;
+		newEntry.epoch = currentEpoch;
+		newEntry.lastUsed = currentEpoch;
+		newEntry.data = data;
+		size = 1;
+		// No need to update head and tail indices here, they are already correct.
+		return {headIndex, currentEpoch};
+	}
+
+	Entry &tail = pool[tailIndex];
+	const unsigned newIndex = tail.nextIndex;
+	Entry &newEntry = pool[newIndex];
+	tail.nextIndex = newEntry.nextIndex;
+	newEntry = {newIndex, headIndex, currentEpoch, currentEpoch, data};
+	pool[headIndex].previousIndex = newIndex;
+	headIndex = newIndex;
+	++size;
+	return {newIndex, currentEpoch};
+}
+
+template<typename T>
+bool LruList<T>::isAlive(const Handle handle) {
+	return pool[handle.index] == handle.epoch;
+}
+
+template<typename T>
+bool LruList<T>::ping(const Handle handle) {
+	Entry &entry = pool[handle.index];
+	if (entry.epoch != handle.epoch) return false;
+	if (entry.lastUsed == currentEpoch) return true;
+	entry.lastUsed = currentEpoch;
+	if (handle.index == headIndex) return true;
+	pool[entry.previousIndex].nextIndex = entry.nextIndex;
+	if (handle.index == tailIndex) tailIndex = entry.previousIndex;
+	else pool[entry.nextIndex].previousIndex = entry.previousIndex;
+	pool[headIndex].previousIndex = handle.index;
+	entry.previousIndex = handle.index;
+	entry.nextIndex = headIndex;
+	headIndex = handle.index;
+	return true;
+}
+
+template<typename T>
+void LruList<T>::remove(const Handle handle) {
+	Entry &entry = pool[handle.index];
+	if (entry.epoch != handle.epoch) return;
+	++entry.epoch;
+	if (size == 1) {
+		size = 0;
+		return;
+	}
+	if (handle.index == headIndex) {
+		Entry &tail = pool[tailIndex];
+		entry.nextIndex = tail.nextIndex;
+		tail.nextIndex = headIndex;
+		headIndex = entry.nextIndex;
+	} else if (handle.index == tailIndex) {
+		tailIndex = entry.previousIndex;
+	} else {
+		pool[entry.previousIndex].nextIndex = entry.nextIndex;
+		pool[entry.nextIndex].previousIndex = entry.previousIndex;
+		Entry &tail = pool[tailIndex];
+		entry.nextIndex = tail.nextIndex;
+		tail.nextIndex = handle.index;
+	}
+	--size;
+}
+
+template<typename T>
+void LruList<T>::evictLast() {
+	if (size == 0) return;
+	Entry &entry = pool[tailIndex];
+	++entry.epoch;
+	tailIndex = entry.previousIndex;
+	--size;
+}
+
+template<typename T>
+T* LruList<T>::getData(const Handle handle) {
+	Entry &entry = pool[handle.index];
+	return entry.epoch == handle.epoch ? &entry.data : nullptr;
+}
+
+template<typename T>
+const T* LruList<T>::getData(const Handle handle) const {
+	Entry &entry = pool[handle.index];
+	return entry.epoch == handle.epoch ? &entry.data : nullptr;
+}
+
+template<typename T>
+T* LruList<T>::getLast() {
+	return size == 0 ? nullptr : &pool[tailIndex].data;
+}
+
+template<typename T>
+const T* LruList<T>::getLast() const {
+	return size == 0 ? nullptr : &pool[tailIndex].data;
+}
+
+template<typename T>
+unsigned LruList<T>::getLastEntryAge() const {
+	return size == 0 ? 0 : currentEpoch - pool[tailIndex].lastUsed;
+}
+
+#endif // GRAPHICS_LRULIST_H

+ 396 - 0
Source/Core/FontEngineDefault/SpriteSet.cpp

@@ -0,0 +1,396 @@
+#include <algorithm>
+
+#include "SpriteSet.h"
+
+namespace {
+	constexpr unsigned nullIndex = -1;
+	constexpr unsigned maxChangedPixels = 256 * 256;
+	constexpr unsigned splitThreshold = 8;
+
+	template<typename T>
+	void initializePool(std::vector<T> &pool) {
+		const unsigned
+			poolSize = static_cast<unsigned>(pool.size()),
+			lastPoolIndex = poolSize - 1;
+		for (unsigned i = 0; i != lastPoolIndex; ++i) pool[i].nextIndex = i + 1;
+		pool[lastPoolIndex].nextIndex = nullIndex;
+	}
+
+	template<typename T>
+	unsigned allocateEntry(std::vector<T> &pool, unsigned &nextFreeIndex) {
+		if (nextFreeIndex == nullIndex) {
+			const unsigned
+				oldPoolSize = static_cast<unsigned>(pool.size()),
+				newPoolSize = oldPoolSize << 1,
+				lastPoolIndex = newPoolSize - 1;
+			pool.resize(newPoolSize);
+			for (unsigned i = oldPoolSize; i != lastPoolIndex; ++i) pool[i].nextIndex = i + 1;
+			pool[lastPoolIndex].nextIndex = nullIndex;
+			nextFreeIndex = oldPoolSize;
+		}
+		const unsigned index = nextFreeIndex;
+		nextFreeIndex = pool[index].nextIndex;
+		return index;
+	}
+
+	template<typename T>
+	void freeEntry(std::vector<T> &pool, unsigned &nextFreeIndex, const unsigned index) {
+		pool[index].nextIndex = nextFreeIndex;
+		nextFreeIndex = index;
+	}
+}
+
+SpriteSet::SpriteSet(const unsigned bytesPerPixel, const unsigned pageSize, const unsigned spritePadding):
+	bytesPerPixel(bytesPerPixel), pageSize(pageSize), spritePadding(spritePadding)
+{
+	initializePool(pagePool);
+	initializePool(shelfPool);
+	initializePool(slotPool);
+}
+
+void SpriteSet::tick() {
+	unsigned changedPixels = 0;
+	// Try compaction by moving sprites from the last page to the first page.
+	while (changedPixels <= maxChangedPixels && firstPageIndex != lastPageIndex) {
+		const Page &sourcePage = pagePool[lastPageIndex];
+		unsigned sourceShelfIndex = sourcePage.firstShelfIndex;
+		while (!shelfPool[sourceShelfIndex].allocated) sourceShelfIndex = shelfPool[sourceShelfIndex].nextIndex;
+		const Shelf &sourceShelf = shelfPool[sourceShelfIndex];
+		unsigned sourceSlotIndex = sourceShelf.firstSlotIndex;
+		while (!slotPool[sourceSlotIndex].allocated) sourceSlotIndex = slotPool[sourceSlotIndex].nextIndex;
+		const Slot &sourceSlot = slotPool[sourceSlotIndex];
+		const unsigned destinationSlotIndex = tryAllocateInPage(firstPageIndex, sourceSlot.width, sourceSlot.height);
+		if (destinationSlotIndex == nullIndex) break;
+		const Slot &destinationSlot = slotPool[destinationSlotIndex];
+		const Shelf &destinationShelf = shelfPool[destinationSlot.shelfIndex];
+		Page &destinationPage = pagePool[firstPageIndex];
+		const unsigned
+			sourceX = sourceSlot.x, sourceY = sourceShelf.y,
+			destinationX = destinationSlot.x, destinationY = destinationShelf.y,
+			width = sourceSlot.width, height = sourceSlot.height;
+		for (unsigned localY = 0; localY != height; ++localY) {
+			const auto dataStart = sourcePage.textureData->begin() + ((sourceY + localY) * pageSize + sourceX);
+			std::copy(
+				dataStart, dataStart + width,
+				destinationPage.textureData->begin() + ((destinationY + localY) * pageSize + destinationX)
+			);
+		}
+		destinationPage.firstDirtyY = std::min(destinationPage.firstDirtyY, destinationY);
+		destinationPage.pastLastDirtyY = std::max(destinationPage.pastLastDirtyY, destinationY + height);
+		destinationPage.firstDirtyX = std::min(destinationPage.firstDirtyX, destinationX);
+		destinationPage.pastLastDirtyX = std::max(destinationPage.pastLastDirtyX, destinationX + width);
+		changedPixels += remove(sourceSlotIndex);
+		if (migrationCallback) migrationCallback(sourceSlotIndex, {destinationSlotIndex, destinationSlot.epoch});
+	}
+}
+
+SpriteSet::Handle SpriteSet::add(
+	const unsigned width, const unsigned height, const unsigned char *const data, const unsigned rowStride
+) {
+	const unsigned paddedWidth = width + spritePadding * 2, paddedHeight = height + spritePadding * 2;
+	const unsigned slotIndex = allocate(paddedWidth, paddedHeight);
+	const Slot &slot = slotPool[slotIndex];
+	const Shelf &shelf = shelfPool[slot.shelfIndex];
+	Page &page = pagePool[shelf.pageIndex];
+	for (
+		unsigned i = 0, topPaddingY = shelf.y, bottomPaddingY = shelf.y + paddedHeight - 1;
+		i != spritePadding; ++i, ++topPaddingY, --bottomPaddingY
+	) {
+		auto textureStart = page.textureData->begin() + (topPaddingY * pageSize + slot.x) * bytesPerPixel;
+		std::fill(textureStart, textureStart + paddedWidth * bytesPerPixel, 0);
+		textureStart = page.textureData->begin() + (bottomPaddingY * pageSize + slot.x) * bytesPerPixel;
+		std::fill(textureStart, textureStart + paddedWidth * bytesPerPixel, 0);
+	}
+	const unsigned textureY = shelf.y + spritePadding;
+	for (unsigned localY = 0; localY != height; ++localY) {
+		const unsigned char *const dataStart = data + localY * rowStride * bytesPerPixel;
+		const auto textureStart = page.textureData->begin() + ((textureY + localY) * pageSize + slot.x) * bytesPerPixel;
+		std::fill(textureStart, textureStart + spritePadding * bytesPerPixel, 0);
+		std::fill(
+			textureStart + (spritePadding + width) * bytesPerPixel, textureStart + paddedWidth * bytesPerPixel, 0
+		);
+		std::copy(dataStart, dataStart + width * bytesPerPixel, textureStart + spritePadding * bytesPerPixel);
+	}
+	page.firstDirtyY = std::min(page.firstDirtyY, shelf.y);
+	page.pastLastDirtyY = std::max(page.pastLastDirtyY, shelf.y + paddedHeight);
+	page.firstDirtyX = std::min(page.firstDirtyX, slot.x);
+	page.pastLastDirtyX = std::max(page.pastLastDirtyX, slot.x + paddedWidth);
+	return {slotIndex, slot.epoch};
+}
+
+unsigned SpriteSet::allocate(const unsigned width, const unsigned height) {
+	// Try to allocate in an existing page.
+	if (firstPageIndex != nullIndex) {
+		unsigned pageIndex = firstPageIndex;
+		while (true) {
+			const unsigned slotIndex = tryAllocateInPage(pageIndex, width, height);
+			if (slotIndex != nullIndex) return slotIndex;
+			if (pageIndex == lastPageIndex) break;
+			pageIndex = pagePool[pageIndex].nextIndex;
+		}
+	}
+
+	// No page could fit, allocate a new page.
+	if (firstPageIndex == nullIndex) {
+		firstPageIndex = lastPageIndex;
+		Page &page = pagePool[lastPageIndex];
+		page.previousIndex = nullIndex;
+	} else {
+		unsigned pageIndex = pagePool[lastPageIndex].nextIndex;
+		if (pageIndex == nullIndex) {
+			const unsigned
+				oldPoolSize = static_cast<unsigned>(pagePool.size()),
+				newPoolSize = oldPoolSize << 1,
+				lastPoolIndex = newPoolSize - 1;
+			pagePool.resize(newPoolSize);
+			for (unsigned i = oldPoolSize; i != lastPoolIndex; ++i) pagePool[i].nextIndex = i + 1;
+			pagePool[lastPoolIndex].nextIndex = nullIndex;
+			pagePool[lastPageIndex].nextIndex = oldPoolSize;
+			pageIndex = oldPoolSize;
+		}
+		pagePool[pageIndex].previousIndex = lastPageIndex;
+		lastPageIndex = pageIndex;
+	}
+
+	Page &page = pagePool[lastPageIndex];
+	page.textureId = pageCount;
+	page.textureData = std::make_unique<std::vector<unsigned char>>(pageSize * pageSize * bytesPerPixel);
+	page.firstDirtyY = pageSize;
+	page.pastLastDirtyY = 0;
+	page.firstDirtyX = pageSize;
+	page.pastLastDirtyX = 0;
+
+	const unsigned shelfIndex = allocateEntry<Shelf>(shelfPool, nextFreeShelfIndex);
+	const unsigned slotIndex = allocateEntry<Slot>(slotPool, nextFreeSlotIndex);
+	slotPool[slotIndex] = {shelfIndex, pageCount, 0, 0, pageSize, 0, 0, nullIndex, nullIndex, nullIndex, nullIndex, 0, false};
+	shelfPool[shelfIndex] = {lastPageIndex, 0, pageSize, nullIndex, nullIndex, slotIndex, slotIndex, false};
+	page.firstShelfIndex = shelfIndex;
+	++pageCount;
+	return tryAllocateInPage(lastPageIndex, width, height);
+}
+
+unsigned SpriteSet::tryAllocateInPage(const unsigned pageIndex, const unsigned width, const unsigned height) {
+	Page &page = pagePool[pageIndex];
+	unsigned selectedShelfIndex = nullIndex, selectedSlotIndex = nullIndex, selectedShelfHeight = -1;
+	for (
+		unsigned shelfIndex = page.firstShelfIndex;
+		shelfIndex != nullIndex; shelfIndex = shelfPool[shelfIndex].nextIndex
+	) {
+		const Shelf &shelf = shelfPool[shelfIndex];
+		if (
+			shelf.height < height || shelf.height >= selectedShelfHeight
+			|| (shelf.allocated && shelf.height > height * 3 / 2)
+		) continue;
+		bool found = false;
+		for (
+			unsigned slotIndex = shelf.firstFreeSlotIndex;
+			slotIndex != nullIndex; slotIndex = slotPool[slotIndex].nextFreeIndex
+		) {
+			const Slot &slot = slotPool[slotIndex];
+			if (slot.width < width) continue;
+			selectedShelfIndex = shelfIndex;
+			selectedSlotIndex = slotIndex;
+			selectedShelfHeight = shelf.height;
+			found = true;
+			break;
+		}
+		if (found && shelf.height == height) break;
+	}
+	if (selectedSlotIndex == nullIndex) return nullIndex;
+	Shelf *shelf = &shelfPool[selectedShelfIndex];
+	if (!shelf->allocated) {
+		shelf->allocated = true;
+		if (shelf->height - height >= splitThreshold) {
+			const unsigned newShelfIndex = allocateEntry<Shelf>(shelfPool, nextFreeShelfIndex);
+			const unsigned newSlotIndex = allocateEntry<Slot>(slotPool, nextFreeSlotIndex);
+			shelf = &shelfPool[selectedShelfIndex];
+			slotPool[newSlotIndex] = {
+				newShelfIndex, page.textureId, 0, shelf->y + height, pageSize, 0, 0, nullIndex, nullIndex, nullIndex, nullIndex, 0, false
+			};
+			shelfPool[newShelfIndex] = {
+				pageIndex, shelf->y + height, shelf->height - height,
+				selectedShelfIndex, shelf->nextIndex,  newSlotIndex, newSlotIndex, false
+			};
+			if (shelf->nextIndex != nullIndex) shelfPool[shelf->nextIndex].previousIndex = newShelfIndex;
+			shelf->nextIndex = newShelfIndex;
+			shelf->height = height;
+		}
+	}
+	Slot *slot = &slotPool[selectedSlotIndex];
+	if (slot->width - width >= splitThreshold) {
+		const unsigned newSlotIndex = allocateEntry<Slot>(slotPool, nextFreeSlotIndex);
+		slot = &slotPool[selectedSlotIndex];
+		slotPool[newSlotIndex] = {
+			selectedShelfIndex, page.textureId, slot->x + width, shelf->y, slot->width - width, 0, 0,
+			selectedSlotIndex, slot->nextIndex, slot->previousFreeIndex, slot->nextFreeIndex, 0, false
+		};
+		if (slot->nextIndex != nullIndex) slotPool[slot->nextIndex].previousIndex = newSlotIndex;
+		slot->nextIndex = newSlotIndex;
+		if (slot->previousFreeIndex == nullIndex) shelf->firstFreeSlotIndex = newSlotIndex;
+		else slotPool[slot->previousFreeIndex].nextFreeIndex = newSlotIndex;
+		if (slot->nextFreeIndex != nullIndex) slotPool[slot->nextFreeIndex].previousFreeIndex = newSlotIndex;
+		slot->width = width;
+	} else {
+		if (slot->previousFreeIndex == nullIndex) shelf->firstFreeSlotIndex = slot->nextFreeIndex;
+		else slotPool[slot->previousFreeIndex].nextFreeIndex = slot->nextFreeIndex;
+		if (slot->nextFreeIndex != nullIndex) slotPool[slot->nextFreeIndex].previousFreeIndex = slot->previousFreeIndex;
+	}
+	slot->allocated = true;
+	slot->actualWidth = width;
+	slot->height = height;
+	slot->epoch = currentEpoch;
+	if (pageIndex == firstPageIndex) firstPageAllocatedPixels += width * shelf->height;
+	return selectedSlotIndex;
+}
+
+unsigned SpriteSet::remove(const unsigned slotIndex) {
+	Slot &slot = slotPool[slotIndex];
+	Shelf &shelf = shelfPool[slot.shelfIndex];
+	const unsigned pageIndex = shelf.pageIndex;
+	Page &page = pagePool[pageIndex];
+
+	for (int offsetY = 0; offsetY < slot.height; ++offsetY) {
+		for (int offsetX = 0; offsetX < slot.width; ++offsetX) {
+			const auto pixel = page.textureData->begin() + ((slot.y + offsetY) * pageSize + slot.x + offsetX) * 4;
+			pixel[0] = 0;
+			pixel[1] = 0;
+			pixel[2] = 255;
+			pixel[3] = 255;
+		}
+	}
+
+	const unsigned slotPixels = slot.width * slot.height;
+	slot.allocated = false;
+	slot.previousFreeIndex = nullIndex;
+	if (shelf.firstFreeSlotIndex == nullIndex) {
+		slot.nextFreeIndex = nullIndex;
+	} else {
+		slot.nextFreeIndex = shelf.firstFreeSlotIndex;
+		slotPool[shelf.firstFreeSlotIndex].previousFreeIndex = slotIndex;
+	}
+	shelf.firstFreeSlotIndex = slotIndex;
+	++slot.epoch;
+
+	// Merge consecutive empty slots.
+	if (slot.nextIndex != nullIndex) {
+		Slot &nextSlot = slotPool[slot.nextIndex];
+		if (!nextSlot.allocated) {
+			slot.width += nextSlot.width;
+			const unsigned nextIndex = slot.nextIndex;
+			slot.nextIndex = nextSlot.nextIndex;
+			if (nextSlot.previousFreeIndex != nullIndex)
+				slotPool[nextSlot.previousFreeIndex].nextFreeIndex = nextSlot.nextFreeIndex;
+			if (nextSlot.nextFreeIndex != nullIndex)
+				slotPool[nextSlot.nextFreeIndex].previousFreeIndex = nextSlot.previousFreeIndex;
+			freeEntry<Slot>(slotPool, nextFreeSlotIndex, nextIndex);
+			if (slot.nextIndex != nullIndex) slotPool[slot.nextIndex].previousIndex = slotIndex;
+		}
+	}
+	if (slot.previousIndex != nullIndex) {
+		Slot &previousSlot = slotPool[slot.previousIndex];
+		if (!previousSlot.allocated) {
+			slot.x -= previousSlot.width;
+			slot.width += previousSlot.width;
+			const unsigned previousIndex = slot.previousIndex;
+			slot.previousIndex = previousSlot.previousIndex;
+			if (previousSlot.previousFreeIndex != nullIndex)
+				slotPool[previousSlot.previousFreeIndex].nextFreeIndex = previousSlot.nextFreeIndex;
+			if (previousSlot.nextFreeIndex != nullIndex)
+				slotPool[previousSlot.nextFreeIndex].previousFreeIndex = previousSlot.previousFreeIndex;
+			freeEntry<Slot>(slotPool, nextFreeSlotIndex, previousIndex);
+			if (slot.previousIndex == nullIndex) {
+				shelf.firstSlotIndex = slotIndex;
+				if (slot.nextIndex == nullIndex) shelf.allocated = false;
+			} else {
+				slotPool[slot.previousIndex].nextIndex = slotIndex;
+			}
+		}
+	}
+
+	// Merge consecutive empty shelves.
+	if (shelf.allocated) return slotPixels;
+	if (shelf.nextIndex != nullIndex) {
+		Shelf &nextShelf = shelfPool[shelf.nextIndex];
+		if (!nextShelf.allocated) {
+			shelf.height += nextShelf.height;
+			const unsigned nextIndex = shelf.nextIndex;
+			shelf.nextIndex = nextShelf.nextIndex;
+			freeEntry<Slot>(slotPool, nextFreeSlotIndex, nextShelf.firstSlotIndex);
+			freeEntry<Shelf>(shelfPool, nextFreeShelfIndex, nextIndex);
+			if (shelf.nextIndex != nullIndex) shelfPool[shelf.nextIndex].previousIndex = slot.shelfIndex;
+		}
+	}
+	if (shelf.previousIndex != nullIndex) {
+		Shelf &previousShelf = shelfPool[shelf.previousIndex];
+		if (!previousShelf.allocated) {
+			shelf.y -= previousShelf.height;
+			shelf.height += previousShelf.height;
+			slot.y = shelf.y;
+			const unsigned previousIndex = shelf.previousIndex;
+			shelf.previousIndex = previousShelf.previousIndex;
+			freeEntry<Slot>(slotPool, nextFreeSlotIndex, previousShelf.firstSlotIndex);
+			freeEntry<Shelf>(shelfPool, nextFreeShelfIndex, previousIndex);
+			if (shelf.previousIndex == nullIndex) page.firstShelfIndex = slot.shelfIndex;
+			else shelfPool[shelf.previousIndex].nextIndex = slot.shelfIndex;
+		}
+	}
+
+	// Deallocate the page if it becomes empty, except when it's the first one.
+	if (pageIndex == firstPageIndex) {
+		firstPageAllocatedPixels -= slot.width * shelf.height;
+		return slotPixels;
+	}
+	if (shelf.height != pageSize) return slotPixels;
+	freeEntry<Slot>(slotPool, nextFreeSlotIndex, slotIndex);
+	freeEntry<Shelf>(shelfPool, nextFreeShelfIndex, page.firstShelfIndex);
+	page.textureData.reset();
+	pagePool[page.previousIndex].nextIndex = page.nextIndex;
+	if (pageIndex != lastPageIndex) pagePool[page.nextIndex].previousIndex = page.previousIndex;
+	pagePool[lastPageIndex].nextIndex = pageIndex;
+	return slotPixels;
+}
+
+void SpriteSet::remove(const Handle handle) {
+	Slot &slot = slotPool[handle.slotIndex];
+	if (slot.epoch != handle.epoch) return;
+	remove(handle.slotIndex);
+}
+
+SpriteSet::SpriteData SpriteSet::get(const Handle handle) const {
+	const Slot &slot = slotPool[handle.slotIndex];
+	return {
+		slot.textureId, slot.x + spritePadding, slot.y + spritePadding,
+		slot.actualWidth - spritePadding * 2, slot.height - spritePadding * 2
+	};
+}
+
+std::vector<const unsigned char*> SpriteSet::getTextures() const {
+	if (firstPageIndex == nullIndex)
+		return {};
+	std::vector<const unsigned char*> textures;
+	unsigned pageIndex = firstPageIndex;
+	while (true) {
+		const Page &page = pagePool[pageIndex];
+		textures.push_back(page.textureData->data());
+		if (pageIndex == lastPageIndex) break;
+		pageIndex = page.nextIndex;
+	}
+	return textures;
+}
+
+void SpriteSet::dump(std::vector<SpriteSet::SpriteData> &sprites) const {
+	if (firstPageIndex == nullIndex) return;
+	for (
+		unsigned shelfIndex = pagePool[firstPageIndex].firstShelfIndex;
+		shelfIndex != nullIndex; shelfIndex = shelfPool[shelfIndex].nextIndex
+	) {
+		const unsigned shelfY = shelfPool[shelfIndex].y;
+		for (
+			unsigned slotIndex = shelfPool[shelfIndex].firstSlotIndex;
+			slotIndex != nullIndex; slotIndex = slotPool[slotIndex].nextIndex
+		) {
+			const Slot &slot = slotPool[slotIndex];
+			if (slot.allocated) sprites.push_back({slot.x, shelfY, slot.width, slot.height});
+		}
+	}
+}

+ 71 - 0
Source/Core/FontEngineDefault/SpriteSet.h

@@ -0,0 +1,71 @@
+#ifndef GRAPHICS_SPRITESET_H
+#define GRAPHICS_SPRITESET_H
+
+#include <cstdlib>
+#include <functional>
+#include <vector>
+
+class SpriteSet final {
+	private:
+		struct Page {
+			unsigned previousIndex, nextIndex;
+			unsigned firstShelfIndex;
+			unsigned textureId;
+			unsigned firstDirtyY, pastLastDirtyY, firstDirtyX, pastLastDirtyX;
+			std::unique_ptr<std::vector<unsigned char>> textureData;
+		};
+		struct Shelf {
+			unsigned pageIndex;
+			unsigned y, height;
+			unsigned previousIndex, nextIndex;
+			unsigned firstSlotIndex, firstFreeSlotIndex;
+			bool allocated;
+		};
+		struct Slot {
+			unsigned shelfIndex;
+			unsigned textureId;
+			unsigned x, y, width, height, actualWidth;
+			unsigned previousIndex, nextIndex;
+			unsigned previousFreeIndex, nextFreeIndex;
+			unsigned epoch;
+			bool allocated;
+		};
+
+		unsigned bytesPerPixel, pageSize, spritePadding;
+		std::vector<Page> pagePool{1 << 3};
+		std::vector<Shelf> shelfPool{1 << 8};
+		std::vector<Slot> slotPool{1 << 10};
+		unsigned
+			pageCount = 0, firstPageIndex = -1, lastPageIndex = 0,
+			nextFreeShelfIndex = 0, nextFreeSlotIndex = 0;
+		unsigned firstPageAllocatedPixels = 0;
+		unsigned currentEpoch = 0;
+
+		unsigned allocate(unsigned width, unsigned height);
+		unsigned tryAllocateInPage(unsigned pageIndex, unsigned width, unsigned height);
+		unsigned remove(unsigned slotIndex);
+	public:
+		struct Handle {
+			unsigned slotIndex;
+			unsigned epoch;
+		};
+		struct SpriteData {
+			unsigned textureId, x, y, width, height;
+		};
+
+		std::function<void(unsigned)> removalCallback;
+		std::function<void(unsigned, Handle)> migrationCallback;
+
+		SpriteSet(unsigned bytesPerPixel, unsigned pageSize, unsigned spritePadding);
+		void tick();
+		Handle add(const unsigned width, const unsigned height, const unsigned char *const data) {
+			return add(width, height, data, width);
+		}
+		Handle add(unsigned width, unsigned height, const unsigned char *data, unsigned rowStride);
+		void remove(Handle handle);
+		SpriteData get(Handle handle) const;
+		std::vector<const unsigned char*> getTextures() const;
+		void dump(std::vector<SpriteData> &sprites) const;
+};
+
+#endif // GRAPHICS_SPRITESET_H

+ 7 - 0
Source/Core/FontEngineInterface.cpp

@@ -39,6 +39,8 @@ void FontEngineInterface::Initialize() {}
 
 void FontEngineInterface::Shutdown() {}
 
+void FontEngineInterface::OnBeginFrame() {}
+
 bool FontEngineInterface::LoadFontFace(const String& /*file_path*/, int /*face_index*/, bool /*fallback_face*/, Style::FontWeight /*weight*/)
 {
 	return false;
@@ -80,6 +82,11 @@ int FontEngineInterface::GenerateString(RenderManager& /*render_manager*/, FontF
 	return 0;
 }
 
+bool FontEngineInterface::EnsureGlyphs(FontFaceHandle /*handle*/, StringView /*string*/)
+{
+	return true;
+}
+
 int FontEngineInterface::GetVersion(FontFaceHandle /*handle*/)
 {
 	return 0;