text/msdf/atlas.go
Functions
func AtlasCount
func (m *AtlasManager) AtlasCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.atlases)
}
func AtlasInfos
AtlasInfos returns information about all atlases.
func (m *AtlasManager) AtlasInfos() []AtlasInfo {
m.mu.RLock()
defer m.mu.RUnlock()
infos := make([]AtlasInfo, len(m.atlases))
for i, atlas := range m.atlases {
infos[i] = AtlasInfo{
Index: i,
GlyphCount: len(atlas.regions),
Utilization: atlas.Utilization(),
Dirty: atlas.dirty,
MemoryBytes: len(atlas.Data),
}
}
return infos
}
func Clear
Clear removes all cached glyphs and resets all atlases.
func (m *AtlasManager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.atlases = m.atlases[:0]
m.lookup = make(map[GlyphKey]Region)
m.hits.Store(0)
m.misses.Store(0)
}
func Clear
Clear clears all shards.
func (c *ConcurrentAtlasManager) Clear() {
for _, shard := range c.shards {
shard.Clear()
}
}
func Compact
Compact removes all atlases that have no glyphs.
This reclaims memory from cleared atlases.
func (m *AtlasManager) Compact() int {
m.mu.Lock()
defer m.mu.Unlock()
removed := 0
newAtlases := make([]*Atlas, 0, len(m.atlases))
for _, atlas := range m.atlases {
if len(atlas.regions) > 0 {
atlas.index = len(newAtlases)
// Update region atlas indices
for key, region := range atlas.regions {
region.AtlasIndex = atlas.index
atlas.regions[key] = region
m.lookup[key] = region
}
newAtlases = append(newAtlases, atlas)
} else {
removed++
}
}
m.atlases = newAtlases
return removed
}
func Config
Config returns the atlas configuration.
func (m *AtlasManager) Config() AtlasConfig {
return m.config
}
func DefaultAtlasConfig
DefaultAtlasConfig returns default configuration.
func DefaultAtlasConfig() AtlasConfig {
return AtlasConfig{
Size: 1024,
GlyphSize: 32,
Padding: 2,
MaxAtlases: 8,
}
}
func DirtyAtlases
DirtyAtlases returns indices of atlases needing GPU upload.
func (m *AtlasManager) DirtyAtlases() []int {
m.mu.RLock()
defer m.mu.RUnlock()
var dirty []int
for i, atlas := range m.atlases {
if atlas.dirty {
dirty = append(dirty, i)
}
}
return dirty
}
func Error
func (e *AtlasConfigError) Error() string {
return "msdf: invalid atlas config." + e.Field + ": " + e.Reason
}
func Error
func (e *AtlasFullError) Error() string {
return fmt.Sprintf("msdf: all %d atlases are full", e.MaxAtlases)
}
func Generator
Generator returns the MSDF generator used by this manager.
func (m *AtlasManager) Generator() *Generator {
return m.generator
}
func Get
Get retrieves a glyph region from the appropriate shard.
func (c *ConcurrentAtlasManager) Get(key GlyphKey, outline *text.GlyphOutline) (Region, error) {
return c.getShard(key).Get(key, outline)
}
func Get
Get retrieves a glyph region, generating MSDF if needed.
Returns the region for the glyph, creating it if necessary.
func (m *AtlasManager) Get(key GlyphKey, outline *text.GlyphOutline) (Region, error) {
// Fast path: check if already cached (read lock)
m.mu.RLock()
if region, ok := m.lookup[key]; ok {
m.mu.RUnlock()
m.hits.Add(1)
return region, nil
}
m.mu.RUnlock()
m.misses.Add(1)
// Slow path: need to generate and add (write lock)
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock
if region, ok := m.lookup[key]; ok {
return region, nil
}
// Generate MSDF
msdf, err := m.generator.Generate(outline)
if err != nil {
return Region{}, fmt.Errorf("failed to generate MSDF: %w", err)
}
// Apply median filter to clean MSDF noise before error correction.
// MedianFilter smooths outlier texels that arise from edge-case
// distance evaluations, reducing color fringing at glyph boundaries.
msdf = MedianFilter(msdf)
// Apply error correction to prevent bilinear interpolation artifacts.
// Without this, GPU bilinear filtering between adjacent MSDF texels can
// cause channel ordering to change, producing incorrect median values
// that manifest as color fringing on small or gray text.
ErrorCorrection(msdf, msdfErrorCorrectionThreshold)
// Find or create atlas with space
atlas, err := m.findOrCreateAtlas()
if err != nil {
return Region{}, err
}
// Allocate cell in atlas
x, y, ok := atlas.allocator.Allocate()
if !ok {
// This shouldn't happen if findOrCreateAtlas works correctly
return Region{}, ErrAllocationFailed
}
// Copy MSDF data to atlas
atlas.copyMSDF(msdf, x, y)
// Compute planeBounds: cell extent in outline reference coordinates.
// The MSDF cell covers [0, Width] x [0, Height] pixels.
planeMinX, planeMinY := msdf.PixelToOutline(0, 0)
planeMaxX, planeMaxY := msdf.PixelToOutline(float64(msdf.Width), float64(msdf.Height))
// Create region with half-pixel UV inset.
// Bilinear sampling at exact cell edges reads from the neighbor cell.
// Inset by 0.5 texels so sample points land on texel centers.
glyphSize := m.config.GlyphSize
atlasSize := float32(m.config.Size)
halfTexel := float32(0.5) / atlasSize
// Corresponding planeBounds inset: 0.5 MSDF pixel in outline coords.
halfPixelInOutline := 0.5 / msdf.Scale
region := Region{
AtlasIndex: atlas.index,
X: x,
Y: y,
Width: glyphSize,
Height: glyphSize,
U0: float32(x)/atlasSize + halfTexel,
V0: float32(y)/atlasSize + halfTexel,
U1: float32(x+glyphSize)/atlasSize - halfTexel,
V1: float32(y+glyphSize)/atlasSize - halfTexel,
PlaneMinX: float32(planeMinX + halfPixelInOutline),
PlaneMinY: float32(planeMinY + halfPixelInOutline),
PlaneMaxX: float32(planeMaxX - halfPixelInOutline),
PlaneMaxY: float32(planeMaxY - halfPixelInOutline),
}
// Store in lookup
m.lookup[key] = region
atlas.regions[key] = region
return region, nil
}
func GetAtlas
GetAtlas returns atlas data for GPU upload.
Returns nil if index is out of range.
func (m *AtlasManager) GetAtlas(index int) *Atlas {
m.mu.RLock()
defer m.mu.RUnlock()
if index < 0 || index >= len(m.atlases) {
return nil
}
return m.atlases[index]
}
func GetBatch
GetBatch retrieves multiple glyph regions efficiently.
This is more efficient than calling Get multiple times as it
reduces lock contention and can batch MSDF generation.
func (m *AtlasManager) GetBatch(keys []GlyphKey, outlines []*text.GlyphOutline) ([]Region, error) {
if len(keys) != len(outlines) {
return nil, ErrLengthMismatch
}
results := make([]Region, len(keys))
missing := make([]int, 0, len(keys))
// First pass: find cached entries (read lock)
m.mu.RLock()
for i, key := range keys {
if region, ok := m.lookup[key]; ok {
results[i] = region
m.hits.Add(1)
} else {
missing = append(missing, i)
}
}
m.mu.RUnlock()
// If all cached, we're done
if len(missing) == 0 {
return results, nil
}
// Second pass: generate missing entries (write lock)
m.mu.Lock()
defer m.mu.Unlock()
for _, idx := range missing {
key := keys[idx]
// Double-check after acquiring write lock
if region, ok := m.lookup[key]; ok {
results[idx] = region
continue
}
m.misses.Add(1)
// Generate MSDF
msdf, err := m.generator.Generate(outlines[idx])
if err != nil {
return nil, fmt.Errorf("failed to generate MSDF for key %v: %w", key, err)
}
// Apply median filter + error correction (see single-glyph Get for rationale).
msdf = MedianFilter(msdf)
ErrorCorrection(msdf, msdfErrorCorrectionThreshold)
// Find or create atlas with space
atlas, err := m.findOrCreateAtlas()
if err != nil {
return nil, err
}
// Allocate cell in atlas
x, y, ok := atlas.allocator.Allocate()
if !ok {
return nil, ErrAllocationFailed
}
// Copy MSDF data to atlas
atlas.copyMSDF(msdf, x, y)
// Compute planeBounds: cell extent in outline reference coordinates.
planeMinX, planeMinY := msdf.PixelToOutline(0, 0)
planeMaxX, planeMaxY := msdf.PixelToOutline(float64(msdf.Width), float64(msdf.Height))
// Half-pixel UV inset + corresponding planeBounds inset.
glyphSize := m.config.GlyphSize
atlasSize := float32(m.config.Size)
halfTexel := float32(0.5) / atlasSize
halfPixelInOutline := 0.5 / msdf.Scale
region := Region{
AtlasIndex: atlas.index,
X: x,
Y: y,
Width: glyphSize,
Height: glyphSize,
U0: float32(x)/atlasSize + halfTexel,
V0: float32(y)/atlasSize + halfTexel,
U1: float32(x+glyphSize)/atlasSize - halfTexel,
V1: float32(y+glyphSize)/atlasSize - halfTexel,
PlaneMinX: float32(planeMinX + halfPixelInOutline),
PlaneMinY: float32(planeMinY + halfPixelInOutline),
PlaneMaxX: float32(planeMaxX - halfPixelInOutline),
PlaneMaxY: float32(planeMaxY - halfPixelInOutline),
}
// Store in lookup
m.lookup[key] = region
atlas.regions[key] = region
results[idx] = region
}
return results, nil
}
func GlyphCount
GlyphCount returns the total glyph count across all shards.
func (c *ConcurrentAtlasManager) GlyphCount() int {
total := 0
for _, shard := range c.shards {
total += shard.GlyphCount()
}
return total
}
func GlyphCount
GlyphCount returns the total number of cached glyphs.
func (m *AtlasManager) GlyphCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.lookup)
}
func GlyphCount
GlyphCount returns the number of glyphs in this atlas.
func (a *Atlas) GlyphCount() int {
return len(a.regions)
}
func HasGlyph
HasGlyph returns true if the glyph is already cached.
func (m *AtlasManager) HasGlyph(key GlyphKey) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.lookup[key]
return ok
}
func HasGlyph
HasGlyph checks if a glyph is cached in the appropriate shard.
func (c *ConcurrentAtlasManager) HasGlyph(key GlyphKey) bool {
return c.getShard(key).HasGlyph(key)
}
func IsDirty
IsDirty returns true if the atlas has been modified since last upload.
func (a *Atlas) IsDirty() bool {
return a.dirty
}
func IsFull
IsFull returns true if no more glyphs can be added.
func (a *Atlas) IsFull() bool {
return a.allocator.IsFull()
}
func MarkAllClean
MarkAllClean marks all atlases as uploaded to GPU.
func (m *AtlasManager) MarkAllClean() {
m.mu.Lock()
defer m.mu.Unlock()
for _, atlas := range m.atlases {
atlas.dirty = false
}
}
func MarkClean
MarkClean marks an atlas as uploaded to GPU.
func (m *AtlasManager) MarkClean(index int) {
m.mu.Lock()
defer m.mu.Unlock()
if index >= 0 && index < len(m.atlases) {
m.atlases[index].dirty = false
}
}
func MemoryUsage
MemoryUsage returns the total memory used by all atlases in bytes.
func (m *AtlasManager) MemoryUsage() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
var total int64
for _, atlas := range m.atlases {
total += int64(len(atlas.Data))
}
return total
}
func MemoryUsage
MemoryUsage returns total memory usage across all shards.
func (c *ConcurrentAtlasManager) MemoryUsage() int64 {
var total int64
for _, shard := range c.shards {
total += shard.MemoryUsage()
}
return total
}
func NewAtlasManager
NewAtlasManager creates a new atlas manager.
func NewAtlasManager(config AtlasConfig) (*AtlasManager, error) {
if err := config.Validate(); err != nil {
return nil, err
}
// Create generator with config matching glyph size
genConfig := DefaultConfig()
genConfig.Size = config.GlyphSize
return &AtlasManager{
config: config,
atlases: make([]*Atlas, 0, config.MaxAtlases),
lookup: make(map[GlyphKey]Region),
generator: NewGenerator(genConfig),
}, nil
}
func NewAtlasManagerDefault
NewAtlasManagerDefault creates a new atlas manager with default configuration.
func NewAtlasManagerDefault() *AtlasManager {
m, _ := NewAtlasManager(DefaultAtlasConfig())
return m
}
func NewConcurrentAtlasManager
NewConcurrentAtlasManager creates a sharded atlas manager.
numShards must be a power of 2.
func NewConcurrentAtlasManager(config AtlasConfig, numShards int) (*ConcurrentAtlasManager, error) {
// Ensure numShards is power of 2
if numShards <= 0 || (numShards&(numShards-1)) != 0 {
numShards = 4 // Default to 4 shards
}
shards := make([]*AtlasManager, numShards)
for i := range shards {
m, err := NewAtlasManager(config)
if err != nil {
return nil, err
}
shards[i] = m
}
return &ConcurrentAtlasManager{
shards: shards,
shardMask: uint64(numShards - 1), //nolint:gosec // numShards is validated to be positive power of 2
}, nil
}
func Remove
Remove removes a specific glyph from the cache.
Note: This does not reclaim space in the atlas.
func (m *AtlasManager) Remove(key GlyphKey) bool {
m.mu.Lock()
defer m.mu.Unlock()
region, ok := m.lookup[key]
if !ok {
return false
}
delete(m.lookup, key)
if region.AtlasIndex >= 0 && region.AtlasIndex < len(m.atlases) {
delete(m.atlases[region.AtlasIndex].regions, key)
}
return true
}
func SetGenerator
SetGenerator sets a custom MSDF generator.
func (m *AtlasManager) SetGenerator(g *Generator) {
m.mu.Lock()
defer m.mu.Unlock()
m.generator = g
}
func Stats
Stats returns combined statistics from all shards.
func (c *ConcurrentAtlasManager) Stats() (hits, misses uint64, atlasCount int) {
for _, shard := range c.shards {
h, m, a := shard.Stats()
hits += h
misses += m
atlasCount += a
}
return
}
func Stats
Stats returns cache statistics.
func (m *AtlasManager) Stats() (hits, misses uint64, atlasCount int) {
m.mu.RLock()
atlasCount = len(m.atlases)
m.mu.RUnlock()
hits = m.hits.Load()
misses = m.misses.Load()
return
}
func Utilization
Utilization returns the percentage of atlas space used.
func (a *Atlas) Utilization() float64 {
return a.allocator.Utilization()
}
func Validate
Validate checks if the configuration is valid.
func (c *AtlasConfig) Validate() error {
if c.Size < 64 {
return &AtlasConfigError{Field: "Size", Reason: "must be at least 64"}
}
if c.Size > 8192 {
return &AtlasConfigError{Field: "Size", Reason: "must be at most 8192"}
}
// Check power of 2
if c.Size&(c.Size-1) != 0 {
return &AtlasConfigError{Field: "Size", Reason: "must be power of 2"}
}
if c.GlyphSize < 8 {
return &AtlasConfigError{Field: "GlyphSize", Reason: "must be at least 8"}
}
if c.GlyphSize > c.Size {
return &AtlasConfigError{Field: "GlyphSize", Reason: "must be at most Size"}
}
if c.Padding < 0 {
return &AtlasConfigError{Field: "Padding", Reason: "must be non-negative"}
}
if c.Padding >= c.GlyphSize/2 {
return &AtlasConfigError{Field: "Padding", Reason: "must be less than half GlyphSize"}
}
if c.MaxAtlases < 1 {
return &AtlasConfigError{Field: "MaxAtlases", Reason: "must be at least 1"}
}
if c.MaxAtlases > 256 {
return &AtlasConfigError{Field: "MaxAtlases", Reason: "must be at most 256"}
}
return nil
}
Structs
type AtlasConfig struct
AtlasConfig holds atlas configuration.
type AtlasConfig struct {
// Size is the atlas texture size (width = height).
// Must be power of 2. Default: 1024
Size int
// GlyphSize is the size of each glyph cell.
// Default: 32
GlyphSize int
// Padding between glyphs to prevent bleeding.
// Default: 2
Padding int
// MaxAtlases limits the number of atlases.
// Default: 8
MaxAtlases int
}
type AtlasConfigError struct
AtlasConfigError represents a configuration validation error.
type AtlasConfigError struct {
Field string
Reason string
}
type Atlas struct
Atlas represents a single MSDF texture atlas.
type Atlas struct {
// Data is the RGB pixel data.
Data []byte
// Size is width = height of the atlas.
Size int
// Regions tracks allocated glyph regions.
regions map[GlyphKey]Region
// allocator packs glyphs using grid algorithm.
allocator *GridAllocator
// dirty marks if atlas needs GPU upload.
dirty bool
// index is the atlas index in the manager.
index int
// glyphSize is the size of each glyph cell.
glyphSize int
}
type Region struct
Region describes a glyph's location in the atlas.
type Region struct {
// AtlasIndex indicates which atlas this glyph is in.
AtlasIndex int
// UV coordinates [0, 1] for texture sampling.
// Inset by 0.5 texels to prevent bilinear bleed from adjacent cells.
U0, V0, U1, V1 float32
// PlaneBounds: MSDF cell extent in outline reference coordinates.
// At render time: quad_pos = cursor + PlaneBound * (fontSize / refSize)
PlaneMinX, PlaneMinY, PlaneMaxX, PlaneMaxY float32
// Pixel coordinates in atlas.
X, Y, Width, Height int
}
type GlyphKey struct
GlyphKey uniquely identifies a glyph in the atlas.
type GlyphKey struct {
// FontID identifies the font (hash of font data or path).
FontID uint64
// GlyphID is the glyph index within the font.
GlyphID uint16
// Size is the MSDF texture size (not font size).
// Different MSDF sizes produce different textures.
Size int16
}
type AtlasManager struct
AtlasManager manages multiple MSDF atlases.
type AtlasManager struct {
mu sync.RWMutex
config AtlasConfig
atlases []*Atlas
lookup map[GlyphKey]Region
generator *Generator
// Statistics (atomic for lock-free reads)
hits atomic.Uint64
misses atomic.Uint64
}
type AtlasInfo struct
AtlasInfo contains information about a single atlas.
type AtlasInfo struct {
Index int
GlyphCount int
Utilization float64
Dirty bool
MemoryBytes int
}
type AtlasFullError struct
AtlasFullError is returned when all atlases are full.
type AtlasFullError struct {
MaxAtlases int
}
type ConcurrentAtlasManager struct
ConcurrentAtlasManager wraps AtlasManager with optimized concurrent access patterns.
It uses sharding to reduce lock contention for high-throughput scenarios.
type ConcurrentAtlasManager struct {
shards []*AtlasManager
shardMask uint64
}
AtlasCount returns the number of atlases currently in use.