diff --git a/Directory.Packages.props b/Directory.Packages.props index de08a5d..df0aa04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,11 +4,7 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + @@ -21,9 +17,13 @@ - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -33,11 +33,11 @@ - - + + - \ No newline at end of file + 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.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.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..67d0b50 --- /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. + public 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)", "vega", "rdna", "apu", "r5 graphics", "r7 graphics" + ]; + + // 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 = 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 = GetAmdApuMemoryLimit(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; + } + + /// + /// Gets the recommended memory limit for Intel integrated graphics based on the renderer string. + /// + /// The Intel GPU renderer string. + /// Recommended memory limit in bytes. + public static long GetIntelGpuMemoryLimit(string renderer) + { + if (string.IsNullOrEmpty(renderer)) + { + return 32 * 1024 * 1024; // Conservative 32MB for unknown Intel GPU + } + + 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")) + { + 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 + } + } + + // Default for unknown Intel GPU + return 32 * 1024 * 1024; // 32MB conservative + } + + /// + /// Gets the recommended memory limit for AMD APU graphics based on the renderer string. + /// + /// The AMD APU renderer string. + /// Recommended memory limit in bytes. + public static long GetAmdApuMemoryLimit(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") || rendererLower.Contains("680m")) + { + 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/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/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} 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" } }