Dalamud/Dalamud/Interface/ManagedFontAtlas/Internals/FontAtlasFactory.Implementation.cs

803 lines
28 KiB
C#

// #define VeryVerboseLog
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reactive.Disposables;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.Interface.GameFonts;
using Dalamud.Interface.Internal;
using Dalamud.Interface.Utility;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
using ImGuiNET;
using JetBrains.Annotations;
namespace Dalamud.Interface.ManagedFontAtlas.Internals;
/// <summary>
/// Standalone font atlas.
/// </summary>
internal sealed partial class FontAtlasFactory
{
/// <summary>
/// Fallback codepoints for ImFont.
/// </summary>
public const string FallbackCodepoints = "\u3013\uFFFD?-";
/// <summary>
/// Ellipsis codepoints for ImFont.
/// </summary>
public const string EllipsisCodepoints = "\u2026\u0085";
/// <summary>
/// If set, disables concurrent font build operation.
/// </summary>
private static readonly object? NoConcurrentBuildOperationLock = null; // new();
private static readonly ModuleLog Log = new(nameof(FontAtlasFactory));
private static readonly Task<FontAtlasBuiltData> EmptyTask = Task.FromResult(default(FontAtlasBuiltData));
private class FontAtlasBuiltData : IRefCountable
{
// Field for debugging.
private static int numActiveInstances;
private readonly List<IDalamudTextureWrap> wraps;
private readonly List<IFontHandleSubstance> substances;
private int refCount;
public unsafe FontAtlasBuiltData(DalamudFontAtlas owner, float scale)
{
this.Owner = owner;
this.Scale = scale;
this.Garbage = new();
this.refCount = 1;
try
{
var substancesList = this.substances = new();
this.Garbage.Add(() => substancesList.Clear());
var wrapsCopy = this.wraps = new();
this.Garbage.Add(() => wrapsCopy.Clear());
var atlasPtr = ImGuiNative.ImFontAtlas_ImFontAtlas();
this.Atlas = atlasPtr;
if (this.Atlas.NativePtr is null)
throw new OutOfMemoryException($"Failed to allocate a new {nameof(ImFontAtlas)}.");
this.Garbage.Add(() => ImGuiNative.ImFontAtlas_destroy(atlasPtr));
this.IsBuildInProgress = true;
Interlocked.Increment(ref numActiveInstances);
this.Garbage.Add(() => Interlocked.Decrement(ref numActiveInstances));
}
catch
{
this.Garbage.Dispose();
throw;
}
}
public DalamudFontAtlas? Owner { get; }
public ImFontAtlasPtr Atlas { get; }
public float Scale { get; }
public bool IsBuildInProgress { get; set; }
public DisposeSafety.ScopedFinalizer Garbage { get; }
public ImVectorWrapper<ImFontPtr> Fonts => this.Atlas.FontsWrapped();
public ImVectorWrapper<ImFontConfig> ConfigData => this.Atlas.ConfigDataWrapped();
public ImVectorWrapper<ImFontAtlasTexture> ImTextures => this.Atlas.TexturesWrapped();
public IReadOnlyList<IDalamudTextureWrap> Wraps => this.wraps;
public IReadOnlyList<IFontHandleSubstance> Substances => this.substances;
public void InitialAddSubstance(IFontHandleSubstance substance) =>
this.substances.Add(this.Garbage.Add(substance));
public void AddExistingTexture(IDalamudTextureWrap wrap)
{
if (this.wraps is null)
throw new ObjectDisposedException(nameof(FontAtlasBuiltData));
this.wraps.Add(this.Garbage.Add(wrap));
}
public int AddNewTexture(IDalamudTextureWrap wrap, bool disposeOnError)
{
if (this.wraps is null)
throw new ObjectDisposedException(nameof(FontAtlasBuiltData));
var handle = wrap.ImGuiHandle;
var index = this.ImTextures.IndexOf(x => x.TexID == handle);
if (index == -1)
{
try
{
this.wraps.EnsureCapacity(this.wraps.Count + 1);
this.ImTextures.EnsureCapacityExponential(this.ImTextures.Length + 1);
index = this.ImTextures.Length;
this.wraps.Add(this.Garbage.Add(wrap));
this.ImTextures.Add(new() { TexID = handle });
}
catch (Exception e)
{
if (disposeOnError)
wrap.Dispose();
if (this.wraps.Count != this.ImTextures.Length)
{
Log.Error(
e,
"{name} failed, and {wraps} and {imtextures} have different number of items",
nameof(this.AddNewTexture),
nameof(this.Wraps),
nameof(this.ImTextures));
if (this.wraps.Count > 0 && this.wraps[^1] == wrap)
this.wraps.RemoveAt(this.wraps.Count - 1);
if (this.ImTextures.Length > 0 && this.ImTextures[^1].TexID == handle)
this.ImTextures.RemoveAt(this.ImTextures.Length - 1);
if (this.wraps.Count != this.ImTextures.Length)
Log.Fatal("^ Failed to undo due to an internal inconsistency; embrace for a crash");
}
throw;
}
}
return index;
}
public int AddRef() => IRefCountable.AlterRefCount(1, ref this.refCount, out var newRefCount) switch
{
IRefCountable.RefCountResult.StillAlive => newRefCount,
IRefCountable.RefCountResult.AlreadyDisposed =>
throw new ObjectDisposedException(nameof(FontAtlasBuiltData)),
IRefCountable.RefCountResult.FinalRelease => throw new InvalidOperationException(),
_ => throw new InvalidOperationException(),
};
public int Release()
{
switch (IRefCountable.AlterRefCount(-1, ref this.refCount, out var newRefCount))
{
case IRefCountable.RefCountResult.StillAlive:
return newRefCount;
case IRefCountable.RefCountResult.FinalRelease:
#if VeryVerboseLog
Log.Verbose("[{name}] 0x{ptr:X}: Disposing", this.Owner?.Name ?? "<?>", (nint)this.Atlas.NativePtr);
#endif
if (this.IsBuildInProgress)
{
unsafe
{
Log.Error(
"[{name}] 0x{ptr:X}: Trying to dispose while build is in progress; disposing later.\n" +
"Stack:\n{trace}",
this.Owner?.Name ?? "<?>",
(nint)this.Atlas.NativePtr,
new StackTrace());
}
Task.Run(
async () =>
{
while (this.IsBuildInProgress)
await Task.Delay(100);
this.Garbage.Dispose();
});
}
else
{
this.Garbage.Dispose();
}
return newRefCount;
case IRefCountable.RefCountResult.AlreadyDisposed:
throw new ObjectDisposedException(nameof(FontAtlasBuiltData));
default:
throw new InvalidOperationException();
}
}
public BuildToolkit CreateToolkit(FontAtlasFactory factory, bool isAsync)
{
var axisSubstance = this.Substances.OfType<GamePrebakedFontHandle.HandleSubstance>().Single();
return new(factory, this, axisSubstance, isAsync) { BuildStep = FontAtlasBuildStep.PreBuild };
}
}
private class DalamudFontAtlas : IFontAtlas, DisposeSafety.IDisposeCallback
{
private readonly DisposeSafety.ScopedFinalizer disposables = new();
private readonly FontAtlasFactory factory;
private readonly DelegateFontHandle.HandleManager delegateFontHandleManager;
private readonly GamePrebakedFontHandle.HandleManager gameFontHandleManager;
private readonly IFontHandleManager[] fontHandleManagers;
private readonly object syncRoot = new();
private Task<FontAtlasBuiltData?> buildTask = EmptyTask;
private FontAtlasBuiltData? builtData;
private int buildSuppressionCounter;
private bool buildSuppressionSuppressed;
private int buildIndex;
private bool buildQueued;
private bool disposed;
/// <summary>
/// Initializes a new instance of the <see cref="DalamudFontAtlas"/> class.
/// </summary>
/// <param name="factory">The factory.</param>
/// <param name="atlasName">Name of atlas, for debugging and logging purposes.</param>
/// <param name="autoRebuildMode">Specify how to auto rebuild.</param>
/// <param name="isGlobalScaled">Whether the fonts in the atlas are under the effect of global scale.</param>
public DalamudFontAtlas(
FontAtlasFactory factory,
string atlasName,
FontAtlasAutoRebuildMode autoRebuildMode,
bool isGlobalScaled)
{
this.IsGlobalScaled = isGlobalScaled;
try
{
this.factory = factory;
this.AutoRebuildMode = autoRebuildMode;
this.Name = atlasName;
this.factory.InterfaceManager.AfterBuildFonts += this.OnRebuildRecommend;
this.disposables.Add(() => this.factory.InterfaceManager.AfterBuildFonts -= this.OnRebuildRecommend);
this.fontHandleManagers = new IFontHandleManager[]
{
this.delegateFontHandleManager = this.disposables.Add(
new DelegateFontHandle.HandleManager(atlasName)),
this.gameFontHandleManager = this.disposables.Add(
new GamePrebakedFontHandle.HandleManager(atlasName, factory)),
};
foreach (var fhm in this.fontHandleManagers)
fhm.RebuildRecommend += this.OnRebuildRecommend;
}
catch
{
this.disposables.Dispose();
throw;
}
this.factory.SceneTask.ContinueWith(
r =>
{
lock (this.syncRoot)
{
if (this.disposed)
return;
r.Result.OnNewRenderFrame += this.ImGuiSceneOnNewRenderFrame;
this.disposables.Add(() => r.Result.OnNewRenderFrame -= this.ImGuiSceneOnNewRenderFrame);
}
if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame)
this.BuildFontsOnNextFrame();
});
}
/// <summary>
/// Finalizes an instance of the <see cref="DalamudFontAtlas"/> class.
/// </summary>
~DalamudFontAtlas()
{
lock (this.syncRoot)
{
this.buildTask.ToDisposableIgnoreExceptions().Dispose();
this.builtData?.Release();
this.builtData = null;
}
}
/// <inheritdoc/>
public event FontAtlasBuildStepDelegate? BuildStepChange;
/// <inheritdoc/>
public event Action? RebuildRecommend;
/// <inheritdoc/>
public event Action<DisposeSafety.IDisposeCallback>? BeforeDispose;
/// <inheritdoc/>
public event Action<DisposeSafety.IDisposeCallback, Exception?>? AfterDispose;
/// <inheritdoc/>
public string Name { get; }
/// <inheritdoc/>
public FontAtlasAutoRebuildMode AutoRebuildMode { get; }
/// <inheritdoc/>
public ImFontAtlasPtr ImAtlas
{
get
{
lock (this.syncRoot)
return this.builtData?.Atlas ?? default;
}
}
/// <inheritdoc/>
public Task BuildTask => this.buildTask;
/// <inheritdoc/>
public bool HasBuiltAtlas => !(this.builtData?.Atlas.IsNull() ?? true);
/// <inheritdoc/>
public bool IsGlobalScaled { get; }
/// <inheritdoc/>
public void Dispose()
{
if (this.disposed)
return;
this.BeforeDispose?.InvokeSafely(this);
try
{
lock (this.syncRoot)
{
this.disposed = true;
this.buildTask.ToDisposableIgnoreExceptions().Dispose();
this.buildTask = EmptyTask;
this.disposables.Add(this.builtData);
this.builtData = default;
this.disposables.Dispose();
}
try
{
this.AfterDispose?.Invoke(this, null);
}
catch
{
// ignore
}
}
catch (Exception e)
{
try
{
this.AfterDispose?.Invoke(this, e);
}
catch
{
// ignore
}
}
GC.SuppressFinalize(this);
}
/// <inheritdoc/>
public IDisposable SuppressAutoRebuild()
{
this.buildSuppressionCounter++;
return Disposable.Create(
() =>
{
this.buildSuppressionCounter--;
if (this.buildSuppressionSuppressed)
this.OnRebuildRecommend();
});
}
/// <inheritdoc/>
public IFontHandle NewGameFontHandle(GameFontStyle style) => this.gameFontHandleManager.NewFontHandle(style);
/// <inheritdoc/>
public IFontHandle NewDelegateFontHandle(FontAtlasBuildStepDelegate buildStepDelegate) =>
this.delegateFontHandleManager.NewFontHandle(buildStepDelegate);
/// <inheritdoc/>
public void BuildFontsOnNextFrame()
{
if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async)
{
throw new InvalidOperationException(
$"{nameof(this.BuildFontsOnNextFrame)} cannot be used when " +
$"{nameof(this.AutoRebuildMode)} is set to " +
$"{nameof(FontAtlasAutoRebuildMode.Async)}.");
}
if (!this.buildTask.IsCompleted || this.buildQueued)
return;
#if VeryVerboseLog
Log.Verbose("[{name}] Queueing from {source}.", this.Name, nameof(this.BuildFontsOnNextFrame));
#endif
this.buildQueued = true;
}
/// <inheritdoc/>
public void BuildFontsImmediately()
{
#if VeryVerboseLog
Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsImmediately));
#endif
if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.Async)
{
throw new InvalidOperationException(
$"{nameof(this.BuildFontsImmediately)} cannot be used when " +
$"{nameof(this.AutoRebuildMode)} is set to " +
$"{nameof(FontAtlasAutoRebuildMode.Async)}.");
}
var tcs = new TaskCompletionSource<FontAtlasBuiltData>();
try
{
var rebuildIndex = Interlocked.Increment(ref this.buildIndex);
lock (this.syncRoot)
{
if (!this.buildTask.IsCompleted)
throw new InvalidOperationException("Font rebuild is already in progress.");
this.buildTask = tcs.Task;
}
#if VeryVerboseLog
Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsImmediately));
#endif
var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f;
var r = this.RebuildFontsPrivate(false, scale);
r.Wait();
if (r.IsCompletedSuccessfully)
{
this.PromoteBuiltData(rebuildIndex, r.Result, nameof(this.BuildFontsImmediately));
tcs.SetResult(r.Result);
}
else if ((r.Exception?.InnerException ?? r.Exception) is { } taskException)
{
ExceptionDispatchInfo.Capture(taskException).Throw();
}
else
{
throw new OperationCanceledException();
}
}
catch (Exception e)
{
tcs.SetException(e);
Log.Error(e, "[{name}] Failed to build fonts.", this.Name);
throw;
}
}
/// <inheritdoc/>
public Task BuildFontsAsync()
{
#if VeryVerboseLog
Log.Verbose("[{name}] Called: {source}.", this.Name, nameof(this.BuildFontsAsync));
#endif
if (this.AutoRebuildMode == FontAtlasAutoRebuildMode.OnNewFrame)
{
throw new InvalidOperationException(
$"{nameof(this.BuildFontsAsync)} cannot be used when " +
$"{nameof(this.AutoRebuildMode)} is set to " +
$"{nameof(FontAtlasAutoRebuildMode.OnNewFrame)}.");
}
lock (this.syncRoot)
{
var scale = this.IsGlobalScaled ? ImGuiHelpers.GlobalScaleSafe : 1f;
var rebuildIndex = Interlocked.Increment(ref this.buildIndex);
return this.buildTask = this.buildTask.ContinueWith(BuildInner).Unwrap();
async Task<FontAtlasBuiltData?> BuildInner(Task<FontAtlasBuiltData> unused)
{
Log.Verbose("[{name}] Building from {source}.", this.Name, nameof(this.BuildFontsAsync));
lock (this.syncRoot)
{
if (this.buildIndex != rebuildIndex)
return null;
}
var res = await this.RebuildFontsPrivate(true, scale);
if (res.Atlas.IsNull())
return res;
this.PromoteBuiltData(rebuildIndex, res, nameof(this.BuildFontsAsync));
return res;
}
}
}
private void PromoteBuiltData(int rebuildIndex, FontAtlasBuiltData data, [UsedImplicitly] string source)
{
// Capture the locks inside the lock block, so that the fonts are guaranteed to be the ones just built.
var fontsAndLocks = new List<(FontHandle FontHandle, ILockedImFont Lock)>();
using var garbage = new DisposeSafety.ScopedFinalizer();
lock (this.syncRoot)
{
if (this.buildIndex != rebuildIndex)
{
data.ExplicitDisposeIgnoreExceptions();
return;
}
var prevBuiltData = this.builtData;
this.builtData = data;
prevBuiltData.ExplicitDisposeIgnoreExceptions();
this.buildTask = EmptyTask;
fontsAndLocks.EnsureCapacity(data.Substances.Sum(x => x.RelevantHandles.Count));
foreach (var substance in data.Substances)
{
substance.Manager.Substance = substance;
foreach (var fontHandle in substance.RelevantHandles)
{
substance.DataRoot.AddRef();
var locked = new LockedImFont(
substance.GetFontPtr(fontHandle),
substance.DataRoot);
fontsAndLocks.Add((fontHandle, garbage.Add(locked)));
}
}
}
foreach (var (fontHandle, lockedFont) in fontsAndLocks)
fontHandle.InvokeImFontChanged(lockedFont);
#if VeryVerboseLog
Log.Verbose("[{name}] Built from {source}.", this.Name, source);
#endif
}
private void ImGuiSceneOnNewRenderFrame()
{
if (!this.buildQueued)
return;
try
{
if (this.AutoRebuildMode != FontAtlasAutoRebuildMode.Async)
this.BuildFontsImmediately();
}
finally
{
this.buildQueued = false;
}
}
private Task<FontAtlasBuiltData> RebuildFontsPrivate(bool isAsync, float scale)
{
if (NoConcurrentBuildOperationLock is null)
return this.RebuildFontsPrivateReal(isAsync, scale);
lock (NoConcurrentBuildOperationLock)
return this.RebuildFontsPrivateReal(isAsync, scale);
}
private async Task<FontAtlasBuiltData> RebuildFontsPrivateReal(bool isAsync, float scale)
{
lock (this.syncRoot)
{
// this lock ensures that this.buildTask is properly set.
}
var sw = new Stopwatch();
sw.Start();
FontAtlasBuiltData? res = null;
nint atlasPtr = 0;
BuildToolkit? toolkit = null;
try
{
res = new(this, scale);
foreach (var fhm in this.fontHandleManagers)
res.InitialAddSubstance(fhm.NewSubstance(res));
unsafe
{
atlasPtr = (nint)res.Atlas.NativePtr;
}
Log.Verbose(
"[{name}:{functionname}] 0x{ptr:X}: PreBuild (at {sw}ms)",
this.Name,
nameof(this.RebuildFontsPrivateReal),
atlasPtr,
sw.ElapsedMilliseconds);
toolkit = res.CreateToolkit(this.factory, isAsync);
this.BuildStepChange?.Invoke(toolkit);
toolkit.PreBuildSubstances();
toolkit.PreBuild();
// Prevent NewImAtlas.ConfigData[].DstFont pointing to a font not owned by the new atlas,
// by making it add a font with default configuration first instead.
if (!ValidateMergeFontReferences(default))
{
Log.Warning(
"[{name}:{functionname}] 0x{ptr:X}: refering to fonts outside the new atlas; " +
"adding a default font, and using that as the merge target.",
this.Name,
nameof(this.RebuildFontsPrivateReal),
atlasPtr);
res.IsBuildInProgress = false;
toolkit.Dispose();
res.Release();
res = new(this, scale);
foreach (var fhm in this.fontHandleManagers)
res.InitialAddSubstance(fhm.NewSubstance(res));
unsafe
{
atlasPtr = (nint)res.Atlas.NativePtr;
}
toolkit = res.CreateToolkit(this.factory, isAsync);
// PreBuildSubstances deals with toolkit.Add... function family. Do this first.
var defaultFont = toolkit.AddDalamudDefaultFont(-1, null);
this.BuildStepChange?.Invoke(toolkit);
toolkit.PreBuildSubstances();
toolkit.PreBuild();
_ = ValidateMergeFontReferences(defaultFont);
}
#if VeryVerboseLog
Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: Build (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds);
#endif
toolkit.DoBuild();
#if VeryVerboseLog
Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: PostBuild (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds);
#endif
toolkit.PostBuild();
toolkit.PostBuildSubstances();
toolkit.PostBuildCallbacks();
this.BuildStepChange?.Invoke(toolkit);
foreach (var font in toolkit.Fonts)
toolkit.BuildLookupTable(font);
if (this.factory.SceneTask is { IsCompleted: false } sceneTask)
{
Log.Verbose(
"[{name}:{functionname}] 0x{ptr:X}: await SceneTask (at {sw}ms)",
this.Name,
nameof(this.RebuildFontsPrivateReal),
atlasPtr,
sw.ElapsedMilliseconds);
await sceneTask.ConfigureAwait(!isAsync);
}
#if VeryVerboseLog
Log.Verbose("[{name}:{functionname}] 0x{ptr:X}: UploadTextures (at {sw}ms)", this.Name, nameof(this.RebuildFontsPrivateReal), atlasPtr, sw.ElapsedMilliseconds);
#endif
toolkit.UploadTextures();
Log.Verbose(
"[{name}:{functionname}] 0x{ptr:X}: Complete (at {sw}ms)",
this.Name,
nameof(this.RebuildFontsPrivateReal),
atlasPtr,
sw.ElapsedMilliseconds);
res.IsBuildInProgress = false;
return res;
}
catch (Exception e)
{
Log.Error(
e,
"[{name}:{functionname}] 0x{ptr:X}: Failed (at {sw}ms)",
this.Name,
nameof(this.RebuildFontsPrivateReal),
atlasPtr,
sw.ElapsedMilliseconds);
if (res is not null)
{
res.IsBuildInProgress = false;
res.Release();
}
throw;
}
finally
{
// RS is being dumb
// ReSharper disable once ConstantConditionalAccessQualifier
toolkit?.Dispose();
this.buildQueued = false;
}
unsafe bool ValidateMergeFontReferences(ImFontPtr replacementDstFont)
{
var correct = true;
foreach (ref var configData in toolkit.NewImAtlas.ConfigDataWrapped().DataSpan)
{
var found = false;
foreach (ref var font in toolkit.Fonts.DataSpan)
{
if (configData.DstFont == font)
{
found = true;
break;
}
}
if (!found)
{
correct = false;
configData.DstFont = replacementDstFont;
}
}
return correct;
}
}
private void OnRebuildRecommend()
{
if (this.disposed)
return;
if (this.buildSuppressionCounter > 0)
{
this.buildSuppressionSuppressed = true;
return;
}
this.buildSuppressionSuppressed = false;
this.factory.Framework.RunOnFrameworkThread(
() =>
{
this.RebuildRecommend?.InvokeSafely();
switch (this.AutoRebuildMode)
{
case FontAtlasAutoRebuildMode.Async:
_ = this.BuildFontsAsync();
break;
case FontAtlasAutoRebuildMode.OnNewFrame:
this.BuildFontsOnNextFrame();
break;
case FontAtlasAutoRebuildMode.Disable:
default:
break;
}
});
}
}
}