From bbf64c3fa3861666979234f713f735e81f386ecf Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Tue, 5 Aug 2025 15:51:33 +1000 Subject: [PATCH 1/4] Implement font memory management in ImGuiApp: Added FontMemoryGuard to limit texture memory allocation for fonts, enhancing performance on low-end GPUs. Updated font initialization to incorporate memory constraints and fallback strategies, ensuring efficient resource usage. Updated package versions for dependencies. --- Directory.Packages.props | 10 +- ImGuiApp.Test/ImGuiAppTests.cs | 2 - ImGuiApp/FontMemoryGuard.cs | 762 +++++++++++++++++++++++++++++++++ ImGuiApp/ImGuiApp.cs | 174 +++++++- ImGuiApp/ImGuiAppConfig.cs | 6 + 5 files changed, 934 insertions(+), 20 deletions(-) create mode 100644 ImGuiApp/FontMemoryGuard.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index de08a5d..0c65d9e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + @@ -21,8 +21,8 @@ - - + + @@ -33,8 +33,8 @@ - - + + diff --git a/ImGuiApp.Test/ImGuiAppTests.cs b/ImGuiApp.Test/ImGuiAppTests.cs index a0b3363..8ed4bf1 100644 --- a/ImGuiApp.Test/ImGuiAppTests.cs +++ b/ImGuiApp.Test/ImGuiAppTests.cs @@ -2,8 +2,6 @@ // All rights reserved. // Licensed under the MIT license. -using Microsoft.VisualStudio.TestTools.UnitTesting; - [assembly: DoNotParallelize] namespace ktsu.ImGuiApp.Test; diff --git a/ImGuiApp/FontMemoryGuard.cs b/ImGuiApp/FontMemoryGuard.cs new file mode 100644 index 0000000..e7fa1b0 --- /dev/null +++ b/ImGuiApp/FontMemoryGuard.cs @@ -0,0 +1,762 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGuiApp; + +using System; +using System.Collections.Generic; +using System.Linq; +using Hexa.NET.ImGui; +using Silk.NET.OpenGL; + +/// +/// Provides memory guards and limits for font loading to prevent excessive texture memory allocation +/// on small GPUs or high-resolution displays. +/// +/// Intel & AMD Integrated GPU Support: +/// Special handling for integrated graphics which are the primary targets for memory constraints: +/// +/// Intel Graphics: HD Graphics, UHD Graphics, Iris, Xe Graphics - Often don't expose memory query extensions +/// AMD APUs: Vega, RDNA integrated graphics - Share system RAM with limited bandwidth +/// Automatic Detection: Uses renderer string analysis when OpenGL memory extensions aren't available +/// Conservative Limits: 16-96MB font atlas limits for integrated GPUs vs 64-128MB for discrete +/// Generation-Aware: Newer integrated GPUs (Xe, RDNA2+) get higher limits than older ones +/// +/// +/// Why This Matters: +/// Integrated GPUs share system RAM and have limited memory bandwidth. A 4K display with full Unicode +/// font support can easily create 200MB+ font atlases, which can cause: +/// +/// Application crashes due to GPU memory exhaustion +/// Severe performance degradation from memory pressure +/// System-wide slowdowns as integrated GPU competes with CPU for RAM bandwidth +/// +/// +public static class FontMemoryGuard +{ + /// + /// Default maximum font atlas texture size in bytes (64MB). + /// This is conservative for integrated GPUs and high-DPI displays. + /// + public const long DefaultMaxAtlasMemoryBytes = 64 * 1024 * 1024; // 64MB + + /// + /// Minimum font atlas texture size in bytes (8MB). + /// Below this threshold, basic functionality may be compromised. + /// + public const long MinAtlasMemoryBytes = 8 * 1024 * 1024; // 8MB + + /// + /// Maximum font atlas texture dimension (e.g., 4096x4096). + /// Most GPUs support at least this size. + /// + public const int MaxAtlasTextureDimension = 4096; + + /// + /// Estimated bytes per glyph for memory calculations. + /// This is a rough estimate based on typical font rasterization. + /// + public const int EstimatedBytesPerGlyph = 128; + + /// + /// Configuration for font memory limits. + /// + public class FontMemoryConfig + { + /// + /// Maximum memory to allocate for font atlas textures in bytes. + /// + public long MaxAtlasMemoryBytes { get; set; } = DefaultMaxAtlasMemoryBytes; + + /// + /// Whether to enable automatic GPU memory detection. + /// + public bool EnableGpuMemoryDetection { get; set; } = true; + + /// + /// Maximum percentage of available GPU memory to use for font textures. + /// Note: Integrated GPUs automatically use lower percentages (3-5%) regardless of this setting. + /// + public float MaxGpuMemoryPercentage { get; set; } = 0.1f; // 10% for discrete GPUs + + /// + /// Whether to enable special handling for Intel integrated GPUs. + /// Intel GPUs often don't expose memory query extensions, so we use heuristics. + /// + public bool EnableIntelGpuHeuristics { get; set; } = true; + + /// + /// Whether to enable special handling for AMD integrated GPUs (APUs). + /// + public bool EnableAmdApuHeuristics { get; set; } = true; + + /// + /// Whether to enable fallback strategies when memory limits are exceeded. + /// + public bool EnableFallbackStrategies { get; set; } = true; + + /// + /// Minimum number of font sizes to load even under memory constraints. + /// + public int MinFontSizesToLoad { get; set; } = 3; // e.g., 12, 16, 20 + + /// + /// Whether to disable emoji fonts under memory constraints. + /// + public bool DisableEmojisOnLowMemory { get; set; } = true; + + /// + /// Whether to reduce Unicode glyph ranges under memory constraints. + /// + public bool ReduceUnicodeRangesOnLowMemory { get; set; } = true; + } + + /// + /// Result of font memory estimation. + /// + public struct FontMemoryEstimate : IEquatable + { + /// + /// Estimated memory usage in bytes. + /// + public long EstimatedBytes { get; set; } + + /// + /// Number of glyphs estimated. + /// + public int EstimatedGlyphCount { get; set; } + + /// + /// Whether the estimate exceeds memory limits. + /// + public bool ExceedsLimits { get; set; } + + /// + /// Recommended maximum number of font sizes to load. + /// + public int RecommendedMaxSizes { get; set; } + + /// + /// Whether emoji fonts should be disabled. + /// + public bool ShouldDisableEmojis { get; set; } + + /// + /// Whether Unicode ranges should be reduced. + /// + public bool ShouldReduceUnicodeRanges { get; set; } + + /// + /// Determines whether the specified object is equal to the current object. + /// + /// The object to compare with the current object. + /// true if the specified object is equal to the current object; otherwise, false. + public override readonly bool Equals(object? obj) => obj is FontMemoryEstimate estimate && Equals(estimate); + + /// + /// Serves as the default hash function. + /// + /// A hash code for the current object. + public override readonly int GetHashCode() => HashCode.Combine(EstimatedBytes, EstimatedGlyphCount, ExceedsLimits, RecommendedMaxSizes, ShouldDisableEmojis, ShouldReduceUnicodeRanges); + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// An object to compare with this object. + /// true if the current object is equal to the other parameter; otherwise, false. + public readonly bool Equals(FontMemoryEstimate other) => + EstimatedBytes == other.EstimatedBytes && + EstimatedGlyphCount == other.EstimatedGlyphCount && + ExceedsLimits == other.ExceedsLimits && + RecommendedMaxSizes == other.RecommendedMaxSizes && + ShouldDisableEmojis == other.ShouldDisableEmojis && + ShouldReduceUnicodeRanges == other.ShouldReduceUnicodeRanges; + + /// + /// Determines whether two specified instances of FontMemoryEstimate are equal. + /// + /// The first FontMemoryEstimate to compare. + /// The second FontMemoryEstimate to compare. + /// true if the two FontMemoryEstimate instances are equal; otherwise, false. + public static bool operator ==(FontMemoryEstimate left, FontMemoryEstimate right) => left.Equals(right); + + /// + /// Determines whether two specified instances of FontMemoryEstimate are not equal. + /// + /// The first FontMemoryEstimate to compare. + /// The second FontMemoryEstimate to compare. + /// true if the two FontMemoryEstimate instances are not equal; otherwise, false. + public static bool operator !=(FontMemoryEstimate left, FontMemoryEstimate right) => !(left == right); + } + + /// + /// Fallback strategy for font loading under memory constraints. + /// + public enum FallbackStrategy + { + /// + /// No fallback needed. + /// + None, + + /// + /// Reduce number of font sizes. + /// + ReduceFontSizes, + + /// + /// Disable emoji fonts. + /// + DisableEmojis, + + /// + /// Reduce Unicode glyph ranges. + /// + ReduceUnicodeRanges, + + /// + /// Aggressive fallback: minimal fonts only. + /// + MinimalFonts + } + + /// + /// Gets the current font memory configuration. + /// + public static FontMemoryConfig CurrentConfig { get; set; } = new(); + + /// + /// Estimates the memory usage for font loading based on the provided parameters. + /// + /// Number of font files to load. + /// Array of font sizes to load. + /// Whether emoji fonts will be included. + /// Whether extended Unicode ranges will be included. + /// Current DPI scale factor. + /// Font memory estimate. + public static FontMemoryEstimate EstimateMemoryUsage( + int fontCount, + int[] fontSizes, + bool includeEmojis, + bool includeExtendedUnicode, + float scaleFactor) + { + ArgumentNullException.ThrowIfNull(fontSizes); + + // Estimate glyph count based on configuration + int baseGlyphCount = 128; // ASCII + if (includeExtendedUnicode) + { + baseGlyphCount += GetExtendedUnicodeGlyphCount(); + } + if (includeEmojis) + { + baseGlyphCount += GetEmojiGlyphCount(); + } + + // Calculate total glyph count across all fonts and sizes + int totalGlyphCount = fontCount * fontSizes.Length * baseGlyphCount; + + // Apply scale factor impact (higher DPI = larger textures) + float scaleImpact = scaleFactor * scaleFactor; // Quadratic impact on texture size + long estimatedBytes = (long)(totalGlyphCount * EstimatedBytesPerGlyph * scaleImpact); + + // Determine if limits are exceeded and recommend fallbacks + bool exceedsLimits = estimatedBytes > CurrentConfig.MaxAtlasMemoryBytes; + int recommendedMaxSizes = CalculateRecommendedMaxSizes(estimatedBytes, fontCount, baseGlyphCount, scaleFactor); + bool shouldDisableEmojis = exceedsLimits && CurrentConfig.DisableEmojisOnLowMemory && includeEmojis; + bool shouldReduceUnicode = exceedsLimits && CurrentConfig.ReduceUnicodeRangesOnLowMemory && includeExtendedUnicode; + + return new FontMemoryEstimate + { + EstimatedBytes = estimatedBytes, + EstimatedGlyphCount = totalGlyphCount, + ExceedsLimits = exceedsLimits, + RecommendedMaxSizes = recommendedMaxSizes, + ShouldDisableEmojis = shouldDisableEmojis, + ShouldReduceUnicodeRanges = shouldReduceUnicode + }; + } + + /// + /// Attempts to detect available GPU memory and update configuration accordingly. + /// Special handling for Intel and AMD integrated GPUs which are primary targets for memory constraints. + /// + /// OpenGL context for querying GPU information. + /// True if GPU memory was successfully detected and configuration updated. + public static unsafe bool TryDetectAndConfigureGpuMemory(GL gl) + { + if (!CurrentConfig.EnableGpuMemoryDetection || gl == null) + { + return false; + } + + try + { + // Get GPU vendor and renderer information + string vendor = gl.GetStringS(GLEnum.Vendor) ?? ""; + string renderer = gl.GetStringS(GLEnum.Renderer) ?? ""; + bool isIntelGpu = vendor.Contains("Intel", StringComparison.OrdinalIgnoreCase); + bool isAmdGpu = vendor.Contains("AMD", StringComparison.OrdinalIgnoreCase) || vendor.Contains("ATI", StringComparison.OrdinalIgnoreCase); + bool isNvidiaGpu = vendor.Contains("NVIDIA", StringComparison.OrdinalIgnoreCase); + bool isIntegratedGpu = IsIntegratedGpu(renderer); + + DebugLogger.Log($"FontMemoryGuard: Detected GPU - Vendor: {vendor}, Renderer: {renderer}"); + DebugLogger.Log($"FontMemoryGuard: GPU Classification - Intel: {isIntelGpu}, AMD: {isAmdGpu}, NVIDIA: {isNvidiaGpu}, Integrated: {isIntegratedGpu}"); + + // Try to get GPU memory information using OpenGL extensions + bool memoryDetected = false; + long availableMemoryKB = 0; + + // Try NVIDIA extension first (works on discrete NVIDIA GPUs) + // Note: NVIDIA memory extensions typically aren't available as TryGetExtension in Silk.NET + // We'll use direct OpenGL calls instead + if (isNvidiaGpu && gl.IsExtensionPresent("GL_NVX_gpu_memory_info")) + { + gl.GetInteger((GLEnum)0x9048, out int totalMemKB); // GL_GPU_MEMORY_TOTAL_AVAILABLE_MEMORY_NVX + gl.GetInteger((GLEnum)0x9049, out int currentMemKB); // GL_GPU_MEMORY_CURRENT_AVAILABLE_MEMORY_NVX + availableMemoryKB = currentMemKB; + memoryDetected = true; + DebugLogger.Log($"FontMemoryGuard: NVIDIA GPU memory: {totalMemKB}KB total, {currentMemKB}KB available"); + } + // Try ATI extension (works on AMD discrete and some integrated GPUs) + else if (isAmdGpu && gl.IsExtensionPresent("GL_ATI_meminfo")) + { + Span memInfo = stackalloc int[4]; + gl.GetInteger((GLEnum)0x87FC, memInfo); // GL_TEXTURE_FREE_MEMORY_ATI + availableMemoryKB = memInfo[0]; + memoryDetected = true; + DebugLogger.Log($"FontMemoryGuard: AMD GPU texture memory: {availableMemoryKB}KB available"); + } + + // Apply memory-based configuration if detected + if (memoryDetected && availableMemoryKB > 0) + { + long recommendedFontMemory = CalculateRecommendedMemoryFromDetection(availableMemoryKB, isIntegratedGpu); + CurrentConfig.MaxAtlasMemoryBytes = recommendedFontMemory; + DebugLogger.Log($"FontMemoryGuard: Set font memory limit to {recommendedFontMemory / (1024 * 1024)}MB based on GPU memory detection"); + return true; + } + + // Fallback to heuristic-based configuration for Intel and undetected GPUs + if (ApplyIntegratedGpuHeuristics(isIntelGpu, isAmdGpu, isIntegratedGpu, renderer)) + { + return true; + } + } + catch (InvalidOperationException ex) + { + DebugLogger.Log($"FontMemoryGuard: GPU memory detection failed with InvalidOperationException: {ex.Message}"); + } + catch (ArgumentException ex) + { + DebugLogger.Log($"FontMemoryGuard: GPU memory detection failed with ArgumentException: {ex.Message}"); + } + catch (System.ComponentModel.Win32Exception ex) + { + DebugLogger.Log($"FontMemoryGuard: GPU memory detection failed with Win32Exception: {ex.Message}"); + } + + return false; + } + + /// + /// Determines if a GPU is integrated based on renderer string analysis. + /// + /// GPU renderer string from OpenGL. + /// True if the GPU appears to be integrated. + private static bool IsIntegratedGpu(string renderer) + { + if (string.IsNullOrEmpty(renderer)) + { + return false; + } + + string rendererLower = renderer.ToLowerInvariant(); + + // Intel integrated GPU patterns + string[] intelIntegratedPatterns = [ + "intel", "hd graphics", "uhd graphics", "iris", "xe graphics" + ]; + + // AMD integrated GPU patterns + string[] amdIntegratedPatterns = [ + "radeon(tm) graphics", "vega", "rdna", "apu" + ]; + + // General integrated GPU indicators + string[] integratedPatterns = [ + "integrated", "onboard", "shared" + ]; + + return intelIntegratedPatterns.Any(rendererLower.Contains) || + amdIntegratedPatterns.Any(rendererLower.Contains) || + integratedPatterns.Any(rendererLower.Contains); + } + + /// + /// Applies memory configuration heuristics for integrated GPUs when direct memory detection fails. + /// This is especially important for Intel integrated GPUs which often don't expose memory extensions. + /// + /// Whether this is an Intel GPU. + /// Whether this is an AMD GPU. + /// Whether this appears to be integrated graphics. + /// GPU renderer string for additional analysis. + /// True if heuristics were applied and configuration updated. + private static bool ApplyIntegratedGpuHeuristics(bool isIntelGpu, bool isAmdGpu, bool isIntegratedGpu, string renderer) + { + if (!isIntegratedGpu && !isIntelGpu) + { + return false; // Only apply heuristics for suspected integrated GPUs + } + + long recommendedMemory = DefaultMaxAtlasMemoryBytes; // Default 64MB + + if (isIntelGpu) + { + // Intel integrated GPU memory recommendations + recommendedMemory = AnalyzeIntelGpuGeneration(renderer); + DebugLogger.Log($"FontMemoryGuard: Applied Intel integrated GPU heuristics: {recommendedMemory / (1024 * 1024)}MB limit"); + } + else if (isAmdGpu && isIntegratedGpu) + { + // AMD integrated GPU (APU) memory recommendations + recommendedMemory = AnalyzeAmdApuGeneration(renderer); + DebugLogger.Log($"FontMemoryGuard: Applied AMD integrated GPU heuristics: {recommendedMemory / (1024 * 1024)}MB limit"); + } + else if (isIntegratedGpu) + { + // Generic integrated GPU - be conservative + recommendedMemory = 32 * 1024 * 1024; // 32MB + DebugLogger.Log($"FontMemoryGuard: Applied generic integrated GPU heuristics: {recommendedMemory / (1024 * 1024)}MB limit"); + } + + CurrentConfig.MaxAtlasMemoryBytes = recommendedMemory; + + // Enable more aggressive fallbacks for integrated GPUs + CurrentConfig.DisableEmojisOnLowMemory = true; + CurrentConfig.ReduceUnicodeRangesOnLowMemory = true; + CurrentConfig.MaxGpuMemoryPercentage = 0.05f; // Use only 5% for integrated GPUs + + return true; + } + + /// + /// Analyzes Intel GPU generation from renderer string to determine appropriate memory limits. + /// + /// GPU renderer string. + /// Recommended memory limit in bytes. + private static long AnalyzeIntelGpuGeneration(string renderer) + { + if (string.IsNullOrEmpty(renderer)) + { + return 32 * 1024 * 1024; // Conservative 32MB for unknown Intel GPU + } + + string rendererLower = renderer.ToLowerInvariant(); + + // Modern Intel GPUs (12th gen+, Xe Graphics) + if (rendererLower.Contains("xe") || rendererLower.Contains("arc")) + { + return 96 * 1024 * 1024; // 96MB - Modern Intel has better memory bandwidth + } + + // Recent Intel GPUs (UHD Graphics series) + if (rendererLower.Contains("uhd")) + { + return 64 * 1024 * 1024; // 64MB - Standard limit + } + + // Older Intel GPUs (HD Graphics series) + if (rendererLower.Contains("hd graphics")) + { + // Extract generation number if possible + if (rendererLower.Contains("630") || rendererLower.Contains("620") || rendererLower.Contains("610")) + { + return 48 * 1024 * 1024; // 48MB - 7th/8th gen + } + else if (rendererLower.Contains("530") || rendererLower.Contains("520") || rendererLower.Contains("510")) + { + return 32 * 1024 * 1024; // 32MB - 6th gen and older + } + else + { + return 24 * 1024 * 1024; // 24MB - Very old Intel GPUs + } + } + + // Iris graphics (higher end integrated) + if (rendererLower.Contains("iris")) + { + return 80 * 1024 * 1024; // 80MB - Iris has better performance + } + + // Default for unknown Intel GPU + return 32 * 1024 * 1024; // 32MB conservative + } + + /// + /// Analyzes AMD APU generation from renderer string to determine appropriate memory limits. + /// + /// GPU renderer string. + /// Recommended memory limit in bytes. + private static long AnalyzeAmdApuGeneration(string renderer) + { + if (string.IsNullOrEmpty(renderer)) + { + return 48 * 1024 * 1024; // Conservative 48MB for unknown AMD APU + } + + string rendererLower = renderer.ToLowerInvariant(); + + // Modern RDNA2/3 APUs (6000+ series, Steam Deck) + if (rendererLower.Contains("rdna") || rendererLower.Contains("6600") || rendererLower.Contains("6800") || rendererLower.Contains("7600")) + { + return 128 * 1024 * 1024; // 128MB - Modern AMD APUs are quite capable + } + + // Vega-based APUs (good performance) + if (rendererLower.Contains("vega")) + { + return 96 * 1024 * 1024; // 96MB - Vega integrated graphics are decent + } + + // Older GCN-based APUs + if (rendererLower.Contains("radeon") && (rendererLower.Contains("r5") || rendererLower.Contains("r7"))) + { + return 48 * 1024 * 1024; // 48MB - Older AMD APUs + } + + // Default for unknown AMD APU + return 64 * 1024 * 1024; // 64MB standard + } + + /// + /// Calculates recommended memory limit based on detected GPU memory and type. + /// + /// Available memory in KB from GPU detection. + /// Whether this is an integrated GPU. + /// Recommended font memory limit in bytes. + private static long CalculateRecommendedMemoryFromDetection(long availableMemoryKB, bool isIntegratedGpu) + { + // Convert KB to bytes + long availableMemoryBytes = availableMemoryKB * 1024; + + // Use different percentages for integrated vs discrete GPUs + float percentage = isIntegratedGpu ? 0.03f : CurrentConfig.MaxGpuMemoryPercentage; // 3% for integrated, configurable for discrete + long recommendedFontMemory = (long)(availableMemoryBytes * percentage); + + // Apply bounds based on GPU type + long minMemory = isIntegratedGpu ? 16 * 1024 * 1024 : MinAtlasMemoryBytes; // 16MB min for integrated + long maxMemory = isIntegratedGpu ? 96 * 1024 * 1024 : DefaultMaxAtlasMemoryBytes * 2; // 96MB max for integrated + + return Math.Clamp(recommendedFontMemory, minMemory, maxMemory); + } + + /// + /// Determines the best fallback strategy based on memory constraints. + /// + /// Current memory estimate. + /// Recommended fallback strategy. + public static FallbackStrategy DetermineFallbackStrategy(FontMemoryEstimate estimate) + { + if (!estimate.ExceedsLimits) + { + return FallbackStrategy.None; + } + + // Calculate how much we're over the limit + double overageRatio = (double)estimate.EstimatedBytes / CurrentConfig.MaxAtlasMemoryBytes; + + if (overageRatio > 4.0) // More than 4x over limit + { + return FallbackStrategy.MinimalFonts; + } + else if (overageRatio > 2.0) // More than 2x over limit + { + return FallbackStrategy.ReduceUnicodeRanges; + } + else if (overageRatio > 1.5) // More than 1.5x over limit + { + return FallbackStrategy.DisableEmojis; + } + else // Less than 1.5x over limit + { + return FallbackStrategy.ReduceFontSizes; + } + } + + /// + /// Gets a reduced set of font sizes based on memory constraints. + /// + /// Original font sizes to load. + /// Maximum number of sizes to include. + /// Preferred size to always include (e.g., default font size). + /// Reduced array of font sizes. + public static int[] GetReducedFontSizes(int[] originalSizes, int maxSizes, int preferredSize = 14) + { + ArgumentNullException.ThrowIfNull(originalSizes); + + if (originalSizes.Length <= maxSizes) + { + return originalSizes; + } + + maxSizes = Math.Max(CurrentConfig.MinFontSizesToLoad, maxSizes); + List selectedSizes = []; + + // Always include the preferred size + if (originalSizes.Contains(preferredSize)) + { + selectedSizes.Add(preferredSize); + } + + // Add other sizes, prioritizing medium sizes over very large or very small ones + IEnumerable remainingSizes = originalSizes.Where(s => s != preferredSize) + .OrderBy(s => Math.Abs(s - preferredSize)) // Sort by distance from preferred size + .Take(maxSizes - selectedSizes.Count); + + selectedSizes.AddRange(remainingSizes); + + return [.. selectedSizes.OrderBy(s => s)]; + } + + /// + /// Gets reduced Unicode glyph ranges for memory-constrained scenarios. + /// + /// Font atlas for building ranges. + /// Pointer to reduced glyph ranges. + public static unsafe uint* GetReducedUnicodeRanges(ImFontAtlasPtr fontAtlasPtr) + { + // Create a minimal set of ranges that still provides good functionality + ImFontGlyphRangesBuilderPtr builder = new(ImGui.ImFontGlyphRangesBuilder()); + + // Add default ranges (ASCII) - always needed + builder.AddRanges(fontAtlasPtr.GetGlyphRangesDefault()); + + // Add only the most essential extended ranges + AddEssentialLatinExtended(builder); + AddEssentialSymbols(builder); + AddEssentialNerdFontRanges(builder); + + // Build and cache the ranges + ImVector reducedRanges = new(); + builder.BuildRanges(ref reducedRanges); + + return reducedRanges.Data; + } + + /// + /// Logs memory usage information for debugging. + /// + /// Memory estimate to log. + /// Applied fallback strategy. + public static void LogMemoryUsage(FontMemoryEstimate estimate, FallbackStrategy strategy) + { + DebugLogger.Log($"FontMemoryGuard: Estimated font memory usage: {estimate.EstimatedBytes / (1024 * 1024)}MB"); + DebugLogger.Log($"FontMemoryGuard: Memory limit: {CurrentConfig.MaxAtlasMemoryBytes / (1024 * 1024)}MB"); + DebugLogger.Log($"FontMemoryGuard: Estimated glyph count: {estimate.EstimatedGlyphCount:N0}"); + + if (estimate.ExceedsLimits) + { + DebugLogger.Log($"FontMemoryGuard: Memory limit exceeded, applying fallback strategy: {strategy}"); + if (estimate.ShouldDisableEmojis) + { + DebugLogger.Log("FontMemoryGuard: Disabling emoji fonts to reduce memory usage"); + } + if (estimate.ShouldReduceUnicodeRanges) + { + DebugLogger.Log("FontMemoryGuard: Reducing Unicode ranges to reduce memory usage"); + } + if (estimate.RecommendedMaxSizes > 0) + { + DebugLogger.Log($"FontMemoryGuard: Recommending maximum {estimate.RecommendedMaxSizes} font sizes"); + } + } + else + { + DebugLogger.Log("FontMemoryGuard: Memory usage within limits, no restrictions applied"); + } + } + + #region Private Helper Methods + + private static int GetExtendedUnicodeGlyphCount() => + // Estimate glyph count for extended Unicode ranges + // This is based on the ranges defined in FontHelper.AddSymbolRanges and AddNerdFontRanges + 2000; // Conservative estimate + + private static int GetEmojiGlyphCount() => + // Estimate glyph count for emoji ranges + // This is based on the ranges defined in FontHelper.AddEmojiRanges + 3000; // Conservative estimate + + private static int CalculateRecommendedMaxSizes(long estimatedBytes, int fontCount, int baseGlyphCount, float scaleFactor) + { + if (estimatedBytes <= CurrentConfig.MaxAtlasMemoryBytes) + { + return int.MaxValue; // No limit needed + } + + // Calculate how many sizes we can fit within the memory limit + long bytesPerFontPerSize = (long)(baseGlyphCount * EstimatedBytesPerGlyph * scaleFactor * scaleFactor); + long availableBytesPerFont = CurrentConfig.MaxAtlasMemoryBytes / fontCount; + int maxSizesPerFont = (int)(availableBytesPerFont / bytesPerFontPerSize); + + return Math.Max(CurrentConfig.MinFontSizesToLoad, maxSizesPerFont); + } + + private static void AddEssentialLatinExtended(ImFontGlyphRangesBuilderPtr builder) + { + // Add only the most commonly used Latin Extended characters + // Latin Extended-A (U+0100–U+017F) - partial + for (uint c = 0x00C0; c <= 0x00FF; c++) // Latin-1 Supplement (most common) + { + builder.AddChar(c); + } + for (uint c = 0x0100; c <= 0x017F; c += 2) // Every other character in Latin Extended-A + { + builder.AddChar(c); + } + } + + private static void AddEssentialSymbols(ImFontGlyphRangesBuilderPtr builder) + { + // Add only the most essential symbols + (uint start, uint end)[] essentialRanges = [ + (0x2000, 0x206F), // General Punctuation + (0x20A0, 0x20CF), // Currency Symbols + (0x2190, 0x21FF), // Arrows + (0x2500, 0x257F), // Box Drawing + ]; + + foreach ((uint start, uint end) in essentialRanges) + { + for (uint c = start; c <= end; c += 2) // Every other character to reduce count + { + builder.AddChar(c); + } + } + } + + private static void AddEssentialNerdFontRanges(ImFontGlyphRangesBuilderPtr builder) + { + // Add only the most commonly used Nerd Font icons + (uint start, uint end)[] essentialNerdRanges = [ + (0xE0A0, 0xE0A2), // Powerline symbols + (0xE0B0, 0xE0B3), // Powerline symbols + (0xF000, 0xF0FF), // First 256 Font Awesome icons (most common) + ]; + + foreach ((uint start, uint end) in essentialNerdRanges) + { + for (uint c = start; c <= end; c++) + { + builder.AddChar(c); + } + } + } + + #endregion +} diff --git a/ImGuiApp/ImGuiApp.cs b/ImGuiApp/ImGuiApp.cs index d6527a7..c50b075 100644 --- a/ImGuiApp/ImGuiApp.cs +++ b/ImGuiApp/ImGuiApp.cs @@ -936,11 +936,11 @@ internal static void UpdateDpiScale() } } - // Using the new ImGui 1.92.0 dynamic font system - // Fonts can now be rendered at any size dynamically - no need to preload multiple sizes! + // Using the new ImGui 1.92.0 dynamic font system with memory guards + // Fonts can now be rendered at any size dynamically, but we limit memory usage for small GPUs internal static unsafe void InitFonts() { - DebugLogger.Log("InitFonts: Starting font initialization"); + DebugLogger.Log("InitFonts: Starting font initialization with memory guards"); // Only load fonts if they haven't been loaded or if scale factor has changed if (controller?.FontsConfigured == true && Math.Abs(lastFontScaleFactor - ScaleFactor) < 0.01f) @@ -954,22 +954,54 @@ internal static unsafe void InitFonts() ImGuiIOPtr io = ImGui.GetIO(); ImFontAtlasPtr fontAtlasPtr = io.Fonts; + // Initialize font memory configuration + FontMemoryGuard.CurrentConfig = Config.FontMemoryConfig; + + // Try to detect GPU memory and adjust limits + if (gl != null) + { + FontMemoryGuard.TryDetectAndConfigureGpuMemory(gl); + } + // Clear existing font data and indices fontAtlasPtr.Clear(); FontIndices.Clear(); + // Load fonts from configuration + IEnumerable> fontsToLoad = Config.Fonts.Concat(Config.DefaultFonts); + int fontCount = fontsToLoad.Count(); + + // Get emoji font data + byte[]? emojiBytes = ImGuiAppConfig.EmojiFont; + bool hasEmojiFont = emojiBytes != null; + + // Estimate memory usage and determine constraints + FontMemoryGuard.FontMemoryEstimate memoryEstimate = FontMemoryGuard.EstimateMemoryUsage( + fontCount, + CommonFontSizes, + hasEmojiFont, + Config.EnableUnicodeSupport, + ScaleFactor); + + // Determine fallback strategy + FontMemoryGuard.FallbackStrategy fallbackStrategy = FontMemoryGuard.DetermineFallbackStrategy(memoryEstimate); + + // Log memory usage information + FontMemoryGuard.LogMemoryUsage(memoryEstimate, fallbackStrategy); + + // Apply fallback constraints + int[] fontSizesToLoad = ApplyFallbackConstraints(fallbackStrategy, memoryEstimate); + bool shouldLoadEmojis = ShouldLoadEmojis(fallbackStrategy, hasEmojiFont); + bool shouldUseReducedUnicode = ShouldUseReducedUnicode(fallbackStrategy); + // Track fonts that need disposal after rebuilding the atlas List fontPinnedData = []; - - // Load fonts from configuration at multiple sizes - IEnumerable> fontsToLoad = Config.Fonts.Concat(Config.DefaultFonts); int defaultFontIndex = -1; - // Pre-allocate emoji font memory outside the loops for reuse + // Pre-allocate emoji font memory if needed nint emojiHandle = IntPtr.Zero; int emojiLength = 0; - byte[]? emojiBytes = ImGuiAppConfig.EmojiFont; - if (emojiBytes != null) + if (shouldLoadEmojis && emojiBytes != null) { emojiHandle = Marshal.AllocHGlobal(emojiBytes.Length); currentFontMemoryHandles.Add(emojiHandle); @@ -977,6 +1009,7 @@ internal static unsafe void InitFonts() emojiLength = emojiBytes.Length; } + // Load fonts with memory constraints applied foreach ((string name, byte[] fontBytes) in fontsToLoad) { // Pre-allocate main font memory outside the size loop for reuse @@ -984,12 +1017,15 @@ internal static unsafe void InitFonts() currentFontMemoryHandles.Add(fontHandle); Marshal.Copy(fontBytes, 0, fontHandle, fontBytes.Length); - foreach (int size in CommonFontSizes) + foreach (int size in fontSizesToLoad) { - LoadFontFromMemory($"{name}_{size}", fontHandle, fontBytes.Length, fontAtlasPtr, size); + // Determine glyph ranges based on constraints + uint* glyphRanges = GetConstrainedGlyphRanges(fontAtlasPtr, shouldUseReducedUnicode); + + LoadFontFromMemory($"{name}_{size}", fontHandle, fontBytes.Length, fontAtlasPtr, size, glyphRanges); // Load and merge emoji font immediately after main font for proper merging - if (emojiHandle != IntPtr.Zero) + if (shouldLoadEmojis && emojiHandle != IntPtr.Zero) { LoadEmojiFontFromMemory(emojiHandle, emojiLength, fontAtlasPtr, size); } @@ -1032,6 +1068,9 @@ internal static unsafe void InitFonts() // Build the font atlas to generate the texture ImGuiP.ImFontAtlasBuildMain(fontAtlasPtr); + // Log final atlas information + LogFontAtlasInfo(fontAtlasPtr, fallbackStrategy); + // Set the default font for ImGui rendering if (defaultFontIndex != -1 && defaultFontIndex < fontAtlasPtr.Fonts.Size) { @@ -1040,7 +1079,116 @@ internal static unsafe void InitFonts() // Store the pinned font data for later cleanup StorePinnedFontData(fontPinnedData); - DebugLogger.Log("InitFonts: Font initialization completed"); + DebugLogger.Log("InitFonts: Font initialization completed with memory guards applied"); + } + + /// + /// Applies fallback constraints to font loading based on memory limits. + /// + /// The determined fallback strategy. + /// The memory estimate for font loading. + /// Array of font sizes to load based on constraints. + internal static int[] ApplyFallbackConstraints(FontMemoryGuard.FallbackStrategy fallbackStrategy, FontMemoryGuard.FontMemoryEstimate memoryEstimate) + { + return fallbackStrategy switch + { + FontMemoryGuard.FallbackStrategy.None => CommonFontSizes, + FontMemoryGuard.FallbackStrategy.ReduceFontSizes => FontMemoryGuard.GetReducedFontSizes(CommonFontSizes, memoryEstimate.RecommendedMaxSizes, FontAppearance.DefaultFontPointSize), + FontMemoryGuard.FallbackStrategy.DisableEmojis => CommonFontSizes, + FontMemoryGuard.FallbackStrategy.ReduceUnicodeRanges => CommonFontSizes, + FontMemoryGuard.FallbackStrategy.MinimalFonts => FontMemoryGuard.GetReducedFontSizes(CommonFontSizes, FontMemoryGuard.CurrentConfig.MinFontSizesToLoad, FontAppearance.DefaultFontPointSize), + _ => CommonFontSizes + }; + } + + /// + /// Determines whether emoji fonts should be loaded based on fallback strategy. + /// + /// The determined fallback strategy. + /// Whether emoji font data is available. + /// True if emoji fonts should be loaded. + internal static bool ShouldLoadEmojis(FontMemoryGuard.FallbackStrategy fallbackStrategy, bool hasEmojiFont) + { + if (!hasEmojiFont) + { + return false; + } + + return fallbackStrategy switch + { + FontMemoryGuard.FallbackStrategy.DisableEmojis => false, + FontMemoryGuard.FallbackStrategy.ReduceUnicodeRanges => false, + FontMemoryGuard.FallbackStrategy.MinimalFonts => false, + _ => true + }; + } + + /// + /// Determines whether reduced Unicode ranges should be used based on fallback strategy. + /// + /// The determined fallback strategy. + /// True if reduced Unicode ranges should be used. + internal static bool ShouldUseReducedUnicode(FontMemoryGuard.FallbackStrategy fallbackStrategy) + { + return fallbackStrategy switch + { + FontMemoryGuard.FallbackStrategy.ReduceUnicodeRanges => true, + FontMemoryGuard.FallbackStrategy.MinimalFonts => true, + _ => false + }; + } + + /// + /// Gets the appropriate glyph ranges based on memory constraints. + /// + /// The font atlas for building ranges. + /// Whether to use reduced Unicode ranges. + /// Pointer to the appropriate glyph ranges. + internal static unsafe uint* GetConstrainedGlyphRanges(ImFontAtlasPtr fontAtlasPtr, bool useReducedUnicode) + { + if (useReducedUnicode) + { + return FontMemoryGuard.GetReducedUnicodeRanges(fontAtlasPtr); + } + else if (Config.EnableUnicodeSupport) + { + return FontHelper.GetExtendedUnicodeRanges(fontAtlasPtr); + } + else + { + return fontAtlasPtr.GetGlyphRangesDefault(); + } + } + + /// + /// Logs information about the final font atlas after building. + /// + /// The built font atlas. + /// The applied fallback strategy. + internal static unsafe void LogFontAtlasInfo(ImFontAtlasPtr fontAtlasPtr, FontMemoryGuard.FallbackStrategy fallbackStrategy) + { + // Get atlas texture information using the correct API for Hexa.NET.ImGui + ImTextureDataPtr texData = fontAtlasPtr.TexData; + int width = texData.Width; + int height = texData.Height; + int bytesPerPixel = 4; // RGBA32 = 4 bytes per pixel + long atlasMemoryBytes = (long)width * height * bytesPerPixel; + + DebugLogger.Log($"FontMemoryGuard: Final font atlas size: {width}x{height} pixels"); + DebugLogger.Log($"FontMemoryGuard: Final font atlas memory: {atlasMemoryBytes / (1024 * 1024)}MB"); + DebugLogger.Log($"FontMemoryGuard: Applied fallback strategy: {fallbackStrategy}"); + DebugLogger.Log($"FontMemoryGuard: Loaded {fontAtlasPtr.Fonts.Size} font variants"); + + // Warn if atlas is getting close to common GPU limits + if (width > 2048 || height > 2048) + { + DebugLogger.Log("FontMemoryGuard: Warning - Large font atlas may cause issues on older GPUs"); + } + + if (atlasMemoryBytes > FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes) + { + DebugLogger.Log("FontMemoryGuard: Warning - Font atlas exceeds configured memory limit"); + } } /// diff --git a/ImGuiApp/ImGuiAppConfig.cs b/ImGuiApp/ImGuiAppConfig.cs index 6de715f..f65837f 100644 --- a/ImGuiApp/ImGuiAppConfig.cs +++ b/ImGuiApp/ImGuiAppConfig.cs @@ -98,6 +98,12 @@ public class ImGuiAppConfig /// public ImGuiAppPerformanceSettings PerformanceSettings { get; init; } = new(); + /// + /// Gets or sets the font memory configuration for limiting texture memory allocation. + /// This helps prevent excessive memory usage on small GPUs or high-resolution displays. + /// + public FontMemoryGuard.FontMemoryConfig FontMemoryConfig { get; init; } = new(); + internal Dictionary DefaultFonts { get; init; } = new Dictionary { { "default", Resources.Resources.NerdFont} From 03158ca979cdd1cc7fc57e48d31bf8e367e33e83 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Wed, 6 Aug 2025 09:29:49 +1000 Subject: [PATCH 2/4] Refactor FontMemoryGuard: Changed access modifiers for GPU detection methods to public, enhancing accessibility. Updated integrated GPU detection patterns for AMD and Intel, and improved memory limit calculations for both GPU types. Added comprehensive unit tests for FontMemoryGuard functionality, ensuring robust memory estimation and GPU detection. --- ImGuiApp.Test/FontMemoryGuardTests.cs | 450 ++++++++++++++++++++++++++ ImGuiApp/FontMemoryGuard.cs | 34 +- 2 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 ImGuiApp.Test/FontMemoryGuardTests.cs diff --git a/ImGuiApp.Test/FontMemoryGuardTests.cs b/ImGuiApp.Test/FontMemoryGuardTests.cs new file mode 100644 index 0000000..64c18ca --- /dev/null +++ b/ImGuiApp.Test/FontMemoryGuardTests.cs @@ -0,0 +1,450 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGuiApp.Test; + +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for FontMemoryGuard functionality including memory estimation, GPU detection, +/// and Intel/AMD integrated GPU support. +/// +[TestClass] +public class FontMemoryGuardTests +{ + [TestInitialize] + public void Setup() + { + ImGuiApp.Reset(); + // Reset FontMemoryGuard configuration to defaults + FontMemoryGuard.CurrentConfig = new FontMemoryGuard.FontMemoryConfig(); + } + + #region FontMemoryEstimate Tests + + [TestMethod] + public void FontMemoryEstimate_Equality_WorksCorrectly() + { + FontMemoryGuard.FontMemoryEstimate estimate1 = new() + { + EstimatedBytes = 1024, + EstimatedGlyphCount = 100, + ExceedsLimits = false, + RecommendedMaxSizes = 5, + ShouldDisableEmojis = false, + ShouldReduceUnicodeRanges = false + }; + + FontMemoryGuard.FontMemoryEstimate estimate2 = new() + { + EstimatedBytes = 1024, + EstimatedGlyphCount = 100, + ExceedsLimits = false, + RecommendedMaxSizes = 5, + ShouldDisableEmojis = false, + ShouldReduceUnicodeRanges = false + }; + + FontMemoryGuard.FontMemoryEstimate estimate3 = new() + { + EstimatedBytes = 2048, + EstimatedGlyphCount = 100, + ExceedsLimits = false, + RecommendedMaxSizes = 5, + ShouldDisableEmojis = false, + ShouldReduceUnicodeRanges = false + }; + + // Test equality + Assert.AreEqual(estimate1, estimate2); + Assert.AreNotEqual(estimate1, estimate3); + + // Test operators + Assert.IsTrue(estimate1 == estimate2); + Assert.IsFalse(estimate1 == estimate3); + Assert.IsFalse(estimate1 != estimate2); + Assert.IsTrue(estimate1 != estimate3); + + // Test Equals with object + Assert.IsTrue(estimate1.Equals((object)estimate2)); + Assert.IsFalse(estimate1.Equals((object)estimate3)); + Assert.IsFalse(estimate1.Equals(null)); + Assert.IsFalse(estimate1.Equals("not an estimate")); + } + + [TestMethod] + public void FontMemoryEstimate_GetHashCode_ConsistentForEqualObjects() + { + FontMemoryGuard.FontMemoryEstimate estimate1 = new() + { + EstimatedBytes = 1024, + EstimatedGlyphCount = 100, + ExceedsLimits = true, + RecommendedMaxSizes = 3, + ShouldDisableEmojis = true, + ShouldReduceUnicodeRanges = false + }; + + FontMemoryGuard.FontMemoryEstimate estimate2 = new() + { + EstimatedBytes = 1024, + EstimatedGlyphCount = 100, + ExceedsLimits = true, + RecommendedMaxSizes = 3, + ShouldDisableEmojis = true, + ShouldReduceUnicodeRanges = false + }; + + Assert.AreEqual(estimate1.GetHashCode(), estimate2.GetHashCode()); + } + + #endregion + + #region EstimateMemoryUsage Tests + + [TestMethod] + public void EstimateMemoryUsage_ValidParameters_ReturnsValidEstimate() + { + int[] fontSizes = [12, 14, 16, 18, 20]; + + FontMemoryGuard.FontMemoryEstimate estimate = FontMemoryGuard.EstimateMemoryUsage( + fontCount: 2, + fontSizes: fontSizes, + includeEmojis: true, + includeExtendedUnicode: true, + scaleFactor: 1.0f); + + Assert.IsTrue(estimate.EstimatedBytes > 0); + Assert.IsTrue(estimate.EstimatedGlyphCount > 0); + Assert.IsTrue(estimate.RecommendedMaxSizes > 0); + } + + [TestMethod] + public void EstimateMemoryUsage_HighScaleFactor_IncreasesMemoryEstimate() + { + int[] fontSizes = [14, 16, 18]; + + FontMemoryGuard.FontMemoryEstimate lowScaleEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, false, false, 1.0f); + FontMemoryGuard.FontMemoryEstimate highScaleEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, false, false, 2.0f); + + Assert.IsTrue(highScaleEstimate.EstimatedBytes > lowScaleEstimate.EstimatedBytes); + } + + [TestMethod] + public void EstimateMemoryUsage_WithEmojisAndUnicode_IncreasesGlyphCount() + { + int[] fontSizes = [14]; + + FontMemoryGuard.FontMemoryEstimate basicEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, false, false, 1.0f); + FontMemoryGuard.FontMemoryEstimate unicodeEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, false, true, 1.0f); + FontMemoryGuard.FontMemoryEstimate emojiEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, true, false, 1.0f); + FontMemoryGuard.FontMemoryEstimate fullEstimate = FontMemoryGuard.EstimateMemoryUsage(1, fontSizes, true, true, 1.0f); + + Assert.IsTrue(unicodeEstimate.EstimatedGlyphCount > basicEstimate.EstimatedGlyphCount); + Assert.IsTrue(emojiEstimate.EstimatedGlyphCount > basicEstimate.EstimatedGlyphCount); + Assert.IsTrue(fullEstimate.EstimatedGlyphCount > unicodeEstimate.EstimatedGlyphCount); + Assert.IsTrue(fullEstimate.EstimatedGlyphCount > emojiEstimate.EstimatedGlyphCount); + } + + [TestMethod] + public void EstimateMemoryUsage_NullFontSizes_ThrowsArgumentNullException() + { + Assert.ThrowsExactly(() => + FontMemoryGuard.EstimateMemoryUsage(1, null!, false, false, 1.0f)); + } + + [TestMethod] + public void EstimateMemoryUsage_ExceedsLimits_SetsCorrectFlags() + { + // Set a very low memory limit to trigger limit exceeded + FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes = 1024; // 1KB - very small + + int[] largeFontSizes = [12, 14, 16, 18, 20, 24, 32, 48]; // Many sizes + + FontMemoryGuard.FontMemoryEstimate estimate = FontMemoryGuard.EstimateMemoryUsage( + fontCount: 3, // Multiple fonts + fontSizes: largeFontSizes, + includeEmojis: true, + includeExtendedUnicode: true, + scaleFactor: 2.0f); // High DPI + + Assert.IsTrue(estimate.ExceedsLimits, "Should exceed the very low memory limit"); + } + + #endregion + + #region GetReducedFontSizes Tests + + [TestMethod] + public void GetReducedFontSizes_FewerThanMax_ReturnsOriginal() + { + int[] originalSizes = [12, 14, 16]; + int[] result = FontMemoryGuard.GetReducedFontSizes(originalSizes, 5, 14); + + CollectionAssert.AreEqual(originalSizes, result); + } + + [TestMethod] + public void GetReducedFontSizes_MoreThanMax_ReducesToMax() + { + int[] originalSizes = [10, 12, 14, 16, 18, 20, 24, 32, 48]; + int[] result = FontMemoryGuard.GetReducedFontSizes(originalSizes, 3, 14); + + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result.Contains(14), "Should always include preferred size"); + } + + [TestMethod] + public void GetReducedFontSizes_PrioritizesPreferredSize() + { + int[] originalSizes = [10, 12, 14, 16, 18, 20, 24, 32, 48]; + int[] result = FontMemoryGuard.GetReducedFontSizes(originalSizes, 4, 16); + + Assert.IsTrue(result.Contains(16), "Should include preferred size 16"); + Assert.AreEqual(4, result.Length); + } + + [TestMethod] + public void GetReducedFontSizes_RespectsMinimumSizes() + { + FontMemoryGuard.CurrentConfig.MinFontSizesToLoad = 2; + int[] originalSizes = [12, 14, 16, 18, 20]; + int[] result = FontMemoryGuard.GetReducedFontSizes(originalSizes, 1, 14); // Request only 1, but min is 2 + + Assert.IsTrue(result.Length >= 2, "Should respect minimum font sizes setting"); + } + + [TestMethod] + public void GetReducedFontSizes_NullOriginalSizes_ThrowsArgumentNullException() + { + Assert.ThrowsExactly(() => + FontMemoryGuard.GetReducedFontSizes(null!, 3, 14)); + } + + [TestMethod] + public void GetReducedFontSizes_ResultIsSorted() + { + int[] originalSizes = [48, 12, 20, 14, 16]; // Unsorted input + int[] result = FontMemoryGuard.GetReducedFontSizes(originalSizes, 3, 14); + + for (int i = 1; i < result.Length; i++) + { + Assert.IsTrue(result[i] > result[i - 1], "Result should be sorted in ascending order"); + } + } + + #endregion + + #region DetermineFallbackStrategy Tests + + [TestMethod] + public void DetermineFallbackStrategy_NoLimitsExceeded_ReturnsNone() + { + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + ExceedsLimits = false, + EstimatedBytes = 1024 + }; + + FontMemoryGuard.FallbackStrategy strategy = FontMemoryGuard.DetermineFallbackStrategy(estimate); + Assert.AreEqual(FontMemoryGuard.FallbackStrategy.None, strategy); + } + + [TestMethod] + public void DetermineFallbackStrategy_SlightOverage_ReturnsReduceFontSizes() + { + FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes = 1024; + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + ExceedsLimits = true, + EstimatedBytes = 1200 // 1.17x over limit + }; + + FontMemoryGuard.FallbackStrategy strategy = FontMemoryGuard.DetermineFallbackStrategy(estimate); + Assert.AreEqual(FontMemoryGuard.FallbackStrategy.ReduceFontSizes, strategy); + } + + [TestMethod] + public void DetermineFallbackStrategy_ModerateOverage_ReturnsDisableEmojis() + { + FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes = 1024; + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + ExceedsLimits = true, + EstimatedBytes = 1700 // 1.66x over limit + }; + + FontMemoryGuard.FallbackStrategy strategy = FontMemoryGuard.DetermineFallbackStrategy(estimate); + Assert.AreEqual(FontMemoryGuard.FallbackStrategy.DisableEmojis, strategy); + } + + [TestMethod] + public void DetermineFallbackStrategy_HighOverage_ReturnsReduceUnicodeRanges() + { + FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes = 1024; + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + ExceedsLimits = true, + EstimatedBytes = 2500 // 2.44x over limit + }; + + FontMemoryGuard.FallbackStrategy strategy = FontMemoryGuard.DetermineFallbackStrategy(estimate); + Assert.AreEqual(FontMemoryGuard.FallbackStrategy.ReduceUnicodeRanges, strategy); + } + + [TestMethod] + public void DetermineFallbackStrategy_ExtremeOverage_ReturnsMinimalFonts() + { + FontMemoryGuard.CurrentConfig.MaxAtlasMemoryBytes = 1024; + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + ExceedsLimits = true, + EstimatedBytes = 5000 // 4.88x over limit + }; + + FontMemoryGuard.FallbackStrategy strategy = FontMemoryGuard.DetermineFallbackStrategy(estimate); + Assert.AreEqual(FontMemoryGuard.FallbackStrategy.MinimalFonts, strategy); + } + + #endregion + + #region GPU Detection Tests + + [TestMethod] + public void TryDetectAndConfigureGpuMemory_NullGL_ReturnsFalse() + { + bool result = FontMemoryGuard.TryDetectAndConfigureGpuMemory(null!); + Assert.IsFalse(result); + } + + [TestMethod] + public void TryDetectAndConfigureGpuMemory_DetectionDisabled_ReturnsFalse() + { + FontMemoryGuard.CurrentConfig.EnableGpuMemoryDetection = false; + + // Since detection is disabled, the method should return false regardless of GL + bool result = FontMemoryGuard.TryDetectAndConfigureGpuMemory(null!); + Assert.IsFalse(result); + } + + #endregion + + #region GPU Heuristics Unit Tests (without actual GL calls) + + [TestMethod] + public void FontMemoryGuard_IsIntegratedGpu_DetectsIntelCorrectly() + { + // Test Intel GPU detection patterns + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("Intel(R) HD Graphics 530")); + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("Intel(R) UHD Graphics 620")); + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("Intel(R) Xe Graphics")); + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("Intel(R) Iris(R) Xe Graphics")); + + // Should not detect discrete GPUs as integrated + Assert.IsFalse(FontMemoryGuard.IsIntegratedGpu("NVIDIA GeForce RTX 3060")); + Assert.IsFalse(FontMemoryGuard.IsIntegratedGpu("AMD Radeon RX 6600 XT")); + } + + [TestMethod] + public void FontMemoryGuard_IsIntegratedGpu_DetectsAmdApuCorrectly() + { + // Test AMD APU detection patterns + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("AMD Radeon(TM) Vega 8 Graphics")); + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("AMD Radeon(TM) 680M")); + Assert.IsTrue(FontMemoryGuard.IsIntegratedGpu("AMD Radeon(TM) R7 Graphics")); + + // Should not detect discrete GPUs as integrated + Assert.IsFalse(FontMemoryGuard.IsIntegratedGpu("AMD Radeon RX 6600 XT")); + Assert.IsFalse(FontMemoryGuard.IsIntegratedGpu("AMD Radeon RX 7900 XTX")); + } + + [TestMethod] + public void FontMemoryGuard_GetIntelGpuMemoryLimit_ReturnsCorrectLimits() + { + // Test Intel GPU generation-based memory limits + Assert.AreEqual(96 * 1024 * 1024, FontMemoryGuard.GetIntelGpuMemoryLimit("Intel(R) Xe Graphics")); + Assert.AreEqual(80 * 1024 * 1024, FontMemoryGuard.GetIntelGpuMemoryLimit("Intel(R) Iris(R) Xe Graphics")); + Assert.AreEqual(64 * 1024 * 1024, FontMemoryGuard.GetIntelGpuMemoryLimit("Intel(R) UHD Graphics 620")); + Assert.AreEqual(32 * 1024 * 1024, FontMemoryGuard.GetIntelGpuMemoryLimit("Intel(R) HD Graphics 530")); + + // Default for unrecognized Intel GPUs + Assert.AreEqual(32 * 1024 * 1024, FontMemoryGuard.GetIntelGpuMemoryLimit("Intel(R) Unknown Graphics")); + } + + [TestMethod] + public void FontMemoryGuard_GetAmdApuMemoryLimit_ReturnsCorrectLimits() + { + // Test AMD APU generation-based memory limits + Assert.AreEqual(128 * 1024 * 1024, FontMemoryGuard.GetAmdApuMemoryLimit("AMD Radeon(TM) 680M")); + Assert.AreEqual(96 * 1024 * 1024, FontMemoryGuard.GetAmdApuMemoryLimit("AMD Radeon(TM) Vega 8 Graphics")); + Assert.AreEqual(48 * 1024 * 1024, FontMemoryGuard.GetAmdApuMemoryLimit("AMD Radeon(TM) R7 Graphics")); + + // Default for unrecognized AMD APUs + Assert.AreEqual(64 * 1024 * 1024, FontMemoryGuard.GetAmdApuMemoryLimit("AMD Radeon(TM) Unknown APU")); + } + + #endregion + + #region Configuration Tests + + [TestMethod] + public void FontMemoryConfig_DefaultValues_AreReasonable() + { + FontMemoryGuard.FontMemoryConfig config = new(); + + Assert.AreEqual(FontMemoryGuard.DefaultMaxAtlasMemoryBytes, config.MaxAtlasMemoryBytes); + Assert.IsTrue(config.EnableGpuMemoryDetection); + Assert.AreEqual(0.1f, config.MaxGpuMemoryPercentage); + Assert.IsTrue(config.EnableFallbackStrategies); + Assert.AreEqual(3, config.MinFontSizesToLoad); + Assert.IsTrue(config.DisableEmojisOnLowMemory); + Assert.IsTrue(config.ReduceUnicodeRangesOnLowMemory); + Assert.IsTrue(config.EnableIntelGpuHeuristics); + Assert.IsTrue(config.EnableAmdApuHeuristics); + } + + [TestMethod] + public void FontMemoryGuard_Constants_HaveExpectedValues() + { + Assert.AreEqual(64 * 1024 * 1024, FontMemoryGuard.DefaultMaxAtlasMemoryBytes); + Assert.AreEqual(8 * 1024 * 1024, FontMemoryGuard.MinAtlasMemoryBytes); + Assert.AreEqual(4096, FontMemoryGuard.MaxAtlasTextureDimension); + Assert.AreEqual(128, FontMemoryGuard.EstimatedBytesPerGlyph); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + public void LogMemoryUsage_DoesNotThrow() + { + FontMemoryGuard.FontMemoryEstimate estimate = new() + { + EstimatedBytes = 1024, + EstimatedGlyphCount = 100, + ExceedsLimits = true + }; + + try + { + FontMemoryGuard.LogMemoryUsage(estimate, FontMemoryGuard.FallbackStrategy.ReduceFontSizes); + } + catch (ArgumentException ex) + { + Assert.Fail($"LogMemoryUsage should not throw ArgumentException, but got: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + Assert.Fail($"LogMemoryUsage should not throw InvalidOperationException, but got: {ex.Message}"); + } + catch (System.ComponentModel.Win32Exception ex) + { + Assert.Fail($"LogMemoryUsage should not throw Win32Exception, but got: {ex.Message}"); + } + } + + #endregion +} diff --git a/ImGuiApp/FontMemoryGuard.cs b/ImGuiApp/FontMemoryGuard.cs index e7fa1b0..67d0b50 100644 --- a/ImGuiApp/FontMemoryGuard.cs +++ b/ImGuiApp/FontMemoryGuard.cs @@ -366,7 +366,7 @@ public static unsafe bool TryDetectAndConfigureGpuMemory(GL gl) /// /// GPU renderer string from OpenGL. /// True if the GPU appears to be integrated. - private static bool IsIntegratedGpu(string renderer) + public static bool IsIntegratedGpu(string renderer) { if (string.IsNullOrEmpty(renderer)) { @@ -382,7 +382,7 @@ private static bool IsIntegratedGpu(string renderer) // AMD integrated GPU patterns string[] amdIntegratedPatterns = [ - "radeon(tm) graphics", "vega", "rdna", "apu" + "radeon(tm)", "vega", "rdna", "apu", "r5 graphics", "r7 graphics" ]; // General integrated GPU indicators @@ -416,13 +416,13 @@ private static bool ApplyIntegratedGpuHeuristics(bool isIntelGpu, bool isAmdGpu, if (isIntelGpu) { // Intel integrated GPU memory recommendations - recommendedMemory = AnalyzeIntelGpuGeneration(renderer); + recommendedMemory = GetIntelGpuMemoryLimit(renderer); DebugLogger.Log($"FontMemoryGuard: Applied Intel integrated GPU heuristics: {recommendedMemory / (1024 * 1024)}MB limit"); } else if (isAmdGpu && isIntegratedGpu) { // AMD integrated GPU (APU) memory recommendations - recommendedMemory = AnalyzeAmdApuGeneration(renderer); + recommendedMemory = GetAmdApuMemoryLimit(renderer); DebugLogger.Log($"FontMemoryGuard: Applied AMD integrated GPU heuristics: {recommendedMemory / (1024 * 1024)}MB limit"); } else if (isIntegratedGpu) @@ -443,11 +443,11 @@ private static bool ApplyIntegratedGpuHeuristics(bool isIntelGpu, bool isAmdGpu, } /// - /// Analyzes Intel GPU generation from renderer string to determine appropriate memory limits. + /// Gets the recommended memory limit for Intel integrated graphics based on the renderer string. /// - /// GPU renderer string. + /// The Intel GPU renderer string. /// Recommended memory limit in bytes. - private static long AnalyzeIntelGpuGeneration(string renderer) + public static long GetIntelGpuMemoryLimit(string renderer) { if (string.IsNullOrEmpty(renderer)) { @@ -456,6 +456,12 @@ private static long AnalyzeIntelGpuGeneration(string renderer) string rendererLower = renderer.ToLowerInvariant(); + // Iris graphics (higher end integrated) - check first before Xe to get correct priority + if (rendererLower.Contains("iris")) + { + return 80 * 1024 * 1024; // 80MB - Iris has better performance + } + // Modern Intel GPUs (12th gen+, Xe Graphics) if (rendererLower.Contains("xe") || rendererLower.Contains("arc")) { @@ -486,22 +492,16 @@ private static long AnalyzeIntelGpuGeneration(string renderer) } } - // Iris graphics (higher end integrated) - if (rendererLower.Contains("iris")) - { - return 80 * 1024 * 1024; // 80MB - Iris has better performance - } - // Default for unknown Intel GPU return 32 * 1024 * 1024; // 32MB conservative } /// - /// Analyzes AMD APU generation from renderer string to determine appropriate memory limits. + /// Gets the recommended memory limit for AMD APU graphics based on the renderer string. /// - /// GPU renderer string. + /// The AMD APU renderer string. /// Recommended memory limit in bytes. - private static long AnalyzeAmdApuGeneration(string renderer) + public static long GetAmdApuMemoryLimit(string renderer) { if (string.IsNullOrEmpty(renderer)) { @@ -511,7 +511,7 @@ private static long AnalyzeAmdApuGeneration(string renderer) string rendererLower = renderer.ToLowerInvariant(); // Modern RDNA2/3 APUs (6000+ series, Steam Deck) - if (rendererLower.Contains("rdna") || rendererLower.Contains("6600") || rendererLower.Contains("6800") || rendererLower.Contains("7600")) + if (rendererLower.Contains("rdna") || rendererLower.Contains("6600") || rendererLower.Contains("6800") || rendererLower.Contains("7600") || rendererLower.Contains("680m")) { return 128 * 1024 * 1024; // 128MB - Modern AMD APUs are quite capable } From 4b0544c0852d0def638bd9b999ad879b59f94541 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 7 Aug 2025 14:14:08 +1000 Subject: [PATCH 3/4] Update project configurations: Added TargetFrameworks for ImGuiApp, ImGuiApp.Test, and ImGuiAppDemo projects to support .NET 8.0 and 9.0. Reintroduced coverlet.collector dependency in test dependencies section of Directory.Packages.props. Updated ktsu SDK versions to 1.56.0 across all projects for improved functionality. --- Directory.Packages.props | 10 +++++----- ImGuiApp.Test/ImGuiApp.Test.csproj | 1 + ImGuiApp/ImGuiApp.csproj | 1 + ImGuiAppDemo/ImGuiAppDemo.csproj | 1 + global.json | 14 +++++++------- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0c65d9e..df0aa04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,10 +4,6 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - @@ -24,6 +20,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -40,4 +40,4 @@ - \ No newline at end of file + diff --git a/ImGuiApp.Test/ImGuiApp.Test.csproj b/ImGuiApp.Test/ImGuiApp.Test.csproj index 88a9665..65fc55d 100644 --- a/ImGuiApp.Test/ImGuiApp.Test.csproj +++ b/ImGuiApp.Test/ImGuiApp.Test.csproj @@ -1,6 +1,7 @@ + net9.0; true diff --git a/ImGuiApp/ImGuiApp.csproj b/ImGuiApp/ImGuiApp.csproj index 2ac3da5..e1608bf 100644 --- a/ImGuiApp/ImGuiApp.csproj +++ b/ImGuiApp/ImGuiApp.csproj @@ -1,6 +1,7 @@ + net9.0;net8.0 true $(NoWarn);CA5392; diff --git a/ImGuiAppDemo/ImGuiAppDemo.csproj b/ImGuiAppDemo/ImGuiAppDemo.csproj index d1dd612..12d985f 100644 --- a/ImGuiAppDemo/ImGuiAppDemo.csproj +++ b/ImGuiAppDemo/ImGuiAppDemo.csproj @@ -1,5 +1,6 @@ + net9.0;net8.0 true diff --git a/global.json b/global.json index af0efba..e44410d 100644 --- a/global.json +++ b/global.json @@ -4,12 +4,12 @@ "rollForward": "latestFeature" }, "msbuild-sdks": { - "ktsu.Sdk": "1.48.0", - "ktsu.Sdk.Lib": "1.48.0", - "ktsu.Sdk.ConsoleApp": "1.48.0", - "ktsu.Sdk.Test": "1.48.0", - "ktsu.Sdk.ImGuiApp": "1.48.0", - "ktsu.Sdk.WinApp": "1.48.0", - "ktsu.Sdk.WinTest": "1.48.0" + "ktsu.Sdk": "1.56.0", + "ktsu.Sdk.Lib": "1.56.0", + "ktsu.Sdk.ConsoleApp": "1.56.0", + "ktsu.Sdk.Test": "1.56.0", + "ktsu.Sdk.ImGuiApp": "1.56.0", + "ktsu.Sdk.WinApp": "1.56.0", + "ktsu.Sdk.WinTest": "1.56.0" } } From 30d53a313a45b6ef85d5b13f827211860dc58ea5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:41:26 +0000 Subject: [PATCH 4/4] Initial plan